1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09:38 +02:00

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
This commit is contained in:
Zach Gollwitzer 2024-10-23 11:20:55 -04:00 committed by GitHub
parent 73e184ad3d
commit 1d20de770f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 1088 additions and 300 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View file

@ -1,5 +1,5 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable
include Pagy::Backend include Pagy::Backend
private private

View file

@ -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

View file

@ -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

View file

@ -19,7 +19,7 @@ class SessionsController < ApplicationController
def destroy def destroy
@session.destroy @session.destroy
redirect_to root_path, notice: t(".logout_successful") redirect_to new_session_path, notice: t(".logout_successful")
end end
private private

View file

@ -1,2 +1,5 @@
class Settings::BillingsController < SettingsController class Settings::BillingsController < SettingsController
def show
@user = Current.user
end
end end

View file

@ -1,26 +1,5 @@
class Settings::PreferencesController < SettingsController class Settings::PreferencesController < SettingsController
def edit def show
@user = Current.user
end 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 end

View file

@ -1,38 +1,5 @@
class Settings::ProfilesController < SettingsController class Settings::ProfilesController < SettingsController
def show def show
@user = Current.user
end 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 end

View file

@ -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

View file

@ -1,6 +1,19 @@
module ApplicationHelper module ApplicationHelper
include Pagy::Frontend 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) def title(page_title)
content_for(:title) { page_title } content_for(:title) { page_title }
end end
@ -132,6 +145,19 @@ module ApplicationHelper
end end
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 = {}) def format_money(number_or_money, options = {})
return nil unless number_or_money return nil unless number_or_money

View file

@ -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

View file

@ -24,7 +24,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
def select(method, choices, options = {}, html_options = {}) def select(method, choices, options = {}, html_options = {})
merged_html_options = { class: "form-field__input" }.merge(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) field = super(method, choices, options, merged_html_options)
build_styled_field(label, field, options, remove_padding_right: true) 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 = {}) def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
merged_html_options = { class: "form-field__input" }.merge(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) field = super(method, collection, value_method, text_method, options, merged_html_options)
build_styled_field(label, field, options, remove_padding_right: true) build_styled_field(label, field, options, remove_padding_right: true)
@ -68,7 +68,17 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
def build_label(method, options) def build_label(method, options)
return "".html_safe unless options[:label] 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
end end

View file

@ -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();
}
}

View file

@ -2,32 +2,34 @@ import { Controller } from "@hotwired/stimulus";
export default class extends Controller { export default class extends Controller {
static targets = [ static targets = [
"imagePreview", "attachedImage",
"fileField", "previewImage",
"deleteField", "placeholderImage",
"deleteProfileImage",
"input",
"clearBtn", "clearBtn",
"template",
]; ];
preview(event) { clearFileInput() {
const file = event.target.files[0]; this.inputTarget.value = null;
if (file) { this.clearBtnTarget.classList.add("hidden");
const reader = new FileReader(); this.placeholderImageTarget.classList.remove("hidden");
reader.onload = (e) => { this.attachedImageTarget.classList.add("hidden");
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-full h-full rounded-full object-cover" />`; this.previewImageTarget.classList.add("hidden");
this.templateTarget.classList.add("hidden"); this.deleteProfileImageTarget.value = "1";
this.clearBtnTarget.classList.remove("hidden");
};
reader.readAsDataURL(file);
}
} }
clear() { showFileInputPreview(event) {
this.deleteFieldTarget.value = true; const file = event.target.files[0];
this.fileFieldTarget.value = null; if (!file) return;
this.templateTarget.classList.remove("hidden");
this.imagePreviewTarget.innerHTML = this.templateTarget.innerHTML; this.placeholderImageTarget.classList.add("hidden");
this.clearBtnTarget.classList.add("hidden"); this.attachedImageTarget.classList.add("hidden");
this.element.submit(); this.previewImageTarget.classList.remove("hidden");
this.clearBtnTarget.classList.remove("hidden");
this.deleteProfileImageTarget.value = "0";
this.previewImageTarget.querySelector("img").src =
URL.createObjectURL(file);
} }
} }

View file

@ -67,7 +67,7 @@ class Account::Entry < ApplicationRecord
class << self class << self
# arbitrary cutoff date to avoid expensive sync operations # arbitrary cutoff date to avoid expensive sync operations
def min_supported_date def min_supported_date
10.years.ago.to_date 20.years.ago.to_date
end end
def daily_totals(entries, currency, period: Period.last_30_days) def daily_totals(entries, currency, period: Period.last_30_days)

View file

@ -1,4 +1,6 @@
class Family < ApplicationRecord 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 include Providable
has_many :users, dependent: :destroy has_many :users, dependent: :destroy
@ -13,6 +15,7 @@ class Family < ApplicationRecord
has_many :issues, through: :accounts has_many :issues, through: :accounts
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :date_format, inclusion: { in: DATE_FORMATS }
def snapshot(period = Period.all) def snapshot(period = Period.all)
query = accounts.active.joins(:balances) query = accounts.active.joins(:balances)

View file

@ -5,7 +5,7 @@ class User < ApplicationRecord
has_many :sessions, dependent: :destroy has_many :sessions, dependent: :destroy
has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, 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 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 validates :email, presence: true, uniqueness: true
validate :ensure_valid_profile_image validate :ensure_valid_profile_image

View file

@ -0,0 +1,7 @@
<footer class="p-6">
<div class="space-y-2 text-center text-xs text-gray-500">
<p>&copy; <%= Date.current.year %>, Maybe Finance, Inc.</p>
<p><%= link_to t(".privacy_policy"), "https://maybe.co/privacy", class: "underline hover:text-gray-600" %> &bull; <%= link_to t(".terms_of_service"), "https://maybe.co/tos", class: "underline hover:text-gray-600" %></p>
</div>
</footer>

View file

