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
include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable
include Pagy::Backend
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
@session.destroy
redirect_to root_path, notice: t(".logout_successful")
redirect_to new_session_path, notice: t(".logout_successful")
end
private

View file

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

View file

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

View file

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

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 = {})
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

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 {
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 = `<img src="${e.target.result}" alt="Preview" class="w-full h-full rounded-full object-cover" />`;
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);
}
}

View file

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

View file

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

View file

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

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 %>
<div id="user-menu" data-controller="menu">
<button data-menu-target="button">
<% profile_image_attached = Current.user.profile_image.attached? %>
<% if profile_image_attached %>
<div class="text-white w-9 h-9">
<%= render "shared/user_profile_image", user: Current.user %>
<div class="w-9 h-9">
<%= render "settings/user_avatar", 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>
<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">
<% if profile_image_attached %>
<div class="text-white shrink-0 w-9 h-9">
<%= render "shared/user_profile_image", user: Current.user %>
<div class="w-9 h-9 shrink-0">
<%= render "settings/user_avatar", user: Current.user %>
</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">
<span class="text-gray-900 font-medium text-sm"><%= Current.user.display_name %></span>
<% if Current.user.display_name != Current.user.email %>

View file

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

View file

@ -1,30 +1,34 @@
<%= 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="grow flex flex-col justify-center">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<%= render "shared/logo" %>
<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">
<h2 class="text-3xl font-medium text-gray-900 text-center">
<%= content_for?(:header_title) ? yield(:header_title).html_safe : t(".your_account") %>
</h2>
<% if controller_name == "sessions" %>
<p class="mt-2 text-sm text-center text-gray-600">
<%= t(".or") %> <%= link_to t(".sign_up"), new_registration_path, class: "font-medium text-gray-600 hover:text-gray-400 transition" %>
<p class="text-sm text-center">
<%= 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>
<% elsif controller_name == "registrations" %>
<p class="mt-2 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" %>
<p class="text-sm text-center text-gray-600">
<%= t(".existing_account") %> <%= link_to t(".sign_in"), new_session_path, class: "font-medium text-gray-900 hover:underline transition" %>
</p>
<% end %>
</div>
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-lg">
<%= 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>
<%= render "layouts/footer" %>
</div>
<% 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? %>
<%= form.text_field :invite_code, required: "required", label: true, value: params[:invite] %>
<% end %>
<%= form.submit %>
<%= form.submit t(".submit") %>
<% end %>

View file

@ -11,5 +11,5 @@
<% end %>
<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>

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">
<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 %>
<% 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 } %>
<% 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 } %>

View file

