From 1d20de770f7968ff85c1c76ab7f19c3faeeaf5f9 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 23 Oct 2024 11:20:55 -0400 Subject: [PATCH] User Onboarding + Bug Fixes (#1352) * Bump min supported date to 20 years * Add basic onboarding * User onboarding * Complete onboarding flow * Cleanup, add user profile update test --- app/assets/images/.keep | 0 app/assets/images/logo-color.png | Bin 0 -> 9326 bytes app/controllers/application_controller.rb | 2 +- app/controllers/concerns/.keep | 0 app/controllers/concerns/onboardable.rb | 17 + app/controllers/onboardings_controller.rb | 19 + app/controllers/sessions_controller.rb | 2 +- .../settings/billings_controller.rb | 3 + .../settings/preferences_controller.rb | 25 +- .../settings/profiles_controller.rb | 35 +- app/controllers/users_controller.rb | 51 +++ app/helpers/application_helper.rb | 26 ++ app/helpers/languages_helper.rb | 370 ++++++++++++++++++ app/helpers/styled_form_builder.rb | 18 +- .../controllers/onboarding_controller.js | 29 ++ .../profile_image_preview_controller.js | 46 +-- app/models/account/entry.rb | 2 +- app/models/family.rb | 3 + app/models/user.rb | 2 +- app/views/layouts/_footer.html.erb | 7 + app/views/layouts/_sidebar.html.erb | 22 +- app/views/layouts/application.html.erb | 2 +- app/views/layouts/auth.html.erb | 48 +-- app/views/onboardings/_header.html.erb | 8 + app/views/onboardings/preferences.html.erb | 88 +++++ app/views/onboardings/profile.html.erb | 40 ++ app/views/onboardings/show.html.erb | 11 + app/views/registrations/new.html.erb | 2 +- app/views/sessions/new.html.erb | 2 +- app/views/settings/_user_avatar.html.erb | 7 + .../settings/_user_avatar_field.html.erb | 52 +++ app/views/settings/billings/show.html.erb | 2 +- app/views/settings/preferences/show.html.erb | 25 +- app/views/settings/profiles/show.html.erb | 44 +-- app/views/shared/_money_field.html.erb | 7 +- app/views/shared/_notification.html.erb | 2 +- app/views/shared/_user_profile_image.html.erb | 1 - config/locales/views/accounts/en.yml | 139 ++++--- .../views/impersonation_sessions/en.yml | 20 +- config/locales/views/imports/en.yml | 5 +- config/locales/views/layout/en.yml | 12 +- config/locales/views/onboardings/en.yml | 28 ++ config/locales/views/pages/en.yml | 2 +- config/locales/views/registrations/en.yml | 1 + config/locales/views/sessions/en.yml | 1 - config/locales/views/settings/en.yml | 17 +- config/locales/views/users/en.yml | 7 + config/routes.rb | 19 +- .../20241022221544_add_onboarding_fields.rb | 7 + db/schema.rb | 7 +- test/controllers/sessions_controller_test.rb | 2 +- .../settings/profiles_controller_test.rb | 31 -- test/controllers/users_controller_test.rb | 64 +++ test/fixtures/users.yml | 4 + test/system/imports_test.rb | 2 +- 55 files changed, 1088 insertions(+), 300 deletions(-) delete mode 100644 app/assets/images/.keep create mode 100644 app/assets/images/logo-color.png delete mode 100644 app/controllers/concerns/.keep create mode 100644 app/controllers/concerns/onboardable.rb create mode 100644 app/controllers/onboardings_controller.rb create mode 100644 app/controllers/users_controller.rb create mode 100644 app/helpers/languages_helper.rb create mode 100644 app/javascript/controllers/onboarding_controller.js create mode 100644 app/views/layouts/_footer.html.erb create mode 100644 app/views/onboardings/_header.html.erb create mode 100644 app/views/onboardings/preferences.html.erb create mode 100644 app/views/onboardings/profile.html.erb create mode 100644 app/views/onboardings/show.html.erb create mode 100644 app/views/settings/_user_avatar.html.erb create mode 100644 app/views/settings/_user_avatar_field.html.erb delete mode 100644 app/views/shared/_user_profile_image.html.erb create mode 100644 config/locales/views/onboardings/en.yml create mode 100644 config/locales/views/users/en.yml create mode 100644 db/migrate/20241022221544_add_onboarding_fields.rb create mode 100644 test/controllers/users_controller_test.rb diff --git a/app/assets/images/.keep b/app/assets/images/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/assets/images/logo-color.png b/app/assets/images/logo-color.png new file mode 100644 index 0000000000000000000000000000000000000000..f536c33e4abfb5719db349cd080a5e0c366962f7 GIT binary patch literal 9326 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 cYfg$J2#SSayZ`_I07*qoM6N<$f<+lc-~a#s literal 0 HcmV?d00001 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b6fa8e0c..8fd5c552 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,5 @@ class ApplicationController < ActionController::Base - include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable + include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable include Pagy::Backend private diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/controllers/concerns/onboardable.rb b/app/controllers/concerns/onboardable.rb new file mode 100644 index 00000000..80b15990 --- /dev/null +++ b/app/controllers/concerns/onboardable.rb @@ -0,0 +1,17 @@ +module Onboardable + extend ActiveSupport::Concern + + included do + before_action :redirect_to_onboarding, if: :needs_onboarding? + end + + private + def redirect_to_onboarding + redirect_to onboarding_path + end + + def needs_onboarding? + Current.user && Current.user.onboarded_at.blank? && + !%w[/users /onboarding /sessions].any? { |path| request.path.start_with?(path) } + end +end diff --git a/app/controllers/onboardings_controller.rb b/app/controllers/onboardings_controller.rb new file mode 100644 index 00000000..4fb5386f --- /dev/null +++ b/app/controllers/onboardings_controller.rb @@ -0,0 +1,19 @@ +class OnboardingsController < ApplicationController + layout "application" + + before_action :set_user + + def show + end + + def profile + end + + def preferences + end + + private + def set_user + @user = Current.user + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index b6a23195..a1fa08c2 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -19,7 +19,7 @@ class SessionsController < ApplicationController def destroy @session.destroy - redirect_to root_path, notice: t(".logout_successful") + redirect_to new_session_path, notice: t(".logout_successful") end private diff --git a/app/controllers/settings/billings_controller.rb b/app/controllers/settings/billings_controller.rb index d6dc4053..2eb6c49b 100644 --- a/app/controllers/settings/billings_controller.rb +++ b/app/controllers/settings/billings_controller.rb @@ -1,2 +1,5 @@ class Settings::BillingsController < SettingsController + def show + @user = Current.user + end end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 6389d9a3..4f4fc1f8 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -1,26 +1,5 @@ class Settings::PreferencesController < SettingsController - def edit + def show + @user = Current.user end - - def update - preference_params_with_family = preference_params - - if Current.family && preference_params[:family_attributes] - family_attributes = preference_params[:family_attributes].merge({ id: Current.family.id }) - preference_params_with_family[:family_attributes] = family_attributes - end - - if Current.user.update(preference_params_with_family) - redirect_to settings_preferences_path, notice: t(".success") - else - redirect_to settings_preferences_path, notice: t(".success") - render :show, status: :unprocessable_entity - end - end - - private - - def preference_params - params.require(:user).permit(family_attributes: [ :id, :currency, :locale ]) - end end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index c6b93c2c..0caca54c 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -1,38 +1,5 @@ class Settings::ProfilesController < SettingsController def show + @user = Current.user end - - def update - user_params_with_family = user_params - - if params[:user][:delete_profile_image] == "true" - Current.user.profile_image.purge - end - - if Current.family && user_params_with_family[:family_attributes] - family_attributes = user_params_with_family[:family_attributes].merge({ id: Current.family.id }) - user_params_with_family[:family_attributes] = family_attributes - end - - if Current.user.update(user_params_with_family) - redirect_to settings_profile_path, notice: t(".success") - else - redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence - end - end - - def destroy - if Current.user.deactivate - Current.session.destroy - redirect_to root_path, notice: t(".success") - else - redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence - end - end - - private - def user_params - params.require(:user).permit(:first_name, :last_name, :profile_image, - family_attributes: [ :name, :id ]) - end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 00000000..2dfae623 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,51 @@ +class UsersController < ApplicationController + before_action :set_user + + def update + @user = Current.user + + @user.update!(user_params.except(:redirect_to, :delete_profile_image)) + @user.profile_image.purge if should_purge_profile_image? + + handle_redirect(t(".success")) + end + + def destroy + if @user.deactivate + Current.session.destroy + redirect_to root_path, notice: t(".success") + else + redirect_to settings_profile_path, alert: @user.errors.full_messages.to_sentence + end + end + + private + def handle_redirect(notice) + case user_params[:redirect_to] + when "onboarding_preferences" + redirect_to preferences_onboarding_path + when "home" + redirect_to root_path + when "preferences" + redirect_to settings_preferences_path, notice: notice + else + redirect_to settings_profile_path, notice: notice + end + end + + def should_purge_profile_image? + user_params[:delete_profile_image] == "1" && + user_params[:profile_image].blank? + end + + def user_params + params.require(:user).permit( + :first_name, :last_name, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, + family_attributes: [ :name, :currency, :country, :locale, :date_format, :id ] + ) + end + + def set_user + @user = Current.user + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 19aa187e..ca83e38d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,6 +1,19 @@ module ApplicationHelper include Pagy::Frontend + def date_format_options + [ + [ "DD-MM-YYYY", "%d-%m-%Y" ], + [ "MM-DD-YYYY", "%m-%d-%Y" ], + [ "YYYY-MM-DD", "%Y-%m-%d" ], + [ "DD/MM/YYYY", "%d/%m/%Y" ], + [ "YYYY/MM/DD", "%Y/%m/%d" ], + [ "MM/DD/YYYY", "%m/%d/%Y" ], + [ "D/MM/YYYY", "%e/%m/%Y" ], + [ "YYYY.MM.DD", "%Y.%m.%d" ] + ] + end + def title(page_title) content_for(:title) { page_title } end @@ -132,6 +145,19 @@ module ApplicationHelper end end + # Wrapper around I18n.l to support custom date formats + def format_date(object, format = :default, options = {}) + date = object.to_date + + format_code = options[:format_code] || Current.family&.date_format + + if format_code.present? + date.strftime(format_code) + else + I18n.l(date, format: format, **options) + end + end + def format_money(number_or_money, options = {}) return nil unless number_or_money diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb new file mode 100644 index 00000000..db47b3a0 --- /dev/null +++ b/app/helpers/languages_helper.rb @@ -0,0 +1,370 @@ +module LanguagesHelper + LANGUAGE_MAPPING = { + en: "English", + ru: "Russian", + ar: "Arabic", + bg: "Bulgarian", + 'ca-CAT': "Catalan (Catalonia)", + ca: "Catalan", + 'da-DK': "Danish (Denmark)", + 'de-AT': "German (Austria)", + 'de-CH': "German (Switzerland)", + de: "German", + ee: "Ewe", + 'en-AU': "English (Australia)", + 'en-BORK': "English (Bork)", + 'en-CA': "English (Canada)", + 'en-GB': "English (United Kingdom)", + 'en-IND': "English (India)", + 'en-KE': "English (Kenya)", + 'en-MS': "English (Malaysia)", + 'en-NEP': "English (Nepal)", + 'en-NG': "English (Nigeria)", + 'en-NZ': "English (New Zealand)", + 'en-PAK': "English (Pakistan)", + 'en-SG': "English (Singapore)", + 'en-TH': "English (Thailand)", + 'en-UG': "English (Uganda)", + 'en-US': "English (United States)", + 'en-ZA': "English (South Africa)", + 'en-au-ocker': "English (Australian Ocker)", + 'es-AR': "Spanish (Argentina)", + 'es-MX': "Spanish (Mexico)", + es: "Spanish", + fa: "Persian", + 'fi-FI': "Finnish (Finland)", + fr: "French", + 'fr-CA': "French (Canada)", + 'fr-CH': "French (Switzerland)", + he: "Hebrew", + hy: "Armenian", + id: "Indonesian", + it: "Italian", + ja: "Japanese", + ko: "Korean", + lt: "Lithuanian", + lv: "Latvian", + 'mi-NZ': "Maori (New Zealand)", + 'nb-NO': "Norwegian Bokmål (Norway)", + nl: "Dutch", + 'no-NO': "Norwegian (Norway)", + pl: "Polish", + 'pt-BR': "Portuguese (Brazil)", + pt: "Portuguese", + sk: "Slovak", + sv: "Swedish", + th: "Thai", + tr: "Turkish", + uk: "Ukrainian", + vi: "Vietnamese", + 'zh-CN': "Chinese (Simplified)", + 'zh-TW': "Chinese (Traditional)", + af: "Afrikaans", + az: "Azerbaijani", + be: "Belarusian", + bn: "Bengali", + bs: "Bosnian", + cs: "Czech", + cy: "Welsh", + da: "Danish", + 'de-DE': "German (Germany)", + dz: "Dzongkha", + 'el-CY': "Greek (Cyprus)", + el: "Greek", + 'en-CY': "English (Cyprus)", + 'en-IE': "English (Ireland)", + 'en-IN': "English (India)", + 'en-TT': "English (Trinidad and Tobago)", + eo: "Esperanto", + 'es-419': "Spanish (Latin America)", + 'es-CL': "Spanish (Chile)", + 'es-CO': "Spanish (Colombia)", + 'es-CR': "Spanish (Costa Rica)", + 'es-EC': "Spanish (Ecuador)", + 'es-ES': "Spanish (Spain)", + 'es-NI': "Spanish (Nicaragua)", + 'es-PA': "Spanish (Panama)", + 'es-PE': "Spanish (Peru)", + 'es-US': "Spanish (United States)", + 'es-VE': "Spanish (Venezuela)", + et: "Estonian", + eu: "Basque", + fi: "Finnish", + 'fr-FR': "French (France)", + fy: "Western Frisian", + gd: "Scottish Gaelic", + gl: "Galician", + 'hi-IN': "Hindi (India)", + hi: "Hindi", + hr: "Croatian", + hu: "Hungarian", + is: "Icelandic", + 'it-CH': "Italian (Switzerland)", + ka: "Georgian", + kk: "Kazakh", + km: "Khmer", + kn: "Kannada", + lb: "Luxembourgish", + lo: "Lao", + mg: "Malagasy", + mk: "Macedonian", + ml: "Malayalam", + mn: "Mongolian", + 'mr-IN': "Marathi (India)", + ms: "Malay", + nb: "Norwegian Bokmål", + ne: "Nepali", + nn: "Norwegian Nynorsk", + oc: "Occitan", + or: "Odia", + pa: "Punjabi", + rm: "Romansh", + ro: "Romanian", + sc: "Sardinian", + sl: "Slovenian", + sq: "Albanian", + sr: "Serbian", + st: "Southern Sotho", + 'sv-FI': "Swedish (Finland)", + 'sv-SE': "Swedish (Sweden)", + sw: "Swahili", + ta: "Tamil", + te: "Telugu", + tl: "Tagalog", + tt: "Tatar", + ug: "Uyghur", + ur: "Urdu", + uz: "Uzbek", + wo: "Wolof" + }.freeze + + # Locales that we don't have files for, but which are available in Rails + EXCLUDED_LOCALES = [ + "en-BORK", + "en-au-ocker", + "ca-CAT", + "da-DK", + "de-AT", + "de-CH", + "ee", + "en-IND", + "en-KE", + "en-MS", + "en-NEP", + "en-NG", + "en-PAK", + "en-SG", + "en-TH", + "en-UG" + ].freeze + + COUNTRY_MAPPING = { + AF: "Afghanistan", + AL: "Albania", + DZ: "Algeria", + AD: "Andorra", + AO: "Angola", + AG: "Antigua and Barbuda", + AR: "Argentina", + AM: "Armenia", + AU: "Australia", + AT: "Austria", + AZ: "Azerbaijan", + BS: "Bahamas", + BH: "Bahrain", + BD: "Bangladesh", + BB: "Barbados", + BY: "Belarus", + BE: "Belgium", + BZ: "Belize", + BJ: "Benin", + BT: "Bhutan", + BO: "Bolivia", + BA: "Bosnia and Herzegovina", + BW: "Botswana", + BR: "Brazil", + BN: "Brunei", + BG: "Bulgaria", + BF: "Burkina Faso", + BI: "Burundi", + KH: "Cambodia", + CM: "Cameroon", + CA: "Canada", + CV: "Cape Verde", + CF: "Central African Republic", + TD: "Chad", + CL: "Chile", + CN: "China", + CO: "Colombia", + KM: "Comoros", + CG: "Congo", + CD: "Congo, Democratic Republic of the", + CR: "Costa Rica", + CI: "Côte d'Ivoire", + HR: "Croatia", + CU: "Cuba", + CY: "Cyprus", + CZ: "Czech Republic", + DK: "Denmark", + DJ: "Djibouti", + DM: "Dominica", + DO: "Dominican Republic", + EC: "Ecuador", + EG: "Egypt", + SV: "El Salvador", + GQ: "Equatorial Guinea", + ER: "Eritrea", + EE: "Estonia", + ET: "Ethiopia", + FJ: "Fiji", + FI: "Finland", + FR: "France", + GA: "Gabon", + GM: "Gambia", + GE: "Georgia", + DE: "Germany", + GH: "Ghana", + GR: "Greece", + GD: "Grenada", + GT: "Guatemala", + GN: "Guinea", + GW: "Guinea-Bissau", + GY: "Guyana", + HT: "Haiti", + HN: "Honduras", + HU: "Hungary", + IS: "Iceland", + IN: "India", + ID: "Indonesia", + IR: "Iran", + IQ: "Iraq", + IE: "Ireland", + IL: "Israel", + IT: "Italy", + JM: "Jamaica", + JP: "Japan", + JO: "Jordan", + KZ: "Kazakhstan", + KE: "Kenya", + KI: "Kiribati", + KP: "North Korea", + KR: "South Korea", + KW: "Kuwait", + KG: "Kyrgyzstan", + LA: "Laos", + LV: "Latvia", + LB: "Lebanon", + LS: "Lesotho", + LR: "Liberia", + LY: "Libya", + LI: "Liechtenstein", + LT: "Lithuania", + LU: "Luxembourg", + MK: "North Macedonia", + MG: "Madagascar", + MW: "Malawi", + MY: "Malaysia", + MV: "Maldives", + ML: "Mali", + MT: "Malta", + MH: "Marshall Islands", + MR: "Mauritania", + MU: "Mauritius", + MX: "Mexico", + FM: "Micronesia", + MD: "Moldova", + MC: "Monaco", + MN: "Mongolia", + ME: "Montenegro", + MA: "Morocco", + MZ: "Mozambique", + MM: "Myanmar", + NA: "Namibia", + NR: "Nauru", + NP: "Nepal", + NL: "Netherlands", + NZ: "New Zealand", + NI: "Nicaragua", + NE: "Niger", + NG: "Nigeria", + NO: "Norway", + OM: "Oman", + PK: "Pakistan", + PW: "Palau", + PA: "Panama", + PG: "Papua New Guinea", + PY: "Paraguay", + PE: "Peru", + PH: "Philippines", + PL: "Poland", + PT: "Portugal", + QA: "Qatar", + RO: "Romania", + RU: "Russia", + RW: "Rwanda", + KN: "Saint Kitts and Nevis", + LC: "Saint Lucia", + VC: "Saint Vincent and the Grenadines", + WS: "Samoa", + SM: "San Marino", + ST: "Sao Tome and Principe", + SA: "Saudi Arabia", + SN: "Senegal", + RS: "Serbia", + SC: "Seychelles", + SL: "Sierra Leone", + SG: "Singapore", + SK: "Slovakia", + SI: "Slovenia", + SB: "Solomon Islands", + SO: "Somalia", + ZA: "South Africa", + SS: "South Sudan", + ES: "Spain", + LK: "Sri Lanka", + SD: "Sudan", + SR: "Suriname", + SE: "Sweden", + CH: "Switzerland", + SY: "Syria", + TW: "Taiwan", + TJ: "Tajikistan", + TZ: "Tanzania", + TH: "Thailand", + TL: "Timor-Leste", + TG: "Togo", + TO: "Tonga", + TT: "Trinidad and Tobago", + TN: "Tunisia", + TR: "Turkey", + TM: "Turkmenistan", + TV: "Tuvalu", + UG: "Uganda", + UA: "Ukraine", + AE: "United Arab Emirates", + GB: "United Kingdom", + US: "United States", + UY: "Uruguay", + UZ: "Uzbekistan", + VU: "Vanuatu", + VA: "Vatican City", + VE: "Venezuela", + VN: "Vietnam", + YE: "Yemen", + ZM: "Zambia", + ZW: "Zimbabwe" + }.freeze + + def country_options + COUNTRY_MAPPING.keys.map { |key| [ COUNTRY_MAPPING[key], key ] } + end + + def language_options + I18n.available_locales + .reject { |locale| EXCLUDED_LOCALES.include?(locale.to_s) } + .map do |locale| + label = LANGUAGE_MAPPING[locale.to_sym] || locale.to_s.humanize + [ "#{label} (#{locale})", locale ] + end + end +end diff --git a/app/helpers/styled_form_builder.rb b/app/helpers/styled_form_builder.rb index 885509d6..f9e060af 100644 --- a/app/helpers/styled_form_builder.rb +++ b/app/helpers/styled_form_builder.rb @@ -24,7 +24,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder def select(method, choices, options = {}, html_options = {}) merged_html_options = { class: "form-field__input" }.merge(html_options) - label = build_label(method, options) + label = build_label(method, options.merge(required: merged_html_options[:required])) field = super(method, choices, options, merged_html_options) build_styled_field(label, field, options, remove_padding_right: true) @@ -33,7 +33,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder def collection_select(method, collection, value_method, text_method, options = {}, html_options = {}) merged_html_options = { class: "form-field__input" }.merge(html_options) - label = build_label(method, options) + label = build_label(method, options.merge(required: merged_html_options[:required])) field = super(method, collection, value_method, text_method, options, merged_html_options) build_styled_field(label, field, options, remove_padding_right: true) @@ -68,7 +68,17 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder def build_label(method, options) return "".html_safe unless options[:label] - return label(method, class: "form-field__label") if options[:label] == true - label(method, options[:label], class: "form-field__label") + + label_text = options[:label] + + if options[:required] + label_text = @template.safe_join([ + label_text == true ? method.to_s.humanize : label_text, + @template.tag.span("*", class: "text-red-500 ml-0.5") + ]) + end + + return label(method, class: "form-field__label") if label_text == true + label(method, label_text, class: "form-field__label") end end diff --git a/app/javascript/controllers/onboarding_controller.js b/app/javascript/controllers/onboarding_controller.js new file mode 100644 index 00000000..2f9d031b --- /dev/null +++ b/app/javascript/controllers/onboarding_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="onboarding" +export default class extends Controller { + setLocale(event) { + this.refreshWithParam("locale", event.target.value); + } + + setDateFormat(event) { + this.refreshWithParam("date_format", event.target.value); + } + + setCurrency(event) { + this.refreshWithParam("currency", event.target.value); + } + + refreshWithParam(key, value) { + const url = new URL(window.location); + url.searchParams.set(key, value); + + // Preserve existing params by getting the current search string + // and appending our new param to it + const currentParams = new URLSearchParams(window.location.search); + currentParams.set(key, value); + + // Refresh the page with all params + window.location.search = currentParams.toString(); + } +} diff --git a/app/javascript/controllers/profile_image_preview_controller.js b/app/javascript/controllers/profile_image_preview_controller.js index b03842be..7e568bee 100644 --- a/app/javascript/controllers/profile_image_preview_controller.js +++ b/app/javascript/controllers/profile_image_preview_controller.js @@ -2,32 +2,34 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static targets = [ - "imagePreview", - "fileField", - "deleteField", + "attachedImage", + "previewImage", + "placeholderImage", + "deleteProfileImage", + "input", "clearBtn", - "template", ]; - preview(event) { - const file = event.target.files[0]; - if (file) { - const reader = new FileReader(); - reader.onload = (e) => { - this.imagePreviewTarget.innerHTML = `Preview`; - this.templateTarget.classList.add("hidden"); - this.clearBtnTarget.classList.remove("hidden"); - }; - reader.readAsDataURL(file); - } + clearFileInput() { + this.inputTarget.value = null; + this.clearBtnTarget.classList.add("hidden"); + this.placeholderImageTarget.classList.remove("hidden"); + this.attachedImageTarget.classList.add("hidden"); + this.previewImageTarget.classList.add("hidden"); + this.deleteProfileImageTarget.value = "1"; } - clear() { - this.deleteFieldTarget.value = true; - this.fileFieldTarget.value = null; - this.templateTarget.classList.remove("hidden"); - this.imagePreviewTarget.innerHTML = this.templateTarget.innerHTML; - this.clearBtnTarget.classList.add("hidden"); - this.element.submit(); + showFileInputPreview(event) { + const file = event.target.files[0]; + if (!file) return; + + this.placeholderImageTarget.classList.add("hidden"); + this.attachedImageTarget.classList.add("hidden"); + this.previewImageTarget.classList.remove("hidden"); + this.clearBtnTarget.classList.remove("hidden"); + this.deleteProfileImageTarget.value = "0"; + + this.previewImageTarget.querySelector("img").src = + URL.createObjectURL(file); } } diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 5b5ada42..e04756f1 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -67,7 +67,7 @@ class Account::Entry < ApplicationRecord class << self # arbitrary cutoff date to avoid expensive sync operations def min_supported_date - 10.years.ago.to_date + 20.years.ago.to_date end def daily_totals(entries, currency, period: Period.last_30_days) diff --git a/app/models/family.rb b/app/models/family.rb index 24da7ddd..c4949a4d 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,4 +1,6 @@ class Family < ApplicationRecord + DATE_FORMATS = [ "%m-%d-%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%m/%d/%Y", "%e/%m/%Y", "%Y.%m.%d" ] + include Providable has_many :users, dependent: :destroy @@ -13,6 +15,7 @@ class Family < ApplicationRecord has_many :issues, through: :accounts validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } + validates :date_format, inclusion: { in: DATE_FORMATS } def snapshot(period = Period.all) query = accounts.active.joins(:balances) diff --git a/app/models/user.rb b/app/models/user.rb index 789e39df..68eaec43 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,7 +5,7 @@ class User < ApplicationRecord has_many :sessions, dependent: :destroy has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy - accepts_nested_attributes_for :family + accepts_nested_attributes_for :family, update_only: true validates :email, presence: true, uniqueness: true validate :ensure_valid_profile_image diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb new file mode 100644 index 00000000..69694a1d --- /dev/null +++ b/app/views/layouts/_footer.html.erb @@ -0,0 +1,7 @@ + +
+
+

© <%= Date.current.year %>, Maybe Finance, Inc.

+

<%= link_to t(".privacy_policy"), "https://maybe.co/privacy", class: "underline hover:text-gray-600" %> • <%= link_to t(".terms_of_service"), "https://maybe.co/tos", class: "underline hover:text-gray-600" %>

+
+
diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index e038247e..8c997673 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -4,24 +4,16 @@ <% end %>