@ -4,24 +4,16 @@
<% end %> <% end %>
<div id="user-menu" data-controller="menu"> <div id="user-menu" data-controller="menu">
<button data-menu-target="button"> <button data-menu-target="button">
<% profile_image_attached = Current.user.profile_image.attached? %> <div class="w-9 h-9">
<% if profile_image_attached %> <%= render "settings/user_avatar", user: Current.user %>
<div class="text-white w-9 h-9"> </div>
<%= render "shared/user_profile_image", user: Current.user %>
</div>
<% else %>
<div class="text-white w-9 h-9 bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= Current.user.initial %></div>
<% end %>
</button> </button>
<div data-menu-target="content" class="hidden absolute w-[240px] z-10 left-[255px] top-[72px] bg-white rounded-sm shadow-xs border border-alpha-black-25"> <div data-menu-target="content" class="hidden absolute w-[240px] z-10 left-[255px] top-[72px] bg-white rounded-sm shadow-xs border border-alpha-black-25">
<div class="p-3 flex items-center gap-3"> <div class="p-3 flex items-center gap-3">
<% if profile_image_attached %> <div class="w-9 h-9 shrink-0">
<div class="text-white shrink-0 w-9 h-9"> <%= render "settings/user_avatar", user: Current.user %>
<%= render "shared/user_profile_image", user: Current.user %> </div>
</div>
<% else %>
<div class="text-white shrink-0 w-9 h-9 bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= Current.user.initial %></div>
<% end %>
<div class="overflow-hidden text-ellipsis"> <div class="overflow-hidden text-ellipsis">
<span class="text-gray-900 font-medium text-sm"><%= Current.user.display_name %></span> <span class="text-gray-900 font-medium text-sm"><%= Current.user.display_name %></span>
<% if Current.user.display_name != Current.user.email %> <% if Current.user.display_name != Current.user.email %>

View file

@ -44,7 +44,7 @@
<%= render "shared/confirm_modal" %> <%= render "shared/confirm_modal" %>
<% if self_hosted? %> <% if self_hosted? && Current.user&.onboarded_at.present? %>
<%= render "shared/app_version" %> <%= render "shared/app_version" %>
<% end %> <% end %>
</body> </body>

View file

@ -1,30 +1,34 @@
<%= content_for :content do %> <%= content_for :content do %>
<div class="flex flex-col justify-center min-h-full px-6 py-12"> <div class="flex flex-col h-screen px-6 py-12 bg-gray-25">
<div class="sm:mx-auto sm:w-full sm:max-w-md"> <div class="grow flex flex-col justify-center">
<%= render "shared/logo" %> <div class="sm:mx-auto sm:w-full sm:max-w-md">
<div class="flex justify-center mb-6">
<%= image_tag "logo-color.png", class: "w-16 mb-6" %>
</div>
<h2 class="mt-6 text-3xl font-semibold tracking-tight text-center font-display"> <div class="space-y-2">
<%= content_for?(:header_title) ? yield(:header_title).html_safe : t(".your_account") %> <h2 class="text-3xl font-medium text-gray-900 text-center">
</h2> <%= content_for?(:header_title) ? yield(:header_title).html_safe : t(".your_account") %>
</h2>
<% if controller_name == "sessions" %> <% if controller_name == "sessions" %>
<p class="mt-2 text-sm text-center text-gray-600"> <p class="text-sm text-center">
<%= t(".or") %> <%= link_to t(".sign_up"), new_registration_path, class: "font-medium text-gray-600 hover:text-gray-400 transition" %> <%= tag.span t(".no_account"), class: "text-gray-500" %> <%= link_to t(".sign_up"), new_registration_path, class: "font-medium text-gray-900 hover:underline transition" %>
</p> </p>
<% elsif controller_name == "registrations" %> <% elsif controller_name == "registrations" %>
<p class="mt-2 text-sm text-center text-gray-600"> <p class="text-sm text-center text-gray-600">
<%= t(".or") %> <%= link_to t(".sign_in"), new_session_path, class: "font-medium text-gray-600 hover:text-gray-400 transition" %> <%= t(".existing_account") %> <%= link_to t(".sign_in"), new_session_path, class: "font-medium text-gray-900 hover:underline transition" %>
</p> </p>
<% end %> <% end %>
</div>
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-lg">
<%= yield %>
</div>
</div> </div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-lg"> <%= render "layouts/footer" %>
<%= yield %>
</div>
<div class="p-8 mt-2 text-center">
<p class="mt-6 text-sm text-black"><%= link_to t(".privacy_policy"), "https://maybe.co/privacy", class: "font-medium text-gray-600 hover:text-gray-400 transition" %> &bull; <%= link_to t(".terms_of_service"), "https://maybe.co/tos", class: "font-medium text-gray-600 hover:text-gray-400 transition" %></p>
</div>
</div> </div>
<% end %> <% end %>

View file

@ -0,0 +1,8 @@
<header class="flex justify-between items-center p-4">
<%= image_tag "logo.svg", class: "h-[22px]" %>
<div class="flex items-center gap-2">
<%= lucide_icon "log-in", class: "w-5 h-5 shrink-0 text-gray-500 gap-2" %>
<%= button_to t(".sign_out"), session_path(Current.session), method: :delete, class: "text-sm text-gray-900 font-medium" %>
</div>
</header>

View file

@ -0,0 +1,88 @@
<div class="bg-gray-25 h-screen flex flex-col justify-between">
<%= render "onboardings/header" %>
<div class="grow max-w-lg w-full mx-auto bg-gray-25 flex flex-col justify-center" data-controller="onboarding">
<div>
<div class="space-y-1 mb-6">
<h1 class="text-2xl font-medium"><%= t(".title") %></h1>
<p class="text-gray-500 text-sm"><%= t(".subtitle") %></p>
</div>
<div class="p-1 mb-2">
<div class="bg-white p-4 rounded-lg flex gap-8" style="box-shadow: 0px 0px 0px 1px rgba(11, 11, 11, 0.05), 0px 1px 2px 0px rgba(11, 11, 11, 0.05);">
<div class="space-y-2">
<%= tag.p t(".example"), class: "text-gray-500 text-sm" %>
<%= tag.p "$2,323.25", class: "text-gray-900 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-gray-500">as of <%= format_date(Date.parse("2024-10-23"), :default, format_code: params[:date_format] || @user.family.date_format) %></span>
</p>
</div>
<% placeholder_series_data = [
{ date: Date.current - 14.days, value: 200 },
{ date: Date.current - 13.days, value: 200 },
{ date: Date.current - 12.days, value: 220 },
{ date: Date.current - 11.days, value: 220 },
{ date: Date.current - 10.days, value: 220 },
{ date: Date.current - 9.days, value: 220 },
{ date: Date.current - 8.days, value: 220 },
{ date: Date.current - 7.days, value: 220 },
{ date: Date.current - 6.days, value: 230 },
{ date: Date.current - 5.days, value: 230 },
{ date: Date.current - 4.days, value: 250 },
{ date: Date.current - 3.days, value: 250 },
{ date: Date.current - 2.days, value: 265 },
{ date: Date.current - 1.day, value: 265 },
{ date: Date.current, value: 265 }
] %>
<div class="flex items-center w-2/5">
<div class="h-12 w-full">
<div
id="previewChart"
class="h-full w-full"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= TimeSeries.new(placeholder_series_data).to_json %>"
data-time-series-chart-use-labels-value="false"
data-time-series-chart-use-tooltip-value="false"></div>
</div>
</div>
</div>
</div>
<p class="text-gray-500 text-xs mb-4"><%= t(".preview") %></p>
<%= styled_form_with model: @user do |form| %>
<%= form.hidden_field :onboarded_at, value: Time.current %>
<%= form.hidden_field :redirect_to, value: "home" %>
<div class="space-y-4 mb-4">
<%= form.fields_for :family do |family_form| %>
<%= family_form.select :locale,
language_options,
{ label: t(".locale"), required: true, selected: params[:locale] || @user.family.locale },
{ data: { action: "onboarding#setLocale" } } %>
<%= family_form.select :currency,
currencies_for_select.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" } } %>
<%= family_form.select :date_format,
date_format_options,
{ label: t(".date_format"), required: true, selected: params[:date_format] || @user.family.date_format },
{ data: { action: "onboarding#setDateFormat" } } %>
<% end %>
</div>
<%= form.submit t(".submit") %>
<% end %>
</div>
</div>
<%= render "layouts/footer" %>
</div>

