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 @@
© <%= Date.current.year %>, Maybe Finance, Inc.
-
<%= link_to t(".privacy_policy"), "https://maybefinance.com/privacy", class: "underline hover:text-gray-600" %> • <%= link_to t(".terms_of_service"), "https://maybefinance.com/tos", class: "underline hover:text-gray-600" %>
+
+ <%= link_to "Privacy Policy", privacy_path, class: "text-secondary", target: "_blank" %>
+ •
+ <%= link_to "Terms of Service", terms_path, class: "text-secondary", target: "_blank" %>
+
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 %>
<%= yield :header_nav %>
- <%= 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| %>
+
+ <%= form.check_box :goals, { multiple: true, class: "sr-only" }, goal[:value], nil %>
+ <%= icon goal[:icon] %>
+ <%= goal[:label] %>
+
+ <% 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 %>
-
-
-
<%= t(".profile_image") %>
- <%= 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 %>
-
-
+
+
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">
+ class="<%= user.profile_image.attached? ? "" : "hidden" %> z-50 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">
<%= icon "x", size: "sm" %>
+
+
+ <%# 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 @@
+
+
+ Upgrade
+
+
+ <%= render ButtonComponent.new(
+ text: "Sign out",
+ icon: "log-out",
+ icon_position: :right,
+ variant: "ghost",
+ href: session_path(Current.session),
+ method: :delete
+ ) %>
+
+
+
+
+ <%= 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