@ -6,16 +6,28 @@
<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 %>
<div>
<%= styled_form_with model: Current.user, url: settings_preferences_path, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %>
<%= form.fields_for :family_attributes do |family_form| %>
<%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %>
<%= form.hidden_field :redirect_to, value: "preferences" %>
<%= form.fields_for :family do |family_form| %>
<%= family_form.select :currency,
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" } } %>
<%= family_form.select :locale,
I18n.available_locales,
{ label: "Locale", selected: Current.family.locale },
language_options,
{ 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" } } %>
<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 %>
<%= settings_section title: t(".theme_title"), subtitle: t(".theme_subtitle") do %>
<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">
<%= image_tag("light-mode-preview.png", alt: "Light Theme Preview", class: "h-44 mb-4") %>
<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>
<div class="space-y-4">
<%= 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| %>
<div class="flex items-center gap-4">
<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>
<%= styled_form_with model: @user, class: "space-y-4" do |form| %>
<%= render "settings/user_avatar_field", form: form, user: @user %>
<div>
<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 :last_name, placeholder: "Last name", value: Current.user.last_name, label: true %>
<%= form.text_field :first_name, placeholder: t(".first_name"), label: t(".first_name") %>
<%= form.text_field :last_name, placeholder: t(".last_name"), label: t(".last_name") %>
</div>
<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" %>
@ -43,9 +21,13 @@
<% end %>
<%= settings_section title: t(".household_title"), subtitle: t(".household_subtitle") do %>
<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| %>
<%= form.fields_for :family_attributes 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" %>
<%= styled_form_with model: Current.user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %>
<%= form.fields_for :family do |family_fields| %>
<%= 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 %>
<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>
</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",
data: { turbo_confirm: {
title: t(".confirm_delete.title"),

View file

@ -6,7 +6,12 @@
currency = Money::Currency.new(currency_value || options[:default_currency] || "USD") %>
<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 grow gap-1">

View file

@ -32,7 +32,7 @@
<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">
<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>
<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" } %>

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:
accounts:
sync_all_button:
sync: Sync all
account:
has_issues: Issue detected.
troubleshoot: Troubleshoot
account_list:
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}"'
new_account: New %{type}
accountables:
credit_card:
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_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:
amount_owed: Amount Owed
available_credit: Available Credit
minimum_payment: Minimum Payment
apr: APR
expiration_date: Expiration Date
annual_fee: Annual Fee
apr: APR
available_credit: Available Credit
expiration_date: Expiration Date
minimum_payment: Minimum Payment
unknown: Unknown
depository:
form:
@ -77,7 +37,8 @@ en:
tooltip:
cash: Cash
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:
form:
interest_rate: Interest rate
@ -133,17 +94,65 @@ en:
trend: Trend
unknown: Unknown
year: Year
create:
success: New account created successfully
destroy:
success: Account deleted successfully
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:
accounts: Accounts
add_institution: Add institution
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:
title: Add an account
manual_entry: Enter account manually
csv_entry: Import accounts CSV
connected_entry: Securely link account with Plaid (coming soon)
csv_entry: Import accounts CSV
manual_entry: Enter account manually
title: Add an account
show:
cash: Cash
holdings: Holdings
@ -157,17 +166,17 @@ en:
summary:
new: New
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_description: Add a liability either via connection, importing or entering manually.
create:
success: New account created successfully
destroy:
success: Account deleted successfully
update:
success: Account updated
no_liabilities_description: Add a liability either via connection, importing
or entering manually.
sync_all:
success: Successfully queued accounts for syncing.
sync_all_button:
sync: Sync all
update:
success: Account updated
credit_cards:
create:
success: Credit card created successfully

View file

@ -1,15 +1,15 @@
---
en:
impersonation_sessions:
create:
success: "Request sent to user. Waiting for approval."
join:
success: "Joined session"
leave:
success: "Left session"
approve:
success: "Request approved"
reject:
success: "Request rejected"
success: Request approved
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:
show:
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
configurations:
show:
@ -18,8 +19,8 @@ en:
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
and provide an account name we can use.
unassigned_account: Need to create a new account for unassigned rows?
rows_label: Rows
unassigned_account: Need to create a new account for unassigned rows?
show:
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

View file

@ -2,12 +2,14 @@
en:
layouts:
auth:
or: or
privacy_policy: Privacy Policy
sign_in: sign in to your account
sign_up: create an account
terms_of_service: Terms of Service
existing_account: Already have an account?
no_account: New to Maybe?
sign_in: Sign in
sign_up: Create account
your_account: Your account
footer:
privacy_policy: Privacy Policy
terms_of_service: Terms of Service
issues:
action: How to fix this issue
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:
assets: Assets
debts: Debts
greeting: Welcome back, %{name}
fallback_greeting: Welcome back, friend
greeting: Welcome back, %{name}
import: Import
income: Income
investing: Investing (coming soon...)

View file

@ -13,6 +13,7 @@ en:
invalid_invite_code: Invalid invite code, please try again.
success: You have signed up successfully.
new:
submit: Create account
title: Create an account
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.

View file

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

View file

@ -29,19 +29,19 @@ en:
previous: Back
preferences:
show:
country: Country
currency: Currency
date_format: Date format
general_subtitle: Configure your preferences
general_title: General
language: Language
page_title: Preferences
theme_dark: Dark
theme_light: Light
theme_subtitle: Choose a preferred theme for the app (coming soon...)
theme_system: System
theme_title: Theme
update:
success: Preferences updated successfully.
profiles:
destroy:
success: Account deleted successfully.
show:
confirm_delete:
body: Are you sure you want to permanently delete your account? This action
@ -51,16 +51,17 @@ en:
delete_account: Delete account
delete_account_warning: Deleting your account will permanently remove all
your data and cannot be undone.
first_name: First Name
household_form_input_placeholder: Enter household name
household_form_label: Household name
household_subtitle: Invite family members, partners and other inviduals. Invitees
can login to your household and access your shared accounts.
household_title: Household
last_name: Last Name
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_title: Profile
save: Save
update:
success: Profile updated successfully.
user_avatar_field:
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 "early-access", to: "pages#early_access"
resource :registration
resource :registration, only: %i[new create]
resources :sessions, only: %i[new create destroy]
resource :password_reset
resource :password
resource :password_reset, only: %i[new create edit update]
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
resource :profile, only: %i[show update destroy]
resource :preferences, only: %i[show update]
resource :profile, only: :show
resource :preferences, only: :show
resource :hosting, only: %i[show update]
resource :billing, only: :show
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.
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
enable_extension "pgcrypto"
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.date "last_sync_date"
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.string "mode"
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_customer_id"
t.string "stripe_subscription_status", default: "incomplete"
t.string "date_format", default: "%m-%d-%Y"
t.string "country", default: "US"
end
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 "role", default: "member", 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 ["family_id"], name: "index_users_on_family_id"
end

View file

@ -28,7 +28,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
sign_in @user
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]
end
end

View file

@ -9,35 +9,4 @@ class Settings::ProfilesControllerTest < ActionDispatch::IntegrationTest
get settings_profile_url
assert_response :success
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

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
email: user1@email.com
password_digest: <%= BCrypt::Password.create('password') %>
onboarded_at: <%= 3.days.ago %>
maybe_support_staff:
family: empty
@ -12,6 +13,7 @@ maybe_support_staff:
email: support@maybe.co
password_digest: <%= BCrypt::Password.create('password') %>
role: super_admin
onboarded_at: <%= 3.days.ago %>
family_admin:
family: dylan_family
@ -20,6 +22,7 @@ family_admin:
email: bob@bobdylan.com
password_digest: <%= BCrypt::Password.create('password') %>
role: admin
onboarded_at: <%= 3.days.ago %>
family_member:
family: dylan_family
@ -27,3 +30,4 @@ family_member:
last_name: Dylan
email: jakobdylan@yahoo.com
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
select "Date", from: "Date"
select "Date", from: "Date*"
select "YYYY-MM-DD", from: "Date format"
select "Amount", from: "Amount"
select "Account", from: "Account (optional)"