View file

@ -0,0 +1,40 @@
<div class="bg-gray-25 h-screen flex flex-col justify-between">
<%= render "onboardings/header" %>
<div class="grow max-w-lg w-full mx-auto bg-gray-25 flex flex-col justify-center">
<div>
<div class="space-y-1 mb-6">
<h1 class="text-2xl font-medium"><%= t(".title") %></h1>
<p class="text-gray-500 text-sm"><%= t(".subtitle") %></p>
</div>
<%= styled_form_with model: @user do |form| %>
<%= form.hidden_field :redirect_to, value: "onboarding_preferences" %>
<div class="space-y-4 mb-4">
<p class="text-gray-500 text-xs"><%= t(".profile_image") %></p>
<%= render "settings/user_avatar_field", form: form, user: @user %>
</div>
<div class="flex justify-between items-center gap-4 mb-4">
<%= form.text_field :first_name, placeholder: t(".first_name"), label: t(".first_name"), container_class: "bg-white w-1/2", required: true %>
<%= form.text_field :last_name, placeholder: t(".last_name"), label: t(".last_name"), container_class: "bg-white w-1/2", required: true %>
</div>
<div class="space-y-4 mb-4">
<%= form.fields_for :family do |family_form| %>
<%= family_form.text_field :name, placeholder: t(".household_name"), label: t(".household_name") %>
<%= family_form.select :country,
country_options,
{ label: t(".country") }, required: true %>
<% end %>
</div>
<%= form.submit t(".submit") %>
<% end %>
</div>
</div>
<%= render "layouts/footer" %>
</div>

View file

@ -0,0 +1,11 @@
<div class="bg-gray-25">
<div class="h-screen flex flex-col items-center py-6">
<div class="grow flex justify-center items-center flex-col max-w-sm w-full text-center">
<%= image_tag "logo-color.png", class: "w-16 mb-6" %>
<%= tag.h1 t(".title"), class: "text-3xl font-medium mb-2" %>
<%= tag.p t(".message"), class: "text-sm text-gray-500 mb-6" %>
<%= link_to t(".setup"), profile_onboarding_path, class: "block flex justify-center items-center btn btn--primary w-full" %>
</div>
</div>
</div>

View file

@ -16,5 +16,5 @@
<% if invite_code_required? %> <% if invite_code_required? %>
<%= form.text_field :invite_code, required: "required", label: true, value: params[:invite] %> <%= form.text_field :invite_code, required: "required", label: true, value: params[:invite] %>
<% end %> <% end %>
<%= form.submit %> <%= form.submit t(".submit") %>
<% end %> <% end %>

View file

@ -11,5 +11,5 @@
<% end %> <% end %>
<div class="mt-6 text-center"> <div class="mt-6 text-center">
<p class="text-sm text-gray-600"><%= t(".forgot_password") %> <%= link_to t(".reset_password"), new_password_reset_path, class: "font-medium text-gray-600 hover:text-gray-400 transition" %></p> <%= link_to t(".forgot_password"), new_password_reset_path, class: "font-medium text-sm text-gray-900 hover:underline transition" %>
</div> </div>

View file

@ -0,0 +1,7 @@
<%# locals: (user:) %>
<% if user.profile_image.attached? %>
<%= image_tag user.profile_image.variant(:thumbnail), class: "rounded-full w-full h-full object-cover" %>
<% else %>
<div class="text-white w-full h-full bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= user.initial %></div>
<% end %>

View file

@ -0,0 +1,52 @@
<%# locals: (form:, user:) %>
<div class="flex items-center gap-4" data-controller="profile-image-preview">
<div class="relative flex justify-center items-center bg-gray-50 w-24 h-24 rounded-full border-alpha-black-300 border border-dashed">
<%# The image preview once user has uploaded a new file %>
<div data-profile-image-preview-target="previewImage" class="h-full w-full flex justify-center items-center hidden">
<img src="" alt="Preview" class="w-full h-full rounded-full object-cover">
</div>
<%# The placeholder image for empty avatar field %>
<div data-profile-image-preview-target="placeholderImage"
class="h-full w-full flex justify-center items-center <%= user.profile_image.attached? ? "hidden" : "" %>">
<div class="h-full w-full flex justify-center items-center">
<%= lucide_icon "image-plus", class: "w-6 h-6 text-gray-500" %>
</div>
</div>
<%# The attached image if user has already uploaded one %>
<div data-profile-image-preview-target="attachedImage"
class="h-full w-full flex justify-center items-center <%= user.profile_image.attached? ? "" : "hidden" %>">
<% if user.profile_image.attached? %>
<div class="h-full w-full">
<%= render "settings/user_avatar", user: user %>
</div>
<% end %>
</div>
<button type="button"
data-profile-image-preview-target="clearBtn"
data-action="click->profile-image-preview#clearFileInput"
class="<%= user.profile_image.attached? ? "" : "hidden" %> cursor-pointer absolute bottom-0 right-0 w-8 h-8 bg-gray-50 rounded-full flex justify-center items-center border border-white border-2">
<%= lucide_icon "x", class: "w-4 h-4 text-gray-500" %>
</button>
</div>
<div>
<%= form.hidden_field :delete_profile_image, value: "0", data: { profile_image_preview_target: "deleteProfileImage" } %>
<p class="mb-3"><%= t(".accepted_formats") %></p>
<%= form.label :profile_image, t(".choose"),
class: "btn btn--outline inline-block" %>
<%= form.file_field :profile_image,
accept: "image/png, image/jpeg",
class: "hidden px-3 py-2 bg-gray-50 text-gray-900 rounded-md text-sm font-medium",
data: {
profile_image_preview_target: "input",
action: "change->profile-image-preview#showFileInputPreview"
} %>
</div>
</div>

