diff --git a/app/javascript/controllers/onboarding_controller.js b/app/javascript/controllers/onboarding_controller.js index 7e410d916..19c3c9ee8 100644 --- a/app/javascript/controllers/onboarding_controller.js +++ b/app/javascript/controllers/onboarding_controller.js @@ -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; diff --git a/app/models/family.rb b/app/models/family.rb index b0f7f2ec2..c2ae27dca 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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) }` diff --git a/app/views/onboardings/preferences.html.erb b/app/views/onboardings/preferences.html.erb index a6e0c68a0..e804979c1 100644 --- a/app/views/onboardings/preferences.html.erb +++ b/app/views/onboardings/preferences.html.erb @@ -8,7 +8,15 @@ <%= render "onboardings/logout" %> <% end %> -
+<% 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 %> + +

<%= t(".title") %>

@@ -19,10 +27,10 @@
<%= 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" %>

- +<%= format_money(Money.new(78.90, params[:currency] || @user.family.currency)) %> - (+<%= format_money(Money.new(6.39, params[:currency] || @user.family.currency)) %>) + +<%= format_money(Money.new(78.90, selected_currency)) %> + (+<%= format_money(Money.new(6.39, selected_currency)) %>) as of <%= format_date(Date.parse("2024-10-23"), :default, format_code: params[:date_format] || @user.family.date_format) %>

@@ -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, diff --git a/app/views/onboardings/show.html.erb b/app/views/onboardings/show.html.erb index 38d90b378..e3cf457f5 100644 --- a/app/views/onboardings/show.html.erb +++ b/app/views/onboardings/show.html.erb @@ -10,7 +10,12 @@ <%= render "onboardings/logout" %> <% end %> -
+
" + 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") %>">

<%= t(".title") %>

@@ -34,12 +39,7 @@
<%= form.fields_for :family do |family_form| %>
-
" - 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") %>"> +

<%= t(".moniker_prompt", product_name: product_name) %>

<% end %> diff --git a/test/controllers/onboardings_controller_test.rb b/test/controllers/onboardings_controller_test.rb index 2c83854db..d1bfbe4cf 100644 --- a/test/controllers/onboardings_controller_test.rb +++ b/test/controllers/onboardings_controller_test.rb @@ -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 diff --git a/test/models/family_test.rb b/test/models/family_test.rb index b214e5c1e..d84e01759 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -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) diff --git a/test/system/onboardings_test.rb b/test/system/onboardings_test.rb index 19410b53a..76fc3e5f5 100644 --- a/test/system/onboardings_test.rb +++ b/test/system/onboardings_test.rb @@ -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