diff --git a/Gemfile.lock b/Gemfile.lock index 2e64fa11..8ec2c249 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -499,7 +499,7 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.7) - stripe (15.0.0) + stripe (15.1.0) tailwindcss-rails (4.2.2) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) diff --git a/Procfile.dev b/Procfile.dev index 68cba921..eb6eadeb 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,3 @@ web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0 -css: bundle exec bin/rails tailwindcss:watch +css: bundle exec bin/rails tailwindcss:watch 2>/dev/null worker: bundle exec sidekiq diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index a7d5b89c..4649a6bc 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -248,7 +248,7 @@ --color-success: var(--color-green-500); --color-warning: var(--color-yellow-400); --color-destructive: var(--color-red-400); - --color-shadow: --alpha(#000000 / 8%); + --color-shadow: --alpha(var(--color-white) / 8%); --shadow-xs: 0px 1px 2px 0px --alpha(var(--color-white) / 8%); --shadow-sm: 0px 1px 6px 0px --alpha(var(--color-white) / 8%); diff --git a/app/components/button_component.html.erb b/app/components/button_component.html.erb index e0c5e017..87268248 100644 --- a/app/components/button_component.html.erb +++ b/app/components/button_component.html.erb @@ -1,5 +1,5 @@ <%= container do %> - <% if icon && (icon_position != "right") %> + <% if icon && (icon_position != :right) %> <%= lucide_icon(icon, class: icon_classes) %> <% end %> @@ -7,7 +7,7 @@ <%= text %> <% end %> - <% if icon && icon_position == "right" %> + <% if icon && icon_position == :right %> <%= lucide_icon(icon, class: icon_classes) %> <% end %> <% end %> diff --git a/app/components/filled_icon_component.rb b/app/components/filled_icon_component.rb index e9c3ce68..dd0561c0 100644 --- a/app/components/filled_icon_component.rb +++ b/app/components/filled_icon_component.rb @@ -1,7 +1,7 @@ class FilledIconComponent < ViewComponent::Base attr_reader :icon, :text, :hex_color, :size, :rounded, :variant - VARIANTS = %i[default text surface container].freeze + VARIANTS = %i[default text surface container inverse].freeze SIZES = { sm: { @@ -72,6 +72,8 @@ class FilledIconComponent < ViewComponent::Base "bg-surface-inset" when :container "bg-container-inset" + when :inverse + "bg-container" end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index cebfea1f..44a94d12 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,27 +2,10 @@ class ApplicationController < ActionController::Base include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable, Notifiable include Pagy::Backend - helper_method :require_upgrade?, :subscription_pending? - before_action :detect_os before_action :set_default_chat private - def require_upgrade? - return false if self_hosted? - return false unless Current.session - return false if Current.family.subscribed? - return false if subscription_pending? || request.path == settings_billing_path - return false if Current.family.active_accounts_count <= 3 - - true - end - - def subscription_pending? - subscribed_at = Current.session.subscribed_at - subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago - end - def detect_os user_agent = request.user_agent @os = case user_agent diff --git a/app/controllers/concerns/onboardable.rb b/app/controllers/concerns/onboardable.rb index 80b15990..804667b4 100644 --- a/app/controllers/concerns/onboardable.rb +++ b/app/controllers/concerns/onboardable.rb @@ -2,16 +2,40 @@ module Onboardable extend ActiveSupport::Concern included do - before_action :redirect_to_onboarding, if: :needs_onboarding? + before_action :require_onboarding_and_upgrade + helper_method :subscription_pending? end private - def redirect_to_onboarding - redirect_to onboarding_path + # A subscription goes into "pending" mode immediately after checkout, but before webhooks are processed. + def subscription_pending? + subscribed_at = Current.session.subscribed_at + subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago end - def needs_onboarding? - Current.user && Current.user.onboarded_at.blank? && - !%w[/users /onboarding /sessions].any? { |path| request.path.start_with?(path) } + # First, we require onboarding, then once that's complete, we require an upgrade for non-subscribed users. + def require_onboarding_and_upgrade + return unless Current.user + return unless redirectable_path?(request.path) + + if Current.user.onboarded_at.blank? + redirect_to onboarding_path + elsif !Current.family.subscribed? && !Current.family.trialing? + redirect_to upgrade_subscription_path + end + end + + def redirectable_path?(path) + return false if path.starts_with?("/settings") + return false if path.starts_with?("/subscription") + return false if path.starts_with?("/onboarding") + return false if path.starts_with?("/users") + + [ + new_registration_path, + new_session_path, + new_password_reset_path, + new_email_confirmation_path + ].exclude?(path) end end diff --git a/app/controllers/onboardings_controller.rb b/app/controllers/onboardings_controller.rb index 36948f92..9b98be3b 100644 --- a/app/controllers/onboardings_controller.rb +++ b/app/controllers/onboardings_controller.rb @@ -1,14 +1,16 @@ class OnboardingsController < ApplicationController + layout "wizard" + before_action :set_user before_action :load_invitation def show end - def profile + def preferences end - def preferences + def trial end private diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb index 64552508..895c7192 100644 --- a/app/controllers/subscriptions_controller.rb +++ b/app/controllers/subscriptions_controller.rb @@ -1,41 +1,63 @@ class SubscriptionsController < ApplicationController - before_action :redirect_to_root_if_self_hosted + # Disables subscriptions for self hosted instances + guard_feature if: -> { self_hosted? } + + # Upgrade page for unsubscribed users + def upgrade + render layout: "onboardings" + end + + def start_trial + if Current.family.trial_started_at.present? + redirect_to root_path, alert: "You've already started or completed your trial" + else + Family.transaction do + Current.family.update(trial_started_at: Time.current) + Current.user.update(onboarded_at: Time.current) + end + + redirect_to root_path, notice: "Your trial has started" + end + end def new - if Current.family.stripe_customer_id.blank? - customer = stripe_client.v1.customers.create( + price_map = { + monthly: ENV["STRIPE_MONTHLY_PRICE_ID"], + annual: ENV["STRIPE_ANNUAL_PRICE_ID"] + } + + price_id = price_map[(params[:plan] || :monthly).to_sym] + + unless Current.family.existing_customer? + customer = stripe.create_customer( email: Current.family.primary_user.email, metadata: { family_id: Current.family.id } ) + Current.family.update(stripe_customer_id: customer.id) end - session = stripe_client.v1.checkout.sessions.create({ - customer: Current.family.stripe_customer_id, - line_items: [ { - price: ENV["STRIPE_PLAN_ID"], - quantity: 1 - } ], - mode: "subscription", - allow_promotion_codes: true, + checkout_session_url = stripe.get_checkout_session_url( + price_id: price_id, + customer_id: Current.family.stripe_customer_id, success_url: success_subscription_url + "?session_id={CHECKOUT_SESSION_ID}", - cancel_url: settings_billing_url - }) + cancel_url: upgrade_subscription_url(plan: params[:plan]) + ) - redirect_to session.url, allow_other_host: true, status: :see_other + redirect_to checkout_session_url, allow_other_host: true, status: :see_other end def show - portal_session = stripe_client.v1.billing_portal.sessions.create( - customer: Current.family.stripe_customer_id, + portal_session_url = stripe.get_billing_portal_session_url( + customer_id: Current.family.stripe_customer_id, return_url: settings_billing_url ) - redirect_to portal_session.url, allow_other_host: true, status: :see_other + redirect_to portal_session_url, allow_other_host: true, status: :see_other end def success - checkout_session = stripe_client.v1.checkout.sessions.retrieve(params[:session_id]) + checkout_session = stripe.retrieve_checkout_session(params[:session_id]) Current.session.update(subscribed_at: Time.at(checkout_session.created)) redirect_to root_path, notice: "You have successfully subscribed to Maybe+." rescue Stripe::InvalidRequestError @@ -43,11 +65,7 @@ class SubscriptionsController < ApplicationController end private - def stripe_client - @stripe_client ||= Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"]) - end - - def redirect_to_root_if_self_hosted - redirect_to root_path, alert: I18n.t("subscriptions.self_hosted_alert") if self_hosted? + def stripe + @stripe ||= Provider::Registry.get_provider(:stripe) end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 0bf735f1..2b3c0866 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -63,6 +63,10 @@ class UsersController < ApplicationController redirect_to root_path when "preferences" redirect_to settings_preferences_path, notice: notice + when "goals" + redirect_to goals_onboarding_path + when "trial" + redirect_to trial_onboarding_path else redirect_to settings_profile_path, notice: notice end @@ -83,8 +87,10 @@ class UsersController < ApplicationController def user_params params.require(:user).permit( - :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, :show_ai_sidebar, :ai_enabled, :theme, - family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ] + :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, + :show_sidebar, :default_period, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at, + family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ], + goals: [] ) end diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index f235ec07..d138e64f 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -33,61 +33,21 @@ class WebhooksController < ApplicationController end def stripe - webhook_body = request.body.read - sig_header = request.env["HTTP_STRIPE_SIGNATURE"] - client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"]) + stripe_provider = Provider::Registry.get_provider(:stripe) begin - thin_event = client.parse_thin_event(webhook_body, sig_header, ENV["STRIPE_WEBHOOK_SECRET"]) + webhook_body = request.body.read + sig_header = request.env["HTTP_STRIPE_SIGNATURE"] - event = client.v1.events.retrieve(thin_event.id) - - case event.type - when /^customer\.subscription\./ - handle_subscription_event(event) - when "customer.created", "customer.updated", "customer.deleted" - handle_customer_event(event) - else - Rails.logger.info "Unhandled event type: #{event.type}" - end + stripe_provider.process_webhook_later(webhook_body, sig_header) + head :ok rescue JSON::ParserError => error Sentry.capture_exception(error) - render json: { error: "Invalid payload" }, status: :bad_request - return + head :bad_request rescue Stripe::SignatureVerificationError => error Sentry.capture_exception(error) - render json: { error: "Invalid signature" }, status: :bad_request - return + head :bad_request end - - render json: { received: true }, status: :ok end - - private - - def handle_subscription_event(event) - subscription = event.data.object - family = Family.find_by(stripe_customer_id: subscription.customer) - - if family - family.update( - stripe_plan_id: subscription.plan.id, - stripe_subscription_status: subscription.status - ) - else - Rails.logger.error "Family not found for Stripe customer ID: #{subscription.customer}" - end - end - - def handle_customer_event(event) - customer = event.data.object - family = Family.find_by(stripe_customer_id: customer.id) - - if family - family.update(stripe_customer_id: customer.id) - else - Rails.logger.error "Family not found for Stripe customer ID: #{customer.id}" - end - end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0db4c9e5..999fd673 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -9,7 +9,7 @@ module ApplicationHelper def icon(key, size: "md", color: "default", custom: false, as_button: false, **opts) extra_classes = opts.delete(:class) sizes = { xs: "w-3 h-3", sm: "w-4 h-4", md: "w-5 h-5", lg: "w-6 h-6", xl: "w-7 h-7", "2xl": "w-8 h-8" } - colors = { default: "fg-gray", success: "text-success", warning: "text-warning", destructive: "text-destructive", current: "text-current" } + colors = { default: "fg-gray", white: "fg-inverse", success: "text-success", warning: "text-warning", destructive: "text-destructive", current: "text-current" } icon_classes = class_names( "shrink-0", diff --git a/app/javascript/controllers/onboarding_controller.js b/app/javascript/controllers/onboarding_controller.js index 2f9d031b..5712ab30 100644 --- a/app/javascript/controllers/onboarding_controller.js +++ b/app/javascript/controllers/onboarding_controller.js @@ -14,6 +14,10 @@ export default class extends Controller { this.refreshWithParam("currency", event.target.value); } + setTheme(event) { + document.documentElement.setAttribute("data-theme", event.target.value); + } + refreshWithParam(key, value) { const url = new URL(window.location); url.searchParams.set(key, value); diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js index b9c0782d..4a7a9f48 100644 --- a/app/javascript/controllers/theme_controller.js +++ b/app/javascript/controllers/theme_controller.js @@ -68,6 +68,15 @@ export default class extends Controller { this.setTheme(false); } + toggle() { + const currentTheme = document.documentElement.getAttribute("data-theme"); + if (currentTheme === "dark") { + this.toLight(); + } else { + this.toDark(); + } + } + startSystemThemeListener() { this.darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); this.darkMediaQuery.addEventListener( diff --git a/app/jobs/stripe_event_handler_job.rb b/app/jobs/stripe_event_handler_job.rb new file mode 100644 index 00000000..ac385169 --- /dev/null +++ b/app/jobs/stripe_event_handler_job.rb @@ -0,0 +1,9 @@ +class StripeEventHandlerJob < ApplicationJob + queue_as :default + + def perform(event_id) + stripe_provider = Provider::Registry.get_provider(:stripe) + Rails.logger.info "Processing Stripe event: #{event_id}" + stripe_provider.process_event(event_id) + end +end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index db479b08..a7ce8940 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -2,7 +2,7 @@ class Demo::Generator COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] # Builds a semi-realistic mirror of what production data might look like - def reset_and_clear_data!(family_names) + def reset_and_clear_data!(family_names, require_onboarding: false) puts "Clearing existing data..." destroy_everything! @@ -10,7 +10,7 @@ class Demo::Generator puts "Data cleared" family_names.each_with_index do |family_name, index| - create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local") + create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", require_onboarding: require_onboarding) end puts "Users reset" @@ -152,7 +152,7 @@ class Demo::Generator Security::Price.destroy_all end - def create_family_and_user!(family_name, user_email, currency: "USD") + def create_family_and_user!(family_name, user_email, currency: "USD", require_onboarding: false) base_uuid = "d99e3c6e-d513-4452-8f24-dc263f8528c0" id = Digest::UUID.uuid_v5(base_uuid, family_name) @@ -160,7 +160,7 @@ class Demo::Generator id: id, name: family_name, currency: currency, - stripe_subscription_status: "active", + stripe_subscription_status: require_onboarding ? nil : "active", locale: "en", country: "US", timezone: "America/New_York", @@ -173,7 +173,7 @@ class Demo::Generator last_name: "User", role: "admin", password: "password", - onboarded_at: Time.current + onboarded_at: require_onboarding ? nil : Time.current family.users.create! \ email: "member_#{user_email}", @@ -181,7 +181,7 @@ class Demo::Generator last_name: "User", role: "member", password: "password", - onboarded_at: Time.current + onboarded_at: require_onboarding ? nil : Time.current end def create_rules!(family) diff --git a/app/models/family.rb b/app/models/family.rb index 1ab64523..0adb1521 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -131,6 +131,18 @@ class Family < ApplicationRecord stripe_subscription_status == "active" end + def trialing? + !subscribed? && trial_started_at.present? && trial_started_at <= 14.days.from_now + end + + def trial_remaining_days + (14 - (Time.current - trial_started_at).to_i / 86400).to_i + end + + def existing_customer? + stripe_customer_id.present? + end + def requires_data_provider? # If family has any trades, they need a provider for historical prices return true if trades.any? diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb index 96548375..16fa81a2 100644 --- a/app/models/provider/registry.rb +++ b/app/models/provider/registry.rb @@ -19,6 +19,15 @@ class Provider::Registry end private + def stripe + secret_key = ENV["STRIPE_SECRET_KEY"] + webhook_secret = ENV["STRIPE_WEBHOOK_SECRET"] + + return nil unless secret_key.present? && webhook_secret.present? + + Provider::Stripe.new(secret_key:, webhook_secret:) + end + def synth api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key) diff --git a/app/models/provider/stripe.rb b/app/models/provider/stripe.rb new file mode 100644 index 00000000..09c67911 --- /dev/null +++ b/app/models/provider/stripe.rb @@ -0,0 +1,68 @@ +class Provider::Stripe + def initialize(secret_key:, webhook_secret:) + @client = Stripe::StripeClient.new( + secret_key, + stripe_version: "2025-04-30.basil" + ) + @webhook_secret = webhook_secret + end + + def process_event(event_id) + event = retrieve_event(event_id) + + case event.type + when /^customer\.subscription\./ + SubscriptionEventProcessor.new(client).process(event) + when /^customer\./ + CustomerEventProcessor.new(client).process(event) + else + Rails.logger.info "Unhandled event type: #{event.type}" + end + end + + def process_webhook_later(webhook_body, sig_header) + thin_event = client.parse_thin_event(webhook_body, sig_header, webhook_secret) + + if thin_event.type.start_with?("customer.") + StripeEventHandlerJob.perform_later(thin_event.id) + else + Rails.logger.info "Unhandled event type: #{thin_event.type}" + end + end + + def create_customer(email:, metadata: {}) + client.v1.customers.create( + email: email, + metadata: metadata + ) + end + + def get_checkout_session_url(price_id:, customer_id: nil, success_url: nil, cancel_url: nil) + client.v1.checkout.sessions.create( + customer: customer_id, + line_items: [ { price: price_id, quantity: 1 } ], + mode: "subscription", + allow_promotion_codes: true, + success_url: success_url, + cancel_url: cancel_url + ).url + end + + def get_billing_portal_session_url(customer_id:, return_url: nil) + client.v1.billing_portal.sessions.create( + customer: customer_id, + return_url: return_url + ).url + end + + def retrieve_checkout_session(session_id) + client.v1.checkout.sessions.retrieve(session_id) + end + + private + attr_reader :client, :webhook_secret + + def retrieve_event(event_id) + client.v2.core.events.retrieve(event_id) + end +end diff --git a/app/models/provider/stripe/customer_event_processor.rb b/app/models/provider/stripe/customer_event_processor.rb new file mode 100644 index 00000000..6efce0b2 --- /dev/null +++ b/app/models/provider/stripe/customer_event_processor.rb @@ -0,0 +1,20 @@ +class Provider::Stripe::CustomerEventProcessor < Provider::Stripe::EventProcessor + Error = Class.new(StandardError) + + def process + raise Error, "Family not found for Stripe customer ID: #{customer_id}" unless family + + family.update( + stripe_customer_id: customer_id + ) + end + + private + def family + Family.find_by(stripe_customer_id: customer_id) + end + + def customer_id + event_data.id + end +end diff --git a/app/models/provider/stripe/event_processor.rb b/app/models/provider/stripe/event_processor.rb new file mode 100644 index 00000000..03806d1d --- /dev/null +++ b/app/models/provider/stripe/event_processor.rb @@ -0,0 +1,17 @@ +class Provider::Stripe::EventProcessor + def initialize(event:, client:) + @event = event + @client = client + end + + def process + raise NotImplementedError, "Subclasses must implement the process method" + end + + private + attr_reader :event, :client + + def event_data + event.data.object + end +end diff --git a/app/models/provider/stripe/subscription_event_processor.rb b/app/models/provider/stripe/subscription_event_processor.rb new file mode 100644 index 00000000..773b91d1 --- /dev/null +++ b/app/models/provider/stripe/subscription_event_processor.rb @@ -0,0 +1,29 @@ +class Provider::Stripe::SubscriptionEventProcessor < Provider::Stripe::EventProcessor + Error = Class.new(StandardError) + + def process + raise Error, "Family not found for Stripe customer ID: #{customer_id}" unless family + + family.update( + stripe_plan_id: plan_id, + stripe_subscription_status: subscription_status + ) + end + + private + def family + Family.find_by(stripe_customer_id: customer_id) + end + + def customer_id + event_data.customer + end + + def plan_id + event_data.plan.id + end + + def subscription_status + event_data.status + end +end diff --git a/app/views/imports/_nav.html.erb b/app/views/imports/_nav.html.erb index eb8f5c08..898c7825 100644 --- a/app/views/imports/_nav.html.erb +++ b/app/views/imports/_nav.html.erb @@ -28,7 +28,7 @@ step[:is_complete] ? "text-green-600" : "text-secondary" end %> <% step_class = if is_current - "bg-primary text-white" + "bg-surface-inset text-primary" else step[:is_complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-container-inset" end %> @@ -36,7 +36,7 @@ <%= link_to step[:path], class: "flex items-center gap-3" do %>
- <%= step[:is_complete] && !is_current ? icon("check", size: "sm") : idx + 1 %> + <%= step[:is_complete] && !is_current ? icon("check", size: "sm", color: "current") : idx + 1 %> <%= step[:name] %> diff --git a/app/views/layouts/shared/_footer.html.erb b/app/views/layouts/shared/_footer.html.erb index ff5f59d6..029a0b5a 100644 --- a/app/views/layouts/shared/_footer.html.erb +++ b/app/views/layouts/shared/_footer.html.erb @@ -1,6 +1,10 @@ diff --git a/app/views/layouts/shared/_htmldoc.html.erb b/app/views/layouts/shared/_htmldoc.html.erb index ae44dc10..9c3908c3 100644 --- a/app/views/layouts/shared/_htmldoc.html.erb +++ b/app/views/layouts/shared/_htmldoc.html.erb @@ -1,11 +1,23 @@ -"> + +<% theme = Current.user&.theme || "system" %> + +" + class="h-full text-primary overflow-hidden lg:overflow-auto font-sans <%= @os %>"> <%= render "layouts/shared/head" %> <%= yield :head %> + <% if Rails.env.development? %> + + <% end %> +
<%= render_flash_notifications %> @@ -20,17 +32,6 @@ <%= family_stream %> - <% if Rails.env.development? %> -
- <%= icon("eclipse", as_button: true, data: { action: "theme#toDark" }) %> - <%= icon("sun", as_button: true, data: { action: "theme#toLight" }) %> -
- <% end %> - - <% if require_upgrade? %> - <%= render "shared/subscribe_modal" %> - <% end %> - <%= turbo_frame_tag "modal" %> <%= turbo_frame_tag "drawer" %> diff --git a/app/views/layouts/wizard.html.erb b/app/views/layouts/wizard.html.erb index a4c5c408..cd775c34 100644 --- a/app/views/layouts/wizard.html.erb +++ b/app/views/layouts/wizard.html.erb @@ -1,25 +1,37 @@ <%= render "layouts/shared/htmldoc" do %> -
+
- <%= render LinkComponent.new( - variant: "icon", - icon: "arrow-left", - href: content_for(:previous_path) || root_path - ) %> + <% if content_for?(:prev_nav) %> + <%= yield :prev_nav %> + <% else %> + <%= render LinkComponent.new( + variant: "icon", + icon: "arrow-left", + href: content_for(:previous_path) || root_path + ) %> + <% end %> - <%= render LinkComponent.new( - variant: "icon", - icon: "x", - href: content_for(:cancel_path) || root_path - ) %> + <% if content_for?(:cancel_action) %> + <%= yield :cancel_action %> + <% else %> + <%= render LinkComponent.new( + variant: "icon", + icon: "x", + href: content_for(:cancel_path) || root_path + ) %> + <% end %>
<%= yield %>
+ + <% if content_for?(:footer) %> + <%= yield :footer %> + <% end %>
<% end %> diff --git a/app/views/onboardings/_header.html.erb b/app/views/onboardings/_header.html.erb deleted file mode 100644 index 8ba333c1..00000000 --- a/app/views/onboardings/_header.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -
- <%= image_tag "logo.svg", class: "h-[22px]" %> -
- <%= icon("log-in", color: "secondary") %> - <%= button_to t(".sign_out"), session_path(Current.session), method: :delete, class: "text-sm text-primary font-medium" %> -
-
diff --git a/app/views/onboardings/_logout.html.erb b/app/views/onboardings/_logout.html.erb new file mode 100644 index 00000000..20f43408 --- /dev/null +++ b/app/views/onboardings/_logout.html.erb @@ -0,0 +1,8 @@ + <%= render ButtonComponent.new( + text: "Sign out", + icon: "log-out", + icon_position: :right, + variant: "ghost", + href: session_path(Current.session), + method: :delete + ) %> diff --git a/app/views/onboardings/_onboarding_nav.html.erb b/app/views/onboardings/_onboarding_nav.html.erb new file mode 100644 index 00000000..6e24cc0f --- /dev/null +++ b/app/views/onboardings/_onboarding_nav.html.erb @@ -0,0 +1,39 @@ +<%# locals: (user:) %> + +<% steps = [ + { name: "Setup", path: onboarding_path, is_complete: user.first_name.present?, step_number: 1 }, + { name: "Preferences", path: preferences_onboarding_path, is_complete: user.set_onboarding_preferences_at.present?, step_number: 2 }, + { name: "Goals", path: goals_onboarding_path , is_complete: user.set_onboarding_goals_at.present?, step_number: 3 }, + { name: "Start", path: trial_onboarding_path, is_complete: user.onboarded_at.present?, step_number: 4 }, +] %> + + diff --git a/app/views/onboardings/goals.html.erb b/app/views/onboardings/goals.html.erb new file mode 100644 index 00000000..3d237cbd --- /dev/null +++ b/app/views/onboardings/goals.html.erb @@ -0,0 +1,53 @@ +<%= content_for :previous_path, preferences_onboarding_path %> + +<%= content_for :header_nav do %> + <%= render "onboardings/onboarding_nav", user: @user %> +<% end %> + +<%= content_for :cancel_action do %> + <%= render "onboardings/logout" %> +<% end %> + +<%= content_for :footer do %> + <%= render "layouts/shared/footer" %> +<% end %> + +
+
+
+

What brings you to Maybe?

+

Select one or more goals that you have with using Maybe as your personal finance tool.

+
+ + <%= form_with model: @user do |form| %> + <%= form.hidden_field :redirect_to, value: "trial" %> + <%= form.hidden_field :set_onboarding_goals_at, value: Time.current %> + +
+ <% [ + { icon: "layers", label: "See all my accounts in one piece", value: "unified_accounts" }, + { icon: "banknote", label: "Understand cashflow and expenses", value: "cashflow" }, + { icon: "pie-chart", label: "Manage financial plans and budgeting", value: "budgeting" }, + { icon: "users", label: "Manage finances with a partner", value: "partner" }, + { icon: "area-chart", label: "Track investments", value: "investments" }, + { icon: "bot", label: "Let AI help me understand my finances", value: "ai_insights" }, + { icon: "settings-2", label: "Analyze and optimize accounts", value: "optimization" }, + { icon: "frown", label: "Reduce financial stress or anxiety", value: "reduce_stress" } + ].each do |goal| %> + + <% end %> +
+ +
+ <%= render ButtonComponent.new( + text: "Next", + full_width: true + ) %> +
+ <% end %> +
+
diff --git a/app/views/onboardings/preferences.html.erb b/app/views/onboardings/preferences.html.erb index 95e701dd..a017f64d 100644 --- a/app/views/onboardings/preferences.html.erb +++ b/app/views/onboardings/preferences.html.erb @@ -1,26 +1,33 @@ -
- <%= render "onboardings/header" %> +<%= content_for :previous_path, onboarding_path %> -
-
-
-

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

-

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

-
+<%= content_for :header_nav do %> + <%= render "onboardings/onboarding_nav", user: @user %> +<% end %> -
-
-
- <%= tag.p t(".example"), class: "text-secondary text-sm" %> - <%= tag.p format_money(Money.new(2325.25, params[:currency] || @user.family.currency)), class: "text-primary font-medium text-2xl" %> -

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

-
+<%= content_for :cancel_action do %> + <%= render "onboardings/logout" %> +<% end %> - <% placeholder_series_data = [ +
+
+
+

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

+

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

+
+ +
+
+
+ <%= tag.p t(".example"), class: "text-secondary text-sm" %> + <%= tag.p format_money(Money.new(2325.25, params[:currency] || @user.family.currency)), class: "text-primary font-medium text-2xl" %> +

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

+
+ + <% placeholder_series_data = [ { date: Date.current - 14.days, value: 200 }, { date: Date.current - 13.days, value: 200 }, { date: Date.current - 12.days, value: 220 }, @@ -38,53 +45,54 @@ { date: Date.current, value: 265 } ] %> - <% placeholder_series = Series.from_raw_values(placeholder_series_data) %> + <% placeholder_series = Series.from_raw_values(placeholder_series_data) %> -
-
-
+
+
-
+
-

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

+

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

- <%= styled_form_with model: @user, data: { turbo: false } do |form| %> - <%= form.hidden_field :onboarded_at, value: Time.current %> - <%= form.hidden_field :redirect_to, value: "home" %> + <%= styled_form_with model: @user, data: { turbo: false } do |form| %> + <%= form.hidden_field :set_onboarding_preferences_at, value: Time.current %> + <%= form.hidden_field :redirect_to, value: "goals" %> -
- <%= form.fields_for :family do |family_form| %> +
+ <%= form.select :theme, [["System", "system"], ["Light", "light"], ["Dark", "dark"]], { label: "Color theme" }, data: { action: "onboarding#setTheme" } %> +
- <%= family_form.select :locale, +
+ <%= 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, + <%= family_form.select :currency, Money::Currency.as_options.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }, { label: t(".currency"), required: true, selected: params[:currency] || @user.family.currency }, { data: { action: "onboarding#setCurrency" } } %> - <%= family_form.select :date_format, + <%= family_form.select :date_format, Family::DATE_FORMATS, { label: t(".date_format"), required: true, selected: params[:date_format] || @user.family.date_format }, { data: { action: "onboarding#setDateFormat" } } %> - <% end %> -
+ <% end %> +
- <%= form.submit t(".submit") %> - <% end %> -
+ <%= form.submit t(".submit") %> + <% end %>
- - <%= render "layouts/shared/footer" %>
diff --git a/app/views/onboardings/profile.html.erb b/app/views/onboardings/profile.html.erb deleted file mode 100644 index e4a61cc4..00000000 --- a/app/views/onboardings/profile.html.erb +++ /dev/null @@ -1,42 +0,0 @@ -
- <%= render "onboardings/header" %> - -
-
-
-

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

-

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

-
- - <%= styled_form_with model: @user do |form| %> - <%= form.hidden_field :redirect_to, value: @invitation ? "home" : "onboarding_preferences" %> - <%= form.hidden_field :onboarded_at, value: Time.current if @invitation %> - -
- - <%= render "settings/user_avatar_field", form: form, user: @user %> -
- -
- <%= form.text_field :first_name, placeholder: t(".first_name"), label: t(".first_name"), container_class: "bg-container md:w-1/2 w-full", required: true %> - <%= form.text_field :last_name, placeholder: t(".last_name"), label: t(".last_name"), container_class: "bg-container md:w-1/2 w-full", required: true %> -
- <% unless @invitation %> -
- <%= 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 %> -
- <% end %> - - <%= form.submit t(".submit") %> - <% end %> -
-
- - <%= render "layouts/shared/footer" %> -
diff --git a/app/views/onboardings/show.html.erb b/app/views/onboardings/show.html.erb index 542691a3..9a2837e6 100644 --- a/app/views/onboardings/show.html.erb +++ b/app/views/onboardings/show.html.erb @@ -1,16 +1,51 @@ -
-
-
- <%= 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-secondary mb-6" %> +<%= content_for :previous_path, onboarding_path %> - <%= render LinkComponent.new( - text: t(".setup"), - href: profile_onboarding_path, - variant: "primary", - full_width: true - ) %> +<%= content_for :header_nav do %> + <%= render "onboardings/onboarding_nav", user: @user %> +<% end %> + +<%= content_for :cancel_action do %> + <%= render "onboardings/logout" %> +<% end %> + +
+
+
+

Let's set up your account

+

First things first, let's get your profile set up.

+ + <%= styled_form_with model: @user do |form| %> + <%= form.hidden_field :redirect_to, value: @invitation ? "home" : "onboarding_preferences" %> + <%= form.hidden_field :onboarded_at, value: Time.current if @invitation %> + +
+ <%= render "settings/user_avatar_field", form: form, user: @user %> +
+ +
+ <%= form.text_field :first_name, placeholder: "First name", label: "First name", container_class: "bg-container md:w-1/2 w-full", required: true %> + <%= form.text_field :last_name, placeholder: "Last name", label: "Last name", container_class: "bg-container md:w-1/2 w-full", required: true %> +
+ + <% unless @invitation %> +
+ <%= form.fields_for :family do |family_form| %> + <%= family_form.text_field :name, placeholder: "Household name", label: "Household name" %> + + <%= family_form.select :country, + country_options, + { label: "Country" }, + required: true + %> + <% end %> +
+ <% end %> + + <%= form.submit "Continue" %> + <% end %>
+ +<%= render "layouts/shared/footer" %> +
diff --git a/app/views/onboardings/trial.html.erb b/app/views/onboardings/trial.html.erb new file mode 100644 index 00000000..4a7e1ab5 --- /dev/null +++ b/app/views/onboardings/trial.html.erb @@ -0,0 +1,121 @@ +<%= content_for :previous_path, goals_onboarding_path %> + +<%= content_for :header_nav do %> + <%= render "onboardings/onboarding_nav", user: @user %> +<% end %> + +<%= content_for :cancel_action do %> + <%= render "onboardings/logout" %> +<% end %> + +<%= content_for :footer do %> + <%= render "layouts/shared/footer" %> +<% end %> + +
+
+ <%= image_tag "logo-color.png", class: "w-16 mb-6" %> + +

+ Try Maybe for 14 days. +

+ +

+ No credit card required +

+ +

+ Starting the trial activates your account for Maybe. You won't need to enter payment details. +

+ +
+ <%= render ButtonComponent.new( + text: "Try Maybe for 14 days", + href: start_trial_subscription_path, + full_width: true + ) %> +
+
+ +
+

How your trial will work

+ +
+
+ <%= render FilledIconComponent.new(icon: "unlock-keyhole", variant: :inverse) %> + <%= render FilledIconComponent.new(icon: "bell", variant: :inverse) %> + <%= render FilledIconComponent.new(icon: "credit-card", variant: :inverse) %> +
+ +
+
+

Today

+

You'll get free access to Maybe for 14 days

+
+ +
+

In 13 days (<%= 13.days.from_now.strftime("%B %d") %>)

+

We'll notify you to remind you when your trial will end.

+
+ +
+

In 14 days (<%= 14.days.from_now.strftime("%B %d") %>)

+

Your trial ends — subscribe to continue using Maybe

+
+
+
+
+ +
+

Here's what's included

+ +
+
+ <%= render FilledIconComponent.new(icon: "landmark", variant: :surface) %> +

More than 10,000 institutions to connect to

+
+ +
+ <%= render FilledIconComponent.new(icon: "layers", variant: :surface) %> +

Connect unlimited accounts and account types

+
+ +
+ <%= render FilledIconComponent.new(icon: "line-chart", variant: :surface) %> +

Performance and investment returns across portfolio

+
+ +
+ <%= render FilledIconComponent.new(icon: "credit-card", variant: :surface) %> +

Comprehensive transaction tracking experience

+
+ +
+
+ <%= render "chats/ai_avatar" %> +
+

Unlimited access and chats with Maybe AI

+
+ +
+ <%= render FilledIconComponent.new(icon: "keyboard", variant: :surface) %> +

Manual account tracking that works well

+
+ +
+ <%= render FilledIconComponent.new(icon: "globe-2", variant: :surface) %> +

Multiple currencies and near global coverage

+
+ +
+ <%= render FilledIconComponent.new(icon: "ship", variant: :surface) %> +

Early access to newly released features

+
+ +
+ <%= render FilledIconComponent.new(icon: "messages-square", variant: :surface) %> +

Priority human support from team

+
+
+
+
diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 97eada58..569caaa2 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -2,7 +2,7 @@

Welcome back, <%= Current.user.first_name %>

-

Here's what's happening with your finances

+

Here's what's happening with your finances

<%= render LinkComponent.new( diff --git a/app/views/pages/early_access.html.erb b/app/views/pages/early_access.html.erb deleted file mode 100644 index b710d85f..00000000 --- a/app/views/pages/early_access.html.erb +++ /dev/null @@ -1,54 +0,0 @@ - - - - <%= content_for(:title) || "🔒 Maybe Early Access" %> - - <%= csrf_meta_tags %> - <%= csp_meta_tag %> - - <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> - - <%= javascript_importmap_tags %> - <%= turbo_refreshes_with method: :morph, scroll: :preserve %> - - - - - - - - - <%= yield :head %> - - - ');"> -
-
- <%= image_tag "logo-squircle.svg", alt: "Maybe Logo", class: "w-16 h-16 sm:w-18 sm:h-18 mx-auto mb-6 sm:mb-8" %> -

Maybe Early Access

- <% if @invite_codes_count > 0 %> -

There <%= @invite_codes_count == 1 ? "is" : "are" %> <%= @invite_codes_count %> invite <%= "code".pluralize(@invite_codes_count) %> remaining.

-
-

Your invite code is <%= @invite_code.token %>

-

<%= link_to "Sign up with this code", new_registration_path(invite: @invite_code.token), class: "block w-full bg-container text-black py-2 px-3 rounded-lg no-underline text-sm sm:text-base hover:bg-gray-200 transition duration-150" %>

-
- -

You may need to refresh the page to get a new invite code if someone else claimed it before you.

- -

- <%= link_to early_access_path, class: "w-full block text-center justify-center inline-flex items-center text-white hover:bg-gray-800 p-2 rounded-md text-base transition duration-150", data: { turbo_method: :get } do %> - <%= icon "refresh-cw", class: "mr-2" %> - Refresh page - <% end %> -

- <% else %> -

Sorry, there are no invite codes remaining. Join our <%= link_to "Discord server", "https://link.maybe.co/discord", target: "_blank", class: "text-white hover:text-gray-300" %> to get notified when new invite codes are available.

-

<%= link_to "Join Discord server", "https://link.maybe.co/discord", target: "_blank", class: "bg-container text-black px-3 py-2 rounded-md no-underline text-base hover:bg-gray-200 transition duration-150" %>

- <% end %> -
-
-
- ©2024 Maybe Finance, Inc. -
- - diff --git a/app/views/settings/_user_avatar_field.html.erb b/app/views/settings/_user_avatar_field.html.erb index c312b910..1bedac90 100644 --- a/app/views/settings/_user_avatar_field.html.erb +++ b/app/views/settings/_user_avatar_field.html.erb @@ -1,40 +1,41 @@ <%# locals: (form:, user:) %> -
-
- - <%# The image preview once user has uploaded a new file %> - - - <%# The placeholder image for empty avatar field %> -
"> -
- <%= icon "image-plus", size: "lg" %> -
-
- - <%# The attached image if user has already uploaded one %> -
"> - <% if user.profile_image.attached? %> -
- <%= render "settings/user_avatar", avatar_url: user.profile_image.url %> -
- <% end %> -
- +
+
+ +
+ <%# The image preview once user has uploaded a new file %> + + + <%# The placeholder image for empty avatar field %> +
"> +
+ <%= icon "image-plus", size: "lg" %> +
+
+ + <%# The attached image if user has already uploaded one %> +
"> + <% if user.profile_image.attached? %> +
+ <%= render "settings/user_avatar", avatar_url: user.profile_image.url %> +
+ <% end %> +
+
-
+
<%= form.hidden_field :delete_profile_image, value: "0", data: { profile_image_preview_target: "deleteProfileImage" } %> <%= form.label :profile_image, class: "px-3 py-2 rounded-lg text-sm hover:bg-surface-hover border border-secondary inline-flex items-center gap-2 cursor-pointer", data: { profile_image_preview_target: "uploadButton" } do %> diff --git a/app/views/settings/billings/show.html.erb b/app/views/settings/billings/show.html.erb index 1b4cc5e2..221cdaad 100644 --- a/app/views/settings/billings/show.html.erb +++ b/app/views/settings/billings/show.html.erb @@ -4,12 +4,25 @@
-
- <%= icon "gem" %> -
+ <%= render FilledIconComponent.new( + icon: "gem", + rounded: true, + size: "lg" + ) %>
- <% if @user.family.subscribed? || subscription_pending? %> + <% if subscription_pending? %> +

+ Your subscription is pending. You can still use Maybe+ while we process your subscription. +

+ <% elsif @user.family.trialing? %> +

+ You are currently trialing Maybe+ + + (<%= @user.family.trial_remaining_days %> days remaining) + +

+ <% elsif @user.family.subscribed? %>

You are currently subscribed to Maybe+

<% else %>

You are currently not subscribed

@@ -18,24 +31,22 @@
- <% if @user.family.subscribed? || subscription_pending? %> + <% if @user.family.subscribed? %> <%= render LinkComponent.new( text: "Manage", icon: "external-link", variant: "primary", icon_position: "right", href: subscription_path, - target: "_blank", rel: "noopener" ) %> - <% else %> + <% elsif @user.family.trialing? && !subscription_pending? %> <%= render LinkComponent.new( - text: "Subscribe", + text: "Choose plan", variant: "primary", - icon: "external-link", + icon: "plus", icon_position: "right", - href: new_subscription_path, - target: "_blank", + href: upgrade_subscription_path(view: "upgrade"), rel: "noopener") %> <% end %>
diff --git a/app/views/shared/_subscribe_modal.html.erb b/app/views/shared/_subscribe_modal.html.erb deleted file mode 100644 index 62d7d72a..00000000 --- a/app/views/shared/_subscribe_modal.html.erb +++ /dev/null @@ -1,26 +0,0 @@ -<%= render DialogComponent.new do |dialog| %> -
'); background-size: cover; background-position: center top;"> -
-
-
- <%= image_tag "maybe-plus-logo.png", class: "w-20" %> -
- -

Join Maybe+

- -
-

Nobody likes paywalls, but we need feedback from users willing to pay for Maybe.

- -

To continue using the app, please subscribe. In this early beta testing phase, we require that you upgrade within one hour to claim your spot.

-
- - <%= render LinkComponent.new( - text: "Upgrade to Maybe+", - href: new_subscription_path, - variant: "primary", - full_width: true - ) %> -
-
-
-<% end %> diff --git a/app/views/shared/notifications/_alert.html.erb b/app/views/shared/notifications/_alert.html.erb index 28373aa8..06ccde60 100644 --- a/app/views/shared/notifications/_alert.html.erb +++ b/app/views/shared/notifications/_alert.html.erb @@ -4,7 +4,7 @@ data: { controller: "element-removal" } do %>
- <%= icon "x", size: "xs" %> + <%= icon "x", size: "xs", color: "white" %>
diff --git a/app/views/shared/notifications/_notice.html.erb b/app/views/shared/notifications/_notice.html.erb index d4931716..a542e729 100644 --- a/app/views/shared/notifications/_notice.html.erb +++ b/app/views/shared/notifications/_notice.html.erb @@ -1,13 +1,13 @@ <%# locals: (message:, description: nil) %> -<%= tag.div class: "relative flex gap-3 rounded-lg bg-container-inset p-4 group w-full md:max-w-80 shadow-border-xs", +<%= tag.div class: "relative flex gap-3 rounded-lg bg-container p-4 group w-full md:max-w-80 shadow-border-xs", data: { controller: "element-removal", action: "animationend->element-removal#remove" } do %>
- <%= icon "check", size: "xs" %> + <%= icon "check", size: "xs", color: "white" %>
diff --git a/app/views/subscriptions/_plan_choice.html.erb b/app/views/subscriptions/_plan_choice.html.erb new file mode 100644 index 00000000..b1a2b5eb --- /dev/null +++ b/app/views/subscriptions/_plan_choice.html.erb @@ -0,0 +1,31 @@ +<%# locals: (plan:, form:, checked: false) %> + +<% price = plan == "annual" ? 90 : 9 %> +<% frequency = plan == "annual" ? "/year" : "/month" %> + +
+ <%= form.radio_button :plan, plan, class: "peer sr-only", checked: checked %> + <%= form.label "plan_#{plan}", class: class_names( + "flex flex-col gap-1 p-4 cursor-pointer rounded-lg border border-primary hover:bg-container", + "peer-checked:bg-container peer-checked:rounded-2xl peer-checked:border-solid peer-checked:ring-4 peer-checked:ring-shadow", + "transition-all duration-300" + ) do %> +

<%= plan.titleize %>

+ +
+

$<%= price %><%= frequency %>

+ + <% if plan == "annual" %> + or <%= Money.new(price.to_f / 52).format %>/week + <% end %> +
+ +

+ <% if plan == "annual" %> + Billed annually, 2 months free + <% else %> + Billed monthly + <% end %> +

+ <% end %> +
diff --git a/app/views/subscriptions/upgrade.html.erb b/app/views/subscriptions/upgrade.html.erb new file mode 100644 index 00000000..b66e927b --- /dev/null +++ b/app/views/subscriptions/upgrade.html.erb @@ -0,0 +1,55 @@ +
+ + +
+ <%= image_tag "logo-color.png", class: "w-16 mb-6" %> + + <% if Current.family.trialing? %> +

Your trial has <%= Current.family.trial_remaining_days %> days remaining

+ <% else %> +

Your trial is over

+ <% end %> + +

+ Unlock + Maybe + today +

+ +

To continue using Maybe pick a plan below.

+ + <%= form_with url: new_subscription_path, method: :get, class: "max-w-xs", data: { turbo: false } do |form| %> +
+ <%= render "subscriptions/plan_choice", form: form, plan: "annual", checked: params[:plan] == "annual" || params[:plan].blank? %> + <%= render "subscriptions/plan_choice", form: form, plan: "monthly", checked: params[:plan] == "monthly" %> +
+ +
+ <%= render ButtonComponent.new( + text: "Subscribe and unlock Maybe", + variant: "primary", + full_width: true + ) %> + +

+ In the next step, you'll be redirected to Stripe which handles our billing. +

+
+ <% end %> +
+ + <%= render "layouts/shared/footer" %> +
diff --git a/config/environments/development.rb b/config/environments/development.rb index f8ad2c13..553da47e 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -35,6 +35,9 @@ Rails.application.configure do # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", "local").to_sym + config.after_initialize do + ActiveStorage::Current.url_options = { host: "localhost", port: 3000 } + end # Set Active Storage URL expiration time to 7 days config.active_storage.urls_expire_in = 7.days @@ -61,10 +64,8 @@ Rails.application.configure do # Raise an error on page load if there are pending migrations. config.active_record.migration_error = :page_load - # Highlight code that triggered database queries in logs. + config.assets.quiet = true config.active_record.verbose_query_logs = true - - # Highlight code that enqueued background job in logs. config.active_job.verbose_enqueue_logs = true # Raises error for missing translations. diff --git a/config/routes.rb b/config/routes.rb index e0f5d9d5..b0538fb1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,7 +24,6 @@ Rails.application.routes.draw do get "changelog", to: "pages#changelog" get "feedback", to: "pages#feedback" - get "early-access", to: "pages#early_access" resource :registration, only: %i[new create] resources :sessions, only: %i[new create destroy] @@ -39,8 +38,9 @@ Rails.application.routes.draw do resource :onboarding, only: :show do collection do - get :profile get :preferences + get :goals + get :trial end end @@ -55,7 +55,11 @@ Rails.application.routes.draw do end resource :subscription, only: %i[new show] do - get :success, on: :collection + collection do + get :upgrade + get :success + post :start_trial + end end resources :tags, except: :show do @@ -212,6 +216,9 @@ Rails.application.routes.draw do get "imports/:import_id/upload/sample_csv", to: "import/uploads#sample_csv", as: :import_upload_sample_csv + get "privacy", to: redirect("https://maybefinance.com/privacy") + get "terms", to: redirect("https://maybefinance.com/tos") + # Defines the root path route ("/") root "pages#dashboard" end diff --git a/db/migrate/20250501172430_add_user_goals.rb b/db/migrate/20250501172430_add_user_goals.rb new file mode 100644 index 00000000..6691ecda --- /dev/null +++ b/db/migrate/20250501172430_add_user_goals.rb @@ -0,0 +1,17 @@ +class AddUserGoals < ActiveRecord::Migration[7.2] + def change + add_column :users, :goals, :text, array: true, default: [] + add_column :users, :set_onboarding_preferences_at, :datetime + add_column :users, :set_onboarding_goals_at, :datetime + + add_column :families, :trial_started_at, :datetime + add_column :families, :early_access, :boolean, default: false + + reversible do |dir| + # All existing families are marked as early access now that we're out of alpha + dir.up do + Family.update_all(early_access: true) + end + end + end +end diff --git a/db/schema.rb b/db/schema.rb index adb03c7d..c5db79e9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_04_16_235758) do +ActiveRecord::Schema[7.2].define(version: 2025_05_01_172430) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -230,6 +230,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_16_235758) do t.datetime "last_synced_at" t.string "timezone" t.boolean "data_enrichment_enabled", default: false + t.datetime "trial_started_at" + t.boolean "early_access", default: false end create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -679,6 +681,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_16_235758) do t.string "theme", default: "system" t.boolean "rule_prompts_disabled", default: false t.datetime "rule_prompt_dismissed_at" + t.text "goals", default: [], array: true + t.datetime "set_onboarding_preferences_at" + t.datetime "set_onboarding_goals_at" t.index ["email"], name: "index_users_on_email", unique: true t.index ["family_id"], name: "index_users_on_family_id" t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id" diff --git a/lib/tasks/demo_data.rake b/lib/tasks/demo_data.rake index 2f5a1b6d..c57a006f 100644 --- a/lib/tasks/demo_data.rake +++ b/lib/tasks/demo_data.rake @@ -5,6 +5,11 @@ namespace :demo_data do Demo::Generator.new.reset_and_clear_data!(families) end + task new_user: :environment do + families = [ "Demo Family 1" ] + Demo::Generator.new.reset_and_clear_data!(families, require_onboarding: true) + end + task :reset, [ :count ] => :environment do |t, args| count = (args[:count] || 1).to_i families = count.times.map { |i| "Demo Family #{i + 1}" } diff --git a/test/controllers/subscriptions_controller_test.rb b/test/controllers/subscriptions_controller_test.rb index fe1b38d7..952aed3b 100644 --- a/test/controllers/subscriptions_controller_test.rb +++ b/test/controllers/subscriptions_controller_test.rb @@ -8,7 +8,6 @@ class SubscriptionsControllerTest < ActionDispatch::IntegrationTest test "redirects to settings if self hosting" do Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) get subscription_path - assert_redirected_to root_path - assert_equal I18n.t("subscriptions.self_hosted_alert"), flash[:alert] + assert_response :forbidden end end diff --git a/test/jobs/stripe_event_handler_job_test.rb b/test/jobs/stripe_event_handler_job_test.rb new file mode 100644 index 00000000..80e40328 --- /dev/null +++ b/test/jobs/stripe_event_handler_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class StripeEventHandlerJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/provider/stripe/customer_event_processor_test.rb b/test/models/provider/stripe/customer_event_processor_test.rb new file mode 100644 index 00000000..c3ab10e9 --- /dev/null +++ b/test/models/provider/stripe/customer_event_processor_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Provider::Stripe::CustomerEventProcessorTest < ActiveSupport::TestCase + # test "process" do + + # end +end diff --git a/test/models/provider/stripe/subscription_event_processor_test.rb b/test/models/provider/stripe/subscription_event_processor_test.rb new file mode 100644 index 00000000..19c6b384 --- /dev/null +++ b/test/models/provider/stripe/subscription_event_processor_test.rb @@ -0,0 +1,6 @@ +require "test_helper" + +class Provider::Stripe::SubscriptionEventProcessorTest < ActiveSupport::TestCase + # test "process" do + # end +end diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index 6972f2a7..1222dfe7 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -55,7 +55,6 @@ class TradesTest < ApplicationSystemTestCase end private - def open_new_trade_modal click_on "New transaction" end