View file

@ -5,7 +5,7 @@
<div class="space-y-4"> <div class="space-y-4">
<h1 class="text-gray-900 text-xl font-medium mb-4"><%= t(".page_title") %></h1> <h1 class="text-gray-900 text-xl font-medium mb-4"><%= t(".page_title") %></h1>
<%= settings_section title: t(".subscription_title"), subtitle: t(".subscription_subtitle") do %> <%= settings_section title: t(".subscription_title"), subtitle: t(".subscription_subtitle") do %>
<% if Current.family.stripe_plan_id.blank? %> <% if @user.family.stripe_plan_id.blank? %>
<%= link_to t(".subscribe_button"), new_subscription_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2", data: { turbo: false } %> <%= link_to t(".subscribe_button"), new_subscription_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2", data: { turbo: false } %>
<% else %> <% else %>
<%= link_to t(".manage_subscription_button"), subscription_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2", data: { turbo: false } %> <%= link_to t(".manage_subscription_button"), subscription_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2", data: { turbo: false } %>

View file

@ -6,16 +6,28 @@
<h1 class="text-gray-900 text-xl font-medium mb-4"><%= t(".page_title") %></h1> <h1 class="text-gray-900 text-xl font-medium mb-4"><%= t(".page_title") %></h1>
<%= settings_section title: t(".general_title"), subtitle: t(".general_subtitle") do %> <%= settings_section title: t(".general_title"), subtitle: t(".general_subtitle") do %>
<div> <div>
<%= styled_form_with model: Current.user, url: settings_preferences_path, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %> <%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %>
<%= form.fields_for :family_attributes do |family_form| %> <%= form.hidden_field :redirect_to, value: "preferences" %>
<%= form.fields_for :family do |family_form| %>
<%= family_form.select :currency, <%= family_form.select :currency,
currencies_for_select.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }, currencies_for_select.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] },
{ label: "Currency", selected: Current.family.currency }, { label: t(".currency") },
{ data: { auto_submit_form_target: "auto" } } %> { data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :locale, <%= family_form.select :locale,
I18n.available_locales, language_options,
{ label: "Locale", selected: Current.family.locale }, { label: t(".language") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :date_format,
date_format_options,
{ label: t(".date_format") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :country,
country_options,
{ label: t(".country") },
{ data: { auto_submit_form_target: "auto" } } %> { data: { auto_submit_form_target: "auto" } } %>
<p class="text-xs italic pl-2 text-gray-500">Please note, we are still working on translations for various languages. Please see the <%= link_to "I18n issue", "https://github.com/maybe-finance/maybe/issues/1225", target: "_blank", class: "underline" %> for more information.</p> <p class="text-xs italic pl-2 text-gray-500">Please note, we are still working on translations for various languages. Please see the <%= link_to "I18n issue", "https://github.com/maybe-finance/maybe/issues/1225", target: "_blank", class: "underline" %> for more information.</p>
@ -25,7 +37,8 @@
<% end %> <% end %>
<%= settings_section title: t(".theme_title"), subtitle: t(".theme_subtitle") do %> <%= settings_section title: t(".theme_title"), subtitle: t(".theme_subtitle") do %>
<div> <div>
<%= styled_form_with model: Current.user, url: settings_preferences_path, local: true, class: "flex justify-between items-center" do |form| %> <%= styled_form_with model: @user, class: "flex justify-between items-center" do |form| %>
<%= form.hidden_field :redirect_to, value: "preferences" %>
<div class="text-center"> <div class="text-center">
<%= image_tag("light-mode-preview.png", alt: "Light Theme Preview", class: "h-44 mb-4") %> <%= image_tag("light-mode-preview.png", alt: "Light Theme Preview", class: "h-44 mb-4") %>
<div class="flex justify-center items-center gap-2"> <div class="flex justify-center items-center gap-2">

View file

@ -5,35 +5,13 @@
<h1 class="text-gray-900 text-xl font-medium mb-4"><%= t(".page_title") %></h1> <h1 class="text-gray-900 text-xl font-medium mb-4"><%= t(".page_title") %></h1>
<div class="space-y-4"> <div class="space-y-4">
<%= settings_section title: t(".profile_title"), subtitle: t(".profile_subtitle") do %> <%= settings_section title: t(".profile_title"), subtitle: t(".profile_subtitle") do %>
<%= styled_form_with model: Current.user, url: settings_profile_path, class: "space-y-4", data: { controller: "profile-image-preview" } do |form| %> <%= styled_form_with model: @user, class: "space-y-4" do |form| %>
<div class="flex items-center gap-4"> <%= render "settings/user_avatar_field", form: form, user: @user %>
<div class="relative flex justify-center items-center bg-gray-50 w-24 h-24 rounded-full border border-alpha-black-25">
<div data-profile-image-preview-target="imagePreview" class="h-full w-full flex justify-center items-center">
<% profile_image_attached = Current.user.profile_image.attached? %>
<% if profile_image_attached %>
<div class="h-24 w-24">
<%= render "shared/user_profile_image", user: Current.user %>
</div>
<% else %>
<%= lucide_icon "image-plus", class: "w-6 h-6 text-gray-500" %>
<% end %>
</div>
<%= lucide_icon "image-plus", class: "hidden w-6 h-6 text-gray-500", data: { profile_image_preview_target: "template" } %>
<div data-profile-image-preview-target="clearBtn" data-action="click->profile-image-preview#clear" class="<%= profile_image_attached ? "" : "hidden" %> cursor-pointer absolute bottom-0 right-0 w-8 h-8 bg-gray-50 rounded-full flex justify-center items-center border border-white border-2">
<%= lucide_icon "x", class: "w-4 h-4 text-gray-500" %>
</div>
</div>
<div class="space-y-3">
<p><%= t(".profile_image_type") %></p>
<%= form.label :profile_image, t(".profile_image_choose"), class: "inline-block cursor-pointer px-3 py-2 bg-gray-50 text-gray-900 rounded-md text-sm font-medium" %>
<%= form.file_field :profile_image, accept: "image/png, image/jpeg", class: "hidden px-3 py-2 bg-gray-50 text-gray-900 rounded-md text-sm font-medium", data: {profile_image_preview_target: "fileField", action: "change->profile-image-preview#preview"} %>
<%= form.hidden_field :delete_profile_image, value: false, data: {profile_image_preview_target: "deleteField"} %>
</div>
</div>
<div> <div>
<div class="grid grid-cols-2 gap-4 mt-4"> <div class="grid grid-cols-2 gap-4 mt-4">
<%= form.text_field :first_name, placeholder: "First name", value: Current.user.first_name, label: true %> <%= form.text_field :first_name, placeholder: t(".first_name"), label: t(".first_name") %>
<%= form.text_field :last_name, placeholder: "Last name", value: Current.user.last_name, label: true %> <%= form.text_field :last_name, placeholder: t(".last_name"), label: t(".last_name") %>
</div> </div>
<div class="flex justify-end mt-4"> <div class="flex justify-end mt-4">
<%= form.submit t(".save"), class: "bg-gray-900 hover:bg-gray-700 cursor-pointer text-white rounded-lg px-3 py-2" %> <%= form.submit t(".save"), class: "bg-gray-900 hover:bg-gray-700 cursor-pointer text-white rounded-lg px-3 py-2" %>
@ -43,9 +21,13 @@
<% end %> <% end %>
<%= settings_section title: t(".household_title"), subtitle: t(".household_subtitle") do %> <%= settings_section title: t(".household_title"), subtitle: t(".household_subtitle") do %>
<div class="space-y-4"> <div class="space-y-4">
<%= styled_form_with model: Current.user, url: settings_profile_path, class: "space-y-4", data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value": "blur" } do |form| %> <%= styled_form_with model: Current.user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %>
<%= form.fields_for :family_attributes do |family_fields| %> <%= form.fields_for :family do |family_fields| %>
<%= family_fields.text_field :name, placeholder: t(".household_form_input_placeholder"), value: Current.family.name, label: t(".household_form_label"), disabled: !Current.user.admin?, "data-auto-submit-form-target": "auto" %> <%= family_fields.text_field :name,
placeholder: t(".household_form_input_placeholder"),
label: t(".household_form_label"),
disabled: !Current.user.admin?,
"data-auto-submit-form-target": "auto" %>
<% end %> <% end %>
<% end %> <% end %>
<div class="bg-gray-25 rounded-xl p-1"> <div class="bg-gray-25 rounded-xl p-1">
@ -71,7 +53,7 @@
<p class="text-gray-500 text-sm"><%= t(".delete_account_warning") %></p> <p class="text-gray-500 text-sm"><%= t(".delete_account_warning") %></p>
</div> </div>
<%= <%=
button_to t(".delete_account"), settings_profile_path, method: :delete, button_to t(".delete_account"), user_path(@user), method: :delete,
class: "bg-red-500 text-white text-sm font-medium rounded-lg px-3 py-2", class: "bg-red-500 text-white text-sm font-medium rounded-lg px-3 py-2",
data: { turbo_confirm: { data: { turbo_confirm: {
title: t(".confirm_delete.title"), title: t(".confirm_delete.title"),

View file

@ -6,7 +6,12 @@
currency = Money::Currency.new(currency_value || options[:default_currency] || "USD") %> currency = Money::Currency.new(currency_value || options[:default_currency] || "USD") %>
<div class="form-field pr-0 <%= options[:container_class] %>" data-controller="money-field"> <div class="form-field pr-0 <%= options[:container_class] %>" data-controller="money-field">
<%= form.label options[:label] || t(".label"), class: "form-field__label" %> <%= form.label options[:label] || t(".label"), class: "form-field__label" do %>
<%= options[:label] || t(".label") %>
<% if options[:required] %>
<span class="text-red-500">*</span>
<% end %>
<% end %>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="flex items-center grow gap-1"> <div class="flex items-center grow gap-1">

View file

@ -32,7 +32,7 @@
<div class="h-5 shrink-0"> <div class="h-5 shrink-0">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" class="shrink-0"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" class="shrink-0">
<path d="M18 10C18 14.4183 14.4183 18 10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2C14.4183 2 18 5.58172 18 10ZM3.6 10C3.6 13.5346 6.46538 16.4 10 16.4C13.5346 16.4 16.4 13.5346 16.4 10C16.4 6.46538 13.5346 3.6 10 3.6C6.46538 3.6 3.6 6.46538 3.6 10Z" fill="#E5E5E5" /> <path d="M18 10C18 14.4183 14.4183 18 10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2C14.4183 2 18 5.58172 18 10ZM3.6 10C3.6 13.5346 6.46538 16.4 10 16.4C13.5346 16.4 16.4 13.5346 16.4 10C16.4 6.46538 13.5346 3.6 10 3.6C6.46538 3.6 3.6 6.46538 3.6 10Z" fill="#E5E5E5" />
<circle class="origin-center -rotate-90 animate-[stroke-fill_5s_300ms_forwards]" stroke="#141414" stroke-opacity="0.4" r="7.2" cx="10" cy="10" stroke-dasharray="43.9822971503" stroke-dashoffset="43.9822971503" /> <circle class="origin-center -rotate-90 animate-[stroke-fill_2.2s_300ms_forwards]" stroke="#141414" stroke-opacity="0.4" r="7.2" cx="10" cy="10" stroke-dasharray="43.9822971503" stroke-dashoffset="43.9822971503" />
</svg> </svg>
<div class="absolute -top-2 -right-2"> <div class="absolute -top-2 -right-2">
<%= lucide_icon "x", class: "w-5 h-5 p-0.5 hidden group-hover:inline-block border border-alpha-black-50 border-solid rounded-lg bg-white text-gray-400 cursor-pointer", data: { action: "click->element-removal#remove" } %> <%= lucide_icon "x", class: "w-5 h-5 p-0.5 hidden group-hover:inline-block border border-alpha-black-50 border-solid rounded-lg bg-white text-gray-400 cursor-pointer", data: { action: "click->element-removal#remove" } %>

View file

@ -1 +0,0 @@
<%= image_tag user.profile_image.variant(:thumbnail), class: "rounded-full w-full h-full object-cover" %>

View file

@ -1,70 +1,30 @@
--- ---
en: en:
accounts: accounts:
sync_all_button:
sync: Sync all
account: account:
has_issues: Issue detected. has_issues: Issue detected.
troubleshoot: Troubleshoot troubleshoot: Troubleshoot
account_list: account_list:
new_account: "New %{type}" new_account: New %{type}
empty:
no_accounts: No accounts yet
empty_message: Add an account either via connection, importing or entering manually.
new_account: New account
form:
name_label: Account name
name_placeholder: Example account name
institution: Financial institution
ungrouped: "(none)"
balance: Today's balance
accountable_type: Account type
mode: Value tracking mode
mode_prompt: Select a mode
type_prompt: Select a type
header:
accounts: Accounts
manage: Manage accounts
new: New account
institution_accounts:
add_account_to_institution: Add new account
has_issues: Issue detected, see accounts
syncing: Syncing...
status: "Last synced %{last_synced_at} ago"
status_never: Requires data sync
edit: Edit institution
delete: Delete institution
confirm_title: Delete financial institution?
confirm_body: Don't worry, none of the accounts within this institution will be affected by this deletion. Accounts will be ungrouped and all historical data will remain intact.
confirm_accept: Delete institution
new_account: Add account
institutionless_accounts:
other_accounts: Other accounts
menu:
edit: Edit
import: Import transactions
confirm_title: Delete account?
confirm_body_html: "<p>By deleting this account, you will erase its value history, affecting various aspects of your overall account. This action will have a direct impact on your net worth calculations and the account graphs.</p><br /> <p>After deletion, there is no way you'll be able to restore the account information because you'll need to add it as a new account.</p>"
confirm_accept: 'Delete "%{name}"'
accountables: accountables:
credit_card: credit_card:
form: form:
available_credit: Available credit
available_credit_placeholder: '10000'
minimum_payment: Minimum payment
minimum_payment_placeholder: '100'
apr: APR
apr_placeholder: '15.99'
expiration_date: Expiration date
annual_fee: Annual fee annual_fee: Annual fee
annual_fee_placeholder: '99' annual_fee_placeholder: '99'
apr: APR
apr_placeholder: '15.99'
available_credit: Available credit
available_credit_placeholder: '10000'
expiration_date: Expiration date
minimum_payment: Minimum payment
minimum_payment_placeholder: '100'
overview: overview:
amount_owed: Amount Owed amount_owed: Amount Owed
available_credit: Available Credit
minimum_payment: Minimum Payment
apr: APR
expiration_date: Expiration Date
annual_fee: Annual Fee annual_fee: Annual Fee
apr: APR
available_credit: Available Credit
expiration_date: Expiration Date
minimum_payment: Minimum Payment
unknown: Unknown unknown: Unknown
depository: depository:
form: form:
@ -77,7 +37,8 @@ en:
tooltip: tooltip:
cash: Cash cash: Cash
holdings: Holdings holdings: Holdings
total_value_tooltip: The total value is the sum of cash balance and your holdings value, minus margin loans. total_value_tooltip: The total value is the sum of cash balance and your
holdings value, minus margin loans.
loan: loan:
form: form:
interest_rate: Interest rate interest_rate: Interest rate
@ -133,17 +94,65 @@ en:
trend: Trend trend: Trend
unknown: Unknown unknown: Unknown
year: Year year: Year
create:
success: New account created successfully
destroy:
success: Account deleted successfully
edit: edit:
edit: "Edit %{account}" edit: Edit %{account}
empty:
empty_message: Add an account either via connection, importing or entering manually.
new_account: New account
no_accounts: No accounts yet
form:
accountable_type: Account type
balance: Today's balance
institution: Financial institution
mode: Value tracking mode
mode_prompt: Select a mode
name_label: Account name
name_placeholder: Example account name
type_prompt: Select a type
ungrouped: "(none)"
header:
accounts: Accounts
manage: Manage accounts
new: New account
index: index:
accounts: Accounts accounts: Accounts
add_institution: Add institution add_institution: Add institution
new_account: New account new_account: New account
institution_accounts:
add_account_to_institution: Add new account
confirm_accept: Delete institution
confirm_body: Don't worry, none of the accounts within this institution will
be affected by this deletion. Accounts will be ungrouped and all historical
data will remain intact.
confirm_title: Delete financial institution?
delete: Delete institution
edit: Edit institution
has_issues: Issue detected, see accounts
new_account: Add account
status: Last synced %{last_synced_at} ago
status_never: Requires data sync
syncing: Syncing...
institutionless_accounts:
other_accounts: Other accounts
menu:
confirm_accept: Delete "%{name}"
confirm_body_html: "<p>By deleting this account, you will erase its value history,
affecting various aspects of your overall account. This action will have a
direct impact on your net worth calculations and the account graphs.</p><br
/> <p>After deletion, there is no way you'll be able to restore the account
information because you'll need to add it as a new account.</p>"
confirm_title: Delete account?
edit: Edit
import: Import transactions
new: new:
title: Add an account
manual_entry: Enter account manually
csv_entry: Import accounts CSV
connected_entry: Securely link account with Plaid (coming soon) connected_entry: Securely link account with Plaid (coming soon)
csv_entry: Import accounts CSV
manual_entry: Enter account manually
title: Add an account
show: show:
cash: Cash cash: Cash
holdings: Holdings holdings: Holdings
@ -157,17 +166,17 @@ en:
summary: summary:
new: New new: New
no_assets: No assets found no_assets: No assets found
no_assets_description: Add an asset either via connection, importing or entering manually. no_assets_description: Add an asset either via connection, importing or entering
manually.
no_liabilities: No liabilities found no_liabilities: No liabilities found
no_liabilities_description: Add a liability either via connection, importing or entering manually. no_liabilities_description: Add a liability either via connection, importing
create: or entering manually.
success: New account created successfully
destroy:
success: Account deleted successfully
update:
success: Account updated
sync_all: sync_all:
success: Successfully queued accounts for syncing. success: Successfully queued accounts for syncing.
sync_all_button:
sync: Sync all
update:
success: Account updated
credit_cards: credit_cards:
create: create:
success: Credit card created successfully success: Credit card created successfully

View file

@ -1,15 +1,15 @@
--- ---
en: en:
impersonation_sessions: impersonation_sessions:
create:
success: "Request sent to user. Waiting for approval."
join:
success: "Joined session"
leave:
success: "Left session"
approve: approve:
success: "Request approved" success: Request approved
reject:
success: "Request rejected"
complete: complete:
success: "Session completed" success: Session completed
create:
success: Request sent to user. Waiting for approval.
join:
success: Joined session
leave:
success: Left session
reject:
success: Request rejected

View file

@ -4,7 +4,8 @@ en:
cleans: cleans:
show: show:
description: Edit your data in the table below. Red cells are invalid. description: Edit your data in the table below. Red cells are invalid.
errors_notice: You have errors in your data. Hover over the error to see details. errors_notice: You have errors in your data. Hover over the error to see
details.
title: Clean your data title: Clean your data
configurations: configurations:
show: show:
@ -18,8 +19,8 @@ en:
no_accounts: You don't have any accounts yet. Please create an account that no_accounts: You don't have any accounts yet. Please create an account that
we can use for (unassigned) rows in your CSV or go back to the Clean step we can use for (unassigned) rows in your CSV or go back to the Clean step
and provide an account name we can use. and provide an account name we can use.
unassigned_account: Need to create a new account for unassigned rows?
rows_label: Rows rows_label: Rows
unassigned_account: Need to create a new account for unassigned rows?
show: show:
account_mapping_description: Assign all of your imported file's accounts to account_mapping_description: Assign all of your imported file's accounts to
Maybe's existing accounts. You can also add new accounts or leave them Maybe's existing accounts. You can also add new accounts or leave them

View file

@ -2,12 +2,14 @@
en: en:
layouts: layouts:
auth: auth:
or: or existing_account: Already have an account?
privacy_policy: Privacy Policy no_account: New to Maybe?
sign_in: sign in to your account sign_in: Sign in
sign_up: create an account sign_up: Create account
terms_of_service: Terms of Service
your_account: Your account your_account: Your account
footer:
privacy_policy: Privacy Policy
terms_of_service: Terms of Service
issues: issues:
action: How to fix this issue action: How to fix this issue
description: Issue Description description: Issue Description

View file

@ -0,0 +1,28 @@
---
en:
onboardings:
header:
sign_out: Log out
preferences:
currency: Currency
date_format: Date format
example: Example account
locale: Language
preview: Preview how data displays based on preferences.
submit: Complete
subtitle: Let's configure your preferences.
title: Configure your preferences
profile:
country: Country
first_name: First Name
household_name: Household Name
last_name: Last Name
profile_image: Profile Image
submit: Continue
subtitle: Let's complete your profile.
title: Let's set up the basics
show:
message: Were really excited youre here. In the next step well ask you a
few questions to complete your profile and then get you all set up.
setup: Set up account
title: Meet Maybe

View file

@ -7,8 +7,8 @@ en:
allocation_chart: allocation_chart:
assets: Assets assets: Assets
debts: Debts debts: Debts
greeting: Welcome back, %{name}
fallback_greeting: Welcome back, friend fallback_greeting: Welcome back, friend
greeting: Welcome back, %{name}
import: Import import: Import
income: Income income: Income
investing: Investing (coming soon...) investing: Investing (coming soon...)

View file

@ -13,6 +13,7 @@ en:
invalid_invite_code: Invalid invite code, please try again. invalid_invite_code: Invalid invite code, please try again.
success: You have signed up successfully. success: You have signed up successfully.
new: new:
submit: Create account
title: Create an account title: Create an account
welcome_body: To get started, you must sign up for a new account. You will welcome_body: To get started, you must sign up for a new account. You will
then be able to configure additional settings within the app. then be able to configure additional settings within the app.

View file

@ -10,6 +10,5 @@ en:
email_placeholder: you@example.com email_placeholder: you@example.com
forgot_password: Forgot your password? forgot_password: Forgot your password?
password: Password password: Password
reset_password: Reset it
submit: Log in submit: Log in
title: Sign in to your account title: Sign in to your account

View file

@ -29,19 +29,19 @@ en:
previous: Back previous: Back
preferences: preferences:
show: show:
country: Country
currency: Currency
date_format: Date format
general_subtitle: Configure your preferences general_subtitle: Configure your preferences
general_title: General general_title: General
language: Language
page_title: Preferences page_title: Preferences
theme_dark: Dark theme_dark: Dark
theme_light: Light theme_light: Light
theme_subtitle: Choose a preferred theme for the app (coming soon...) theme_subtitle: Choose a preferred theme for the app (coming soon...)
theme_system: System theme_system: System
theme_title: Theme theme_title: Theme
update:
success: Preferences updated successfully.
profiles: profiles:
destroy:
success: Account deleted successfully.
show: show:
confirm_delete: confirm_delete:
body: Are you sure you want to permanently delete your account? This action body: Are you sure you want to permanently delete your account? This action
@ -51,16 +51,17 @@ en:
delete_account: Delete account delete_account: Delete account
delete_account_warning: Deleting your account will permanently remove all delete_account_warning: Deleting your account will permanently remove all
your data and cannot be undone. your data and cannot be undone.
first_name: First Name
household_form_input_placeholder: Enter household name household_form_input_placeholder: Enter household name
household_form_label: Household name household_form_label: Household name
household_subtitle: Invite family members, partners and other inviduals. Invitees household_subtitle: Invite family members, partners and other inviduals. Invitees
can login to your household and access your shared accounts. can login to your household and access your shared accounts.
household_title: Household household_title: Household
last_name: Last Name
page_title: Account page_title: Account
profile_image_choose: Choose
profile_image_type: JPG, GIF or PNG. 5MB max.
profile_subtitle: Customize how you appear on Maybe profile_subtitle: Customize how you appear on Maybe
profile_title: Profile profile_title: Profile
save: Save save: Save
update: user_avatar_field:
success: Profile updated successfully. accepted_formats: JPG or PNG. 5MB max.
choose: Choose

View file

@ -0,0 +1,7 @@
---
en:
users:
destroy:
success: Your account has been deleted.
update:
success: Your profile has been updated.

View file

@ -5,14 +5,23 @@ Rails.application.routes.draw do
get "feedback", to: "pages#feedback" get "feedback", to: "pages#feedback"
get "early-access", to: "pages#early_access" get "early-access", to: "pages#early_access"
resource :registration resource :registration, only: %i[new create]
resources :sessions, only: %i[new create destroy] resources :sessions, only: %i[new create destroy]
resource :password_reset resource :password_reset, only: %i[new create edit update]
resource :password resource :password, only: %i[edit update]
resources :users, only: %i[update destroy]
resource :onboarding, only: :show do
collection do
get :profile
get :preferences
end
end
namespace :settings do namespace :settings do
resource :profile, only: %i[show update destroy] resource :profile, only: :show
resource :preferences, only: %i[show update] resource :preferences, only: :show
resource :hosting, only: %i[show update] resource :hosting, only: %i[show update]
resource :billing, only: :show resource :billing, only: :show
end end

View file

@ -0,0 +1,7 @@
class AddOnboardingFields < ActiveRecord::Migration[7.2]
def change
add_column :users, :onboarded_at, :datetime
add_column :families, :date_format, :string, default: "%m-%d-%Y"
add_column :families, :country, :string, default: "US"
end
end

7
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_10_22_192319) do ActiveRecord::Schema[7.2].define(version: 2024_10_22_221544) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@ -119,7 +119,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_22_192319) do
t.boolean "is_active", default: true, null: false t.boolean "is_active", default: true, null: false
t.date "last_sync_date" t.date "last_sync_date"
t.uuid "institution_id" t.uuid "institution_id"
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
t.uuid "import_id" t.uuid "import_id"
t.string "mode" t.string "mode"
t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type"
@ -226,6 +226,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_22_192319) do
t.string "stripe_plan_id" t.string "stripe_plan_id"
t.string "stripe_customer_id" t.string "stripe_customer_id"
t.string "stripe_subscription_status", default: "incomplete" t.string "stripe_subscription_status", default: "incomplete"
t.string "date_format", default: "%m-%d-%Y"
t.string "country", default: "US"
end end
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@ -558,6 +560,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_22_192319) do
t.string "last_alerted_upgrade_commit_sha" t.string "last_alerted_upgrade_commit_sha"
t.string "role", default: "member", null: false t.string "role", default: "member", null: false
t.boolean "active", default: true, null: false t.boolean "active", default: true, null: false
t.datetime "onboarded_at"
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
t.index ["family_id"], name: "index_users_on_family_id" t.index ["family_id"], name: "index_users_on_family_id"
end end

View file

@ -28,7 +28,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
sign_in @user sign_in @user
delete session_url(@user.sessions.order(:created_at).last) delete session_url(@user.sessions.order(:created_at).last)
assert_redirected_to root_url assert_redirected_to new_session_path
assert_equal "You have signed out successfully.", flash[:notice] assert_equal "You have signed out successfully.", flash[:notice]
end end
end end

View file

@ -9,35 +9,4 @@ class Settings::ProfilesControllerTest < ActionDispatch::IntegrationTest
get settings_profile_url get settings_profile_url
assert_response :success assert_response :success
end end
test "member can deactivate their account" do
sign_in @member = users(:family_member)
delete settings_profile_url
assert_redirected_to root_url
assert_not User.find(@member.id).active?
assert_enqueued_with(job: UserPurgeJob, args: [ @member ])
end
test "admin prevented from deactivating when other users are present" do
sign_in @admin = users(:family_admin)
delete settings_profile_url
assert_redirected_to settings_profile_url
assert_equal "Admin cannot delete account while other users are present. Please delete all members first.", flash[:alert]
assert_no_enqueued_jobs only: UserPurgeJob
assert User.find(@admin.id).active?
end
test "admin can deactivate their account when they are the last user in the family" do
sign_in @admin = users(:family_admin)
users(:family_member).destroy
delete settings_profile_url
assert_redirected_to root_url
assert_not User.find(@admin.id).active?
assert_enqueued_with(job: UserPurgeJob, args: [ @admin ])
end
end end

View file

@ -0,0 +1,64 @@
require "test_helper"
class UsersControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
end
test "can supply custom redirect after update" do
patch user_url(@user), params: { user: { redirect_to: "home" } }
assert_redirected_to root_url
end
test "can update user profile" do
patch user_url(@user), params: {
user: {
first_name: "John",
last_name: "Doe",
onboarded_at: Time.current,
profile_image: file_fixture_upload("profile_image.png", "image/png", :binary),
family_attributes: {
name: "New Family Name",
country: "US",
date_format: "%m/%d/%Y",
currency: "USD",
locale: "en"
}
}
}
assert_redirected_to settings_profile_url
assert_equal "Your profile has been updated.", flash[:notice]
end
test "member can deactivate their account" do
sign_in @member = users(:family_member)
delete user_url(@member)
assert_redirected_to root_url
assert_not User.find(@member.id).active?
assert_enqueued_with(job: UserPurgeJob, args: [ @member ])
end
test "admin prevented from deactivating when other users are present" do
sign_in @admin = users(:family_admin)
delete user_url(users(:family_member))
assert_redirected_to settings_profile_url
assert_equal "Admin cannot delete account while other users are present. Please delete all members first.", flash[:alert]
assert_no_enqueued_jobs only: UserPurgeJob
assert User.find(@admin.id).active?
end
test "admin can deactivate their account when they are the last user in the family" do
sign_in @admin = users(:family_admin)
users(:family_member).destroy
delete user_url(@admin)
assert_redirected_to root_url
assert_not User.find(@admin.id).active?
assert_enqueued_with(job: UserPurgeJob, args: [ @admin ])
end
end

View file

@ -4,6 +4,7 @@ empty:
last_name: One last_name: One
email: user1@email.com email: user1@email.com
password_digest: <%= BCrypt::Password.create('password') %> password_digest: <%= BCrypt::Password.create('password') %>
onboarded_at: <%= 3.days.ago %>
maybe_support_staff: maybe_support_staff:
family: empty family: empty
@ -12,6 +13,7 @@ maybe_support_staff:
email: support@maybe.co email: support@maybe.co
password_digest: <%= BCrypt::Password.create('password') %> password_digest: <%= BCrypt::Password.create('password') %>
role: super_admin role: super_admin
onboarded_at: <%= 3.days.ago %>
family_admin: family_admin:
family: dylan_family family: dylan_family
@ -20,6 +22,7 @@ family_admin:
email: bob@bobdylan.com email: bob@bobdylan.com
password_digest: <%= BCrypt::Password.create('password') %> password_digest: <%= BCrypt::Password.create('password') %>
role: admin role: admin
onboarded_at: <%= 3.days.ago %>
family_member: family_member:
family: dylan_family family: dylan_family
@ -27,3 +30,4 @@ family_member:
last_name: Dylan last_name: Dylan
email: jakobdylan@yahoo.com email: jakobdylan@yahoo.com
password_digest: <%= BCrypt::Password.create('password') %> password_digest: <%= BCrypt::Password.create('password') %>
onboarded_at: <%= 3.days.ago %>

View file

@ -16,7 +16,7 @@ class ImportsTest < ApplicationSystemTestCase
find('input[type="submit"][value="Upload CSV"]').click find('input[type="submit"][value="Upload CSV"]').click
select "Date", from: "Date" select "Date", from: "Date*"
select "YYYY-MM-DD", from: "Date format" select "YYYY-MM-DD", from: "Date format"
select "Amount", from: "Amount" select "Amount", from: "Amount"
select "Account", from: "Account (optional)" select "Account", from: "Account (optional)"