Fix onboarding country and currency defaults (#3070)

* fix: derive onboarding currency from country

* feat: use browser locale for onboarding defaults

* Fix onboarding currency defaults

* Preserve saved onboarding currency during hydration

---------

Co-authored-by: sure-admin <sure-admin@splashblot.com>
This commit is contained in:
Atlas
2026-08-20 06:30:14 +02:00
committed by GitHub
co-authored by sure-admin
parent fd01a5b3dc
commit eb382b8899
7 changed files with 177 additions and 26 deletions
@@ -2,16 +2,20 @@ import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="onboarding"
export default class extends Controller {
static targets = ["nameField", "monikerRadio"]
static targets = ["nameField", "monikerRadio", "countryField", "currencyField"]
static values = {
householdNameLabel: String,
householdNamePlaceholder: String,
groupNameLabel: String,
groupNamePlaceholder: String
groupNamePlaceholder: String,
country: String,
countryCurrencies: Object,
currencyOverride: Boolean
}
connect() {
this.updateNameFieldForCurrentMoniker();
this.applyBrowserLocaleDefaults();
}
setLocale(event) {
@@ -30,6 +34,43 @@ export default class extends Controller {
document.documentElement.setAttribute("data-theme", event.target.value);
}
applyBrowserLocaleDefaults() {
const browserCountry = this.browserCountry();
if (this.hasCountryFieldTarget && browserCountry && this.countryFieldTarget.value === "US" && this.hasCountryOption(browserCountry)) {
this.countryFieldTarget.value = browserCountry;
}
if (this.hasCurrencyFieldTarget && !this.currencyOverrideValue) {
const country = this.countryValue || (this.hasCountryFieldTarget ? this.countryFieldTarget.value : null) || browserCountry;
const currency = this.currencyForCountry(country);
if (currency && this.hasCurrencyOption(currency)) {
this.currencyFieldTarget.value = currency;
}
}
}
browserCountry() {
try {
return new Intl.Locale(navigator.language).maximize().region;
} catch (_error) {
return null;
}
}
currencyForCountry(country) {
return (this.hasCountryCurrenciesValue ? this.countryCurrenciesValue : {})[country?.toUpperCase()];
}
hasCountryOption(country) {
return Array.from(this.countryFieldTarget.options).some((option) => option.value === country);
}
hasCurrencyOption(currency) {
return Array.from(this.currencyFieldTarget.options).some((option) => option.value === currency);
}
updateNameFieldForCurrentMoniker(event = null) {
if (!this.hasNameFieldTarget) {
return;
+23 -10
View File
@@ -151,7 +151,28 @@ class Family < ApplicationRecord
before_validation :normalize_enabled_currencies!
def primary_currency_code
normalize_currency_code(currency) || "USD"
self.class.normalize_currency_code(currency) || "USD"
end
def default_currency_for_country
self.class.default_currency_for_country(country)
end
def self.default_currency_for_country(country)
country_currency = ISO3166::Country.new(country.to_s.upcase)&.currency_code
normalize_currency_code(country_currency) || "USD"
end
def self.default_currency_by_country
LanguagesHelper::COUNTRY_MAPPING.keys.index_with { |country| default_currency_for_country(country) }
end
def self.normalize_currency_code(value)
return if value.blank?
Money::Currency.new(value).iso_code
rescue Money::Currency::UnknownCurrencyError, ArgumentError
nil
end
def custom_enabled_currencies?
@@ -491,15 +512,7 @@ class Family < ApplicationRecord
end
def normalize_currency_codes(values)
Array(values).filter_map { |value| normalize_currency_code(value) }.uniq
end
def normalize_currency_code(value)
return if value.blank?
Money::Currency.new(value).iso_code
rescue Money::Currency::UnknownCurrencyError, ArgumentError
nil
Array(values).filter_map { |value| self.class.normalize_currency_code(value) }.uniq
end
# Not a plain `inclusion: { in: ActiveSupport::TimeZone.all.map(&:name) }`
+14 -6
View File
@@ -8,7 +8,15 @@
<%= render "onboardings/logout" %>
<% end %>
<div class="grow max-w-lg w-full mx-auto bg-surface flex flex-col justify-center md:py-0 py-6 px-4 md:px-0" data-controller="onboarding">
<% currency_override = Family.normalize_currency_code(params[:currency]) %>
<% saved_currency = @user.family.primary_currency_code if @user.set_onboarding_preferences_at? %>
<% selected_currency = currency_override || saved_currency || @user.family.default_currency_for_country %>
<div class="grow max-w-lg w-full mx-auto bg-surface flex flex-col justify-center md:py-0 py-6 px-4 md:px-0"
data-controller="onboarding"
data-onboarding-country-value="<%= @user.family.country %>"
data-onboarding-country-currencies-value="<%= Family.default_currency_by_country.to_json %>"
data-onboarding-currency-override-value="<%= currency_override.present? || saved_currency.present? %>">
<div>
<div class="space-y-1 mb-6">
<h1 class="text-2xl font-medium"><%= t(".title") %></h1>
@@ -19,10 +27,10 @@
<div class="bg-container p-4 rounded-lg flex gap-8 shadow-border-xs">
<div class="space-y-2">
<%= tag.p t(".example"), class: "text-secondary text-sm" %>
<%= tag.p format_money(Money.new(2325.25, params[:currency] || @user.family.currency)), class: "text-primary font-medium text-2xl" %>
<%= tag.p format_money(Money.new(2325.25, selected_currency)), class: "text-primary font-medium text-2xl" %>
<p class="text-sm">
<span class="text-green-500 font-medium">+<%= format_money(Money.new(78.90, params[:currency] || @user.family.currency)) %></span>
<span class="text-green-500 font-medium">(+<%= format_money(Money.new(6.39, params[:currency] || @user.family.currency)) %>)</span>
<span class="text-green-500 font-medium">+<%= format_money(Money.new(78.90, selected_currency)) %></span>
<span class="text-green-500 font-medium">(+<%= format_money(Money.new(6.39, selected_currency)) %>)</span>
<span class="text-secondary">as of <%= format_date(Date.parse("2024-10-23"), :default, format_code: params[:date_format] || @user.family.date_format) %></span>
</p>
</div>
@@ -81,8 +89,8 @@
<%= family_form.select :currency,
Money::Currency.as_options.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] },
{ label: t(".currency"), required: true, selected: params[:currency] || @user.family.currency },
{ data: { action: "onboarding#setCurrency" } } %>
{ label: t(".currency"), required: true, selected: selected_currency },
{ data: { action: "onboarding#setCurrency", onboarding_target: "currencyField" } } %>
<%= family_form.select :date_format,
Family::DATE_FORMATS,
+8 -8
View File
@@ -10,7 +10,12 @@
<%= render "onboardings/logout" %>
<% end %>
<div class="grow max-w-lg w-full mx-auto bg-surface flex flex-col justify-center md:py-0 py-2 px-4 md:px-0">
<div class="grow max-w-lg w-full mx-auto bg-surface flex flex-col justify-center md:py-0 py-2 px-4 md:px-0"
data-controller="onboarding"
data-onboarding-household-name-label-value="<%= t(".household_name") %>"
data-onboarding-household-name-placeholder-value="<%= t(".household_name_placeholder") %>"
data-onboarding-group-name-label-value="<%= t(".group_name") %>"
data-onboarding-group-name-placeholder-value="<%= t(".group_name_placeholder") %>">
<div>
<div class="space-y-1 mb-6 text-center">
<h1 class="text-2xl font-medium md:text-2xl"><%= t(".title") %></h1>
@@ -34,12 +39,7 @@
<div class="space-y-4 mb-4">
<%= form.fields_for :family do |family_form| %>
<div class="bg-container rounded-lg shadow-border-xs p-4">
<div class="space-y-2"
data-controller="onboarding"
data-onboarding-household-name-label-value="<%= t(".household_name") %>"
data-onboarding-household-name-placeholder-value="<%= t(".household_name_placeholder") %>"
data-onboarding-group-name-label-value="<%= t(".group_name") %>"
data-onboarding-group-name-placeholder-value="<%= t(".group_name_placeholder") %>">
<div class="space-y-2">
<p class="text-sm font-medium text-primary"><%= t(".moniker_prompt", product_name: product_name) %></p>
<label class="flex items-center gap-2 text-sm text-primary">
@@ -67,7 +67,7 @@
<%= family_form.select :country,
country_options,
{ label: t(".country") },
required: true %>
{ required: true, data: { onboarding_target: "countryField" } } %>
<% end %>
</div>
<% end %>
@@ -109,6 +109,44 @@ class OnboardingsControllerTest < ActionDispatch::IntegrationTest
assert_select "span", text: /\+\$78\.90/
end
test "preferences page derives currency from the onboarding country" do
@family.update!(country: "CA", currency: "USD")
get preferences_onboarding_url
assert_response :success
assert_select "select[name='user[family_attributes][currency]'] option[selected][value='CAD']"
end
test "preferences page keeps the saved currency after preferences have been set" do
@family.update!(country: "CA", currency: "USD")
@user.update!(set_onboarding_preferences_at: Time.current)
get preferences_onboarding_url
assert_response :success
assert_select "select[name='user[family_attributes][currency]'] option[selected][value='USD']"
end
test "preferences page preserves an explicit currency override" do
@family.update!(country: "CA", currency: "USD")
get preferences_onboarding_url, params: { currency: "USD" }
assert_response :success
assert_select "select[name='user[family_attributes][currency]'] option[selected][value='USD']"
end
test "preferences page ignores an unsupported currency override" do
@family.update!(country: "CA", currency: "USD")
get preferences_onboarding_url, params: { currency: "NOPE" }
assert_response :success
assert_select "[data-onboarding-currency-override-value='false']"
assert_select "select[name='user[family_attributes][currency]'] option[selected][value='CAD']"
end
test "preferences page shows date formatting example" do
get preferences_onboarding_url
assert_response :success
+7
View File
@@ -165,6 +165,13 @@ class FamilyTest < ActiveSupport::TestCase
assert_equal "Groups", family.moniker_label_plural
end
test "default currency comes from country ISO data" do
assert_equal "CAD", Family.default_currency_for_country("CA")
assert_equal "EUR", Family.default_currency_for_country("DE")
assert_equal "BRL", Family.default_currency_for_country("BR")
assert_equal "USD", Family.default_currency_for_country("unknown")
end
test "available_merchants includes family merchants without transactions" do
family = families(:dylan_family)
+44
View File
@@ -48,6 +48,38 @@ class OnboardingsTest < ApplicationSystemTestCase
assert_text I18n.t("onboardings.goals.title")
end
test "browser locale defaults onboarding country and currency" do
@family.update!(country: "US", currency: "USD")
set_browser_language("en-CA")
visit onboarding_path
assert_equal "CA", find("#user_family_attributes_country").value
click_button I18n.t("onboardings.show.submit")
assert_current_path preferences_onboarding_path
assert_equal "CAD", find("#user_family_attributes_currency").value
click_button I18n.t("onboardings.preferences.submit")
assert_current_path goals_onboarding_path
@family.reload
assert_equal "CA", @family.country
assert_equal "CAD", @family.currency
end
test "saved currency stays authoritative after onboarding controller connects" do
@family.update!(country: "CA", currency: "USD")
@user.update!(set_onboarding_preferences_at: Time.current)
set_browser_language("en-CA")
visit preferences_onboarding_path
assert_selector "[data-onboarding-currency-override-value='true']"
assert_equal "USD", find("#user_family_attributes_currency").value
end
test "preferences page renders chart without errors" do
visit preferences_onboarding_path
@@ -194,6 +226,18 @@ class OnboardingsTest < ApplicationSystemTestCase
.select_option
end
def set_browser_language(language)
page.driver.browser.execute_cdp(
"Page.addScriptToEvaluateOnNewDocument",
source: <<~JS
Object.defineProperty(navigator, "language", {
get: () => "#{language}",
configurable: true
});
JS
)
end
def sign_in(user)
visit new_session_path
within %(form[action='#{sessions_path}']) do