mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-18 20:59:39 +02:00
New onboarding, trials, Stripe integration (#2185)
* New onboarding, trials, Stripe integration * Fix tests * Lint fixes * Fix subscription endpoints
This commit is contained in:
parent
79b4a3769b
commit
a51c4d2cba
53 changed files with 847 additions and 372 deletions
|
@ -499,7 +499,7 @@ GEM
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.1.7)
|
stringio (3.1.7)
|
||||||
stripe (15.0.0)
|
stripe (15.1.0)
|
||||||
tailwindcss-rails (4.2.2)
|
tailwindcss-rails (4.2.2)
|
||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
tailwindcss-ruby (~> 4.0)
|
tailwindcss-ruby (~> 4.0)
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
|
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
|
worker: bundle exec sidekiq
|
||||||
|
|
|
@ -248,7 +248,7 @@
|
||||||
--color-success: var(--color-green-500);
|
--color-success: var(--color-green-500);
|
||||||
--color-warning: var(--color-yellow-400);
|
--color-warning: var(--color-yellow-400);
|
||||||
--color-destructive: var(--color-red-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-xs: 0px 1px 2px 0px --alpha(var(--color-white) / 8%);
|
||||||
--shadow-sm: 0px 1px 6px 0px --alpha(var(--color-white) / 8%);
|
--shadow-sm: 0px 1px 6px 0px --alpha(var(--color-white) / 8%);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<%= container do %>
|
<%= container do %>
|
||||||
<% if icon && (icon_position != "right") %>
|
<% if icon && (icon_position != :right) %>
|
||||||
<%= lucide_icon(icon, class: icon_classes) %>
|
<%= lucide_icon(icon, class: icon_classes) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@
|
||||||
<%= text %>
|
<%= text %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if icon && icon_position == "right" %>
|
<% if icon && icon_position == :right %>
|
||||||
<%= lucide_icon(icon, class: icon_classes) %>
|
<%= lucide_icon(icon, class: icon_classes) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
class FilledIconComponent < ViewComponent::Base
|
class FilledIconComponent < ViewComponent::Base
|
||||||
attr_reader :icon, :text, :hex_color, :size, :rounded, :variant
|
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 = {
|
SIZES = {
|
||||||
sm: {
|
sm: {
|
||||||
|
@ -72,6 +72,8 @@ class FilledIconComponent < ViewComponent::Base
|
||||||
"bg-surface-inset"
|
"bg-surface-inset"
|
||||||
when :container
|
when :container
|
||||||
"bg-container-inset"
|
"bg-container-inset"
|
||||||
|
when :inverse
|
||||||
|
"bg-container"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -2,27 +2,10 @@ class ApplicationController < ActionController::Base
|
||||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable, Notifiable
|
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable, Notifiable
|
||||||
include Pagy::Backend
|
include Pagy::Backend
|
||||||
|
|
||||||
helper_method :require_upgrade?, :subscription_pending?
|
|
||||||
|
|
||||||
before_action :detect_os
|
before_action :detect_os
|
||||||
before_action :set_default_chat
|
before_action :set_default_chat
|
||||||
|
|
||||||
private
|
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
|
def detect_os
|
||||||
user_agent = request.user_agent
|
user_agent = request.user_agent
|
||||||
@os = case user_agent
|
@os = case user_agent
|
||||||
|
|
|
@ -2,16 +2,40 @@ module Onboardable
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
included do
|
included do
|
||||||
before_action :redirect_to_onboarding, if: :needs_onboarding?
|
before_action :require_onboarding_and_upgrade
|
||||||
|
helper_method :subscription_pending?
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def redirect_to_onboarding
|
# A subscription goes into "pending" mode immediately after checkout, but before webhooks are processed.
|
||||||
redirect_to onboarding_path
|
def subscription_pending?
|
||||||
|
subscribed_at = Current.session.subscribed_at
|
||||||
|
subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago
|
||||||
end
|
end
|
||||||
|
|
||||||
def needs_onboarding?
|
# First, we require onboarding, then once that's complete, we require an upgrade for non-subscribed users.
|
||||||
Current.user && Current.user.onboarded_at.blank? &&
|
def require_onboarding_and_upgrade
|
||||||
!%w[/users /onboarding /sessions].any? { |path| request.path.start_with?(path) }
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
class OnboardingsController < ApplicationController
|
class OnboardingsController < ApplicationController
|
||||||
|
layout "wizard"
|
||||||
|
|
||||||
before_action :set_user
|
before_action :set_user
|
||||||
before_action :load_invitation
|
before_action :load_invitation
|
||||||
|
|
||||||
def show
|
def show
|
||||||
end
|
end
|
||||||
|
|
||||||
def profile
|
def preferences
|
||||||
end
|
end
|
||||||
|
|
||||||
def preferences
|
def trial
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -1,41 +1,63 @@
|
||||||
class SubscriptionsController < ApplicationController
|
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
|
def new
|
||||||
if Current.family.stripe_customer_id.blank?
|
price_map = {
|
||||||
customer = stripe_client.v1.customers.create(
|
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,
|
email: Current.family.primary_user.email,
|
||||||
metadata: { family_id: Current.family.id }
|
metadata: { family_id: Current.family.id }
|
||||||
)
|
)
|
||||||
|
|
||||||
Current.family.update(stripe_customer_id: customer.id)
|
Current.family.update(stripe_customer_id: customer.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
session = stripe_client.v1.checkout.sessions.create({
|
checkout_session_url = stripe.get_checkout_session_url(
|
||||||
customer: Current.family.stripe_customer_id,
|
price_id: price_id,
|
||||||
line_items: [ {
|
customer_id: Current.family.stripe_customer_id,
|
||||||
price: ENV["STRIPE_PLAN_ID"],
|
|
||||||
quantity: 1
|
|
||||||
} ],
|
|
||||||
mode: "subscription",
|
|
||||||
allow_promotion_codes: true,
|
|
||||||
success_url: success_subscription_url + "?session_id={CHECKOUT_SESSION_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
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
portal_session = stripe_client.v1.billing_portal.sessions.create(
|
portal_session_url = stripe.get_billing_portal_session_url(
|
||||||
customer: Current.family.stripe_customer_id,
|
customer_id: Current.family.stripe_customer_id,
|
||||||
return_url: settings_billing_url
|
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
|
end
|
||||||
|
|
||||||
def success
|
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))
|
Current.session.update(subscribed_at: Time.at(checkout_session.created))
|
||||||
redirect_to root_path, notice: "You have successfully subscribed to Maybe+."
|
redirect_to root_path, notice: "You have successfully subscribed to Maybe+."
|
||||||
rescue Stripe::InvalidRequestError
|
rescue Stripe::InvalidRequestError
|
||||||
|
@ -43,11 +65,7 @@ class SubscriptionsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def stripe_client
|
def stripe
|
||||||
@stripe_client ||= Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
|
@stripe ||= Provider::Registry.get_provider(:stripe)
|
||||||
end
|
|
||||||
|
|
||||||
def redirect_to_root_if_self_hosted
|
|
||||||
redirect_to root_path, alert: I18n.t("subscriptions.self_hosted_alert") if self_hosted?
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -63,6 +63,10 @@ class UsersController < ApplicationController
|
||||||
redirect_to root_path
|
redirect_to root_path
|
||||||
when "preferences"
|
when "preferences"
|
||||||
redirect_to settings_preferences_path, notice: notice
|
redirect_to settings_preferences_path, notice: notice
|
||||||
|
when "goals"
|
||||||
|
redirect_to goals_onboarding_path
|
||||||
|
when "trial"
|
||||||
|
redirect_to trial_onboarding_path
|
||||||
else
|
else
|
||||||
redirect_to settings_profile_path, notice: notice
|
redirect_to settings_profile_path, notice: notice
|
||||||
end
|
end
|
||||||
|
@ -83,8 +87,10 @@ class UsersController < ApplicationController
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(
|
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,
|
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
|
||||||
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ]
|
: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
|
end
|
||||||
|
|
||||||
|
|
|
@ -33,61 +33,21 @@ class WebhooksController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def stripe
|
def stripe
|
||||||
webhook_body = request.body.read
|
stripe_provider = Provider::Registry.get_provider(:stripe)
|
||||||
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
|
|
||||||
client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
|
|
||||||
|
|
||||||
begin
|
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)
|
stripe_provider.process_webhook_later(webhook_body, sig_header)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
head :ok
|
||||||
rescue JSON::ParserError => error
|
rescue JSON::ParserError => error
|
||||||
Sentry.capture_exception(error)
|
Sentry.capture_exception(error)
|
||||||
render json: { error: "Invalid payload" }, status: :bad_request
|
head :bad_request
|
||||||
return
|
|
||||||
rescue Stripe::SignatureVerificationError => error
|
rescue Stripe::SignatureVerificationError => error
|
||||||
Sentry.capture_exception(error)
|
Sentry.capture_exception(error)
|
||||||
render json: { error: "Invalid signature" }, status: :bad_request
|
head :bad_request
|
||||||
return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: { received: true }, status: :ok
|
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -9,7 +9,7 @@ module ApplicationHelper
|
||||||
def icon(key, size: "md", color: "default", custom: false, as_button: false, **opts)
|
def icon(key, size: "md", color: "default", custom: false, as_button: false, **opts)
|
||||||
extra_classes = opts.delete(:class)
|
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" }
|
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(
|
icon_classes = class_names(
|
||||||
"shrink-0",
|
"shrink-0",
|
||||||
|
|
|
@ -14,6 +14,10 @@ export default class extends Controller {
|
||||||
this.refreshWithParam("currency", event.target.value);
|
this.refreshWithParam("currency", event.target.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTheme(event) {
|
||||||
|
document.documentElement.setAttribute("data-theme", event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
refreshWithParam(key, value) {
|
refreshWithParam(key, value) {
|
||||||
const url = new URL(window.location);
|
const url = new URL(window.location);
|
||||||
url.searchParams.set(key, value);
|
url.searchParams.set(key, value);
|
||||||
|
|
|
@ -68,6 +68,15 @@ export default class extends Controller {
|
||||||
this.setTheme(false);
|
this.setTheme(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
const currentTheme = document.documentElement.getAttribute("data-theme");
|
||||||
|
if (currentTheme === "dark") {
|
||||||
|
this.toLight();
|
||||||
|
} else {
|
||||||
|
this.toDark();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
startSystemThemeListener() {
|
startSystemThemeListener() {
|
||||||
this.darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
this.darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
this.darkMediaQuery.addEventListener(
|
this.darkMediaQuery.addEventListener(
|
||||||
|
|
9
app/jobs/stripe_event_handler_job.rb
Normal file
9
app/jobs/stripe_event_handler_job.rb
Normal file
|
@ -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
|
|
@ -2,7 +2,7 @@ class Demo::Generator
|
||||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||||
|
|
||||||
# Builds a semi-realistic mirror of what production data might look like
|
# 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..."
|
puts "Clearing existing data..."
|
||||||
|
|
||||||
destroy_everything!
|
destroy_everything!
|
||||||
|
@ -10,7 +10,7 @@ class Demo::Generator
|
||||||
puts "Data cleared"
|
puts "Data cleared"
|
||||||
|
|
||||||
family_names.each_with_index do |family_name, index|
|
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
|
end
|
||||||
|
|
||||||
puts "Users reset"
|
puts "Users reset"
|
||||||
|
@ -152,7 +152,7 @@ class Demo::Generator
|
||||||
Security::Price.destroy_all
|
Security::Price.destroy_all
|
||||||
end
|
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"
|
base_uuid = "d99e3c6e-d513-4452-8f24-dc263f8528c0"
|
||||||
id = Digest::UUID.uuid_v5(base_uuid, family_name)
|
id = Digest::UUID.uuid_v5(base_uuid, family_name)
|
||||||
|
|
||||||
|
@ -160,7 +160,7 @@ class Demo::Generator
|
||||||
id: id,
|
id: id,
|
||||||
name: family_name,
|
name: family_name,
|
||||||
currency: currency,
|
currency: currency,
|
||||||
stripe_subscription_status: "active",
|
stripe_subscription_status: require_onboarding ? nil : "active",
|
||||||
locale: "en",
|
locale: "en",
|
||||||
country: "US",
|
country: "US",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
|
@ -173,7 +173,7 @@ class Demo::Generator
|
||||||
last_name: "User",
|
last_name: "User",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
password: "password",
|
password: "password",
|
||||||
onboarded_at: Time.current
|
onboarded_at: require_onboarding ? nil : Time.current
|
||||||
|
|
||||||
family.users.create! \
|
family.users.create! \
|
||||||
email: "member_#{user_email}",
|
email: "member_#{user_email}",
|
||||||
|
@ -181,7 +181,7 @@ class Demo::Generator
|
||||||
last_name: "User",
|
last_name: "User",
|
||||||
role: "member",
|
role: "member",
|
||||||
password: "password",
|
password: "password",
|
||||||
onboarded_at: Time.current
|
onboarded_at: require_onboarding ? nil : Time.current
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_rules!(family)
|
def create_rules!(family)
|
||||||
|
|
|
@ -131,6 +131,18 @@ class Family < ApplicationRecord
|
||||||
stripe_subscription_status == "active"
|
stripe_subscription_status == "active"
|
||||||
end
|
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?
|
def requires_data_provider?
|
||||||
# If family has any trades, they need a provider for historical prices
|
# If family has any trades, they need a provider for historical prices
|
||||||
return true if trades.any?
|
return true if trades.any?
|
||||||
|
|
|
@ -19,6 +19,15 @@ class Provider::Registry
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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
|
def synth
|
||||||
api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key)
|
api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key)
|
||||||
|
|
||||||
|
|
68
app/models/provider/stripe.rb
Normal file
68
app/models/provider/stripe.rb
Normal file
|
@ -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
|
20
app/models/provider/stripe/customer_event_processor.rb
Normal file
20
app/models/provider/stripe/customer_event_processor.rb
Normal file
|
@ -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
|
17
app/models/provider/stripe/event_processor.rb
Normal file
17
app/models/provider/stripe/event_processor.rb
Normal file
|
@ -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
|
29
app/models/provider/stripe/subscription_event_processor.rb
Normal file
29
app/models/provider/stripe/subscription_event_processor.rb
Normal file
|
@ -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
|
|
@ -28,7 +28,7 @@
|
||||||
step[:is_complete] ? "text-green-600" : "text-secondary"
|
step[:is_complete] ? "text-green-600" : "text-secondary"
|
||||||
end %>
|
end %>
|
||||||
<% step_class = if is_current
|
<% step_class = if is_current
|
||||||
"bg-primary text-white"
|
"bg-surface-inset text-primary"
|
||||||
else
|
else
|
||||||
step[:is_complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-container-inset"
|
step[:is_complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-container-inset"
|
||||||
end %>
|
end %>
|
||||||
|
@ -36,7 +36,7 @@
|
||||||
<%= link_to step[:path], class: "flex items-center gap-3" do %>
|
<%= link_to step[:path], class: "flex items-center gap-3" do %>
|
||||||
<div class="flex items-center gap-2 text-sm font-medium <%= text_class %>">
|
<div class="flex items-center gap-2 text-sm font-medium <%= text_class %>">
|
||||||
<span class="<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent">
|
<span class="<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent">
|
||||||
<%= step[:is_complete] && !is_current ? icon("check", size: "sm") : idx + 1 %>
|
<%= step[:is_complete] && !is_current ? icon("check", size: "sm", color: "current") : idx + 1 %>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span><%= step[:name] %></span>
|
<span><%= step[:name] %></span>
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
<footer class="p-6">
|
<footer class="p-6">
|
||||||
<div class="space-y-2 text-center text-xs text-secondary">
|
<div class="space-y-2 text-center text-xs text-secondary">
|
||||||
<p>© <%= Date.current.year %>, Maybe Finance, Inc.</p>
|
<p>© <%= Date.current.year %>, Maybe Finance, Inc.</p>
|
||||||
<p><%= 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" %></p>
|
<div class="flex justify-center items-center gap-2">
|
||||||
|
<%= link_to "Privacy Policy", privacy_path, class: "text-secondary", target: "_blank" %>
|
||||||
|
<span>•</span>
|
||||||
|
<%= link_to "Terms of Service", terms_path, class: "text-secondary", target: "_blank" %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
@ -1,11 +1,23 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html class="h-full text-primary overflow-hidden lg:overflow-auto font-sans <%= @os %>" lang="en" data-controller="theme intercom" data-theme-user-preference-value="<%= Current.user&.theme || "system" %>">
|
|
||||||
|
<% theme = Current.user&.theme || "system" %>
|
||||||
|
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
data-theme="<%= theme %>"
|
||||||
|
data-controller="theme intercom"
|
||||||
|
data-theme-user-preference-value="<%= Current.user&.theme || "system" %>"
|
||||||
|
class="h-full text-primary overflow-hidden lg:overflow-auto font-sans <%= @os %>">
|
||||||
<head>
|
<head>
|
||||||
<%= render "layouts/shared/head" %>
|
<%= render "layouts/shared/head" %>
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="h-full overflow-hidden lg:overflow-auto antialiased">
|
<body class="h-full overflow-hidden lg:overflow-auto antialiased">
|
||||||
|
<% if Rails.env.development? %>
|
||||||
|
<button hidden data-controller="hotkey" data-hotkey="t t /" data-action="theme#toggle"></button>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="fixed z-50 top-6 md:top-4 left-1/2 -translate-x-1/2 w-full md:w-80 px-4 md:px-0 mx-auto md:mx-0 md:right-auto mt-safe">
|
<div class="fixed z-50 top-6 md:top-4 left-1/2 -translate-x-1/2 w-full md:w-80 px-4 md:px-0 mx-auto md:mx-0 md:right-auto mt-safe">
|
||||||
<div id="notification-tray" class="space-y-1 w-full">
|
<div id="notification-tray" class="space-y-1 w-full">
|
||||||
<%= render_flash_notifications %>
|
<%= render_flash_notifications %>
|
||||||
|
@ -20,17 +32,6 @@
|
||||||
|
|
||||||
<%= family_stream %>
|
<%= family_stream %>
|
||||||
|
|
||||||
<% if Rails.env.development? %>
|
|
||||||
<div class="fixed bottom-32 left-7 flex flex-col gap-1">
|
|
||||||
<%= icon("eclipse", as_button: true, data: { action: "theme#toDark" }) %>
|
|
||||||
<%= icon("sun", as_button: true, data: { action: "theme#toLight" }) %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% if require_upgrade? %>
|
|
||||||
<%= render "shared/subscribe_modal" %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= turbo_frame_tag "modal" %>
|
<%= turbo_frame_tag "modal" %>
|
||||||
<%= turbo_frame_tag "drawer" %>
|
<%= turbo_frame_tag "drawer" %>
|
||||||
|
|
||||||
|
|
|
@ -1,25 +1,37 @@
|
||||||
<%= render "layouts/shared/htmldoc" do %>
|
<%= render "layouts/shared/htmldoc" do %>
|
||||||
<div class="bg-container flex flex-col h-full">
|
<div class="bg-surface flex flex-col h-full">
|
||||||
<header class="flex items-center justify-between p-8">
|
<header class="flex items-center justify-between p-8">
|
||||||
<%= render LinkComponent.new(
|
<% if content_for?(:prev_nav) %>
|
||||||
variant: "icon",
|
<%= yield :prev_nav %>
|
||||||
icon: "arrow-left",
|
<% else %>
|
||||||
href: content_for(:previous_path) || root_path
|
<%= render LinkComponent.new(
|
||||||
) %>
|
variant: "icon",
|
||||||
|
icon: "arrow-left",
|
||||||
|
href: content_for(:previous_path) || root_path
|
||||||
|
) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
<%= yield :header_nav %>
|
<%= yield :header_nav %>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<%= render LinkComponent.new(
|
<% if content_for?(:cancel_action) %>
|
||||||
variant: "icon",
|
<%= yield :cancel_action %>
|
||||||
icon: "x",
|
<% else %>
|
||||||
href: content_for(:cancel_path) || root_path
|
<%= render LinkComponent.new(
|
||||||
) %>
|
variant: "icon",
|
||||||
|
icon: "x",
|
||||||
|
href: content_for(:cancel_path) || root_path
|
||||||
|
) %>
|
||||||
|
<% end %>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="grow px-8 pt-12 pb-32 overflow-y-auto">
|
<main class="grow px-8 pt-12 pb-32 overflow-y-auto">
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<% if content_for?(:footer) %>
|
||||||
|
<%= yield :footer %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
<header class="flex justify-between items-center p-4">
|
|
||||||
<%= image_tag "logo.svg", class: "h-[22px]" %>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<%= icon("log-in", color: "secondary") %>
|
|
||||||
<%= button_to t(".sign_out"), session_path(Current.session), method: :delete, class: "text-sm text-primary font-medium" %>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
8
app/views/onboardings/_logout.html.erb
Normal file
8
app/views/onboardings/_logout.html.erb
Normal file
|
@ -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
|
||||||
|
) %>
|
39
app/views/onboardings/_onboarding_nav.html.erb
Normal file
39
app/views/onboardings/_onboarding_nav.html.erb
Normal file
|
@ -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 },
|
||||||
|
] %>
|
||||||
|
|
||||||
|
<ul class="hidden md:flex items-center gap-2">
|
||||||
|
<% steps.each_with_index do |step, idx| %>
|
||||||
|
<li class="flex items-center gap-2 group">
|
||||||
|
<% is_current = request.path == step[:path] %>
|
||||||
|
|
||||||
|
<% text_class = if is_current
|
||||||
|
"text-primary"
|
||||||
|
else
|
||||||
|
step[:is_complete] ? "text-green-600" : "text-secondary"
|
||||||
|
end %>
|
||||||
|
<% step_class = if is_current
|
||||||
|
"bg-surface-inset text-primary"
|
||||||
|
else
|
||||||
|
step[:is_complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-container-inset"
|
||||||
|
end %>
|
||||||
|
|
||||||
|
<%= link_to step[:path], class: "flex items-center gap-3" do %>
|
||||||
|
<div class="flex items-center gap-2 text-sm font-medium <%= text_class %>">
|
||||||
|
<span class="<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent">
|
||||||
|
<%= step[:is_complete] && !is_current ? icon("check", size: "sm", color: "current") : idx + 1 %>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span><%= step[:name] %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<hr class="border border-secondary w-12 group-last:hidden">
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
53
app/views/onboardings/goals.html.erb
Normal file
53
app/views/onboardings/goals.html.erb
Normal file
|
@ -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 %>
|
||||||
|
|
||||||
|
<div class="grow max-w-lg w-full mx-auto bg-surface flex flex-col justify-center md:py-0 py-6 px-4 md:px-0">
|
||||||
|
<div>
|
||||||
|
<div class="space-y-1 mb-6 text-center">
|
||||||
|
<h1 class="text-2xl font-medium md:text-2xl">What brings you to Maybe?</h1>
|
||||||
|
<p class="text-secondary text-sm">Select one or more goals that you have with using Maybe as your personal finance tool.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form_with model: @user do |form| %>
|
||||||
|
<%= form.hidden_field :redirect_to, value: "trial" %>
|
||||||
|
<%= form.hidden_field :set_onboarding_goals_at, value: Time.current %>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<% [
|
||||||
|
{ 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| %>
|
||||||
|
<label class="flex items-center gap-2.5 p-4 rounded-lg border border-tertiary cursor-pointer hover:bg-container transition-colors [&:has(input:checked)]:border-solid [&:has(input:checked)]:bg-container">
|
||||||
|
<%= form.check_box :goals, { multiple: true, class: "sr-only" }, goal[:value], nil %>
|
||||||
|
<%= icon goal[:icon] %>
|
||||||
|
<span class="text-primary text-sm"><%= goal[:label] %></span>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<%= render ButtonComponent.new(
|
||||||
|
text: "Next",
|
||||||
|
full_width: true
|
||||||
|
) %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,26 +1,33 @@
|
||||||
<div class="bg-surface h-screen flex flex-col justify-between">
|
<%= content_for :previous_path, onboarding_path %>
|
||||||
<%= render "onboardings/header" %>
|
|
||||||
|
|
||||||
<div class="grow max-w-lg w-full mx-auto bg-surface flex flex-col justify-center md:py-0 py-6 px-4 md:px-0" data-controller="onboarding">
|
<%= content_for :header_nav do %>
|
||||||
<div>
|
<%= render "onboardings/onboarding_nav", user: @user %>
|
||||||
<div class="space-y-1 mb-6">
|
<% end %>
|
||||||
<h1 class="text-2xl font-medium"><%= t(".title") %></h1>
|
|
||||||
<p class="text-secondary text-sm"><%= t(".subtitle") %></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-1 bg-alpha-black-25 mb-2 rounded-lg">
|
<%= content_for :cancel_action do %>
|
||||||
<div class="bg-container p-4 rounded-lg flex gap-8 shadow-border-xs">
|
<%= render "onboardings/logout" %>
|
||||||
<div class="space-y-2">
|
<% 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" %>
|
|
||||||
<p class="text-sm">
|
|
||||||
<span class="text-green-500 font-medium">+<%= format_money(Money.new(78.90, params[:currency] || @user.family.currency)) %></span>
|
|
||||||
<span class="text-green-500 font-medium">(+<%= format_money(Money.new(6.39, params[:currency] || @user.family.currency)) %>)</span>
|
|
||||||
<span class="text-secondary">as of <%= format_date(Date.parse("2024-10-23"), :default, format_code: params[:date_format] || @user.family.date_format) %></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% placeholder_series_data = [
|
<div class="grow max-w-lg w-full mx-auto bg-surface flex flex-col justify-center md:py-0 py-6 px-4 md:px-0" data-controller="onboarding">
|
||||||
|
<div>
|
||||||
|
<div class="space-y-1 mb-6">
|
||||||
|
<h1 class="text-2xl font-medium"><%= t(".title") %></h1>
|
||||||
|
<p class="text-secondary text-sm"><%= t(".subtitle") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-1 bg-alpha-black-25 mb-2 rounded-lg">
|
||||||
|
<div class="bg-container p-4 rounded-lg flex gap-8 shadow-border-xs">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<%= 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" %>
|
||||||
|
<p class="text-sm">
|
||||||
|
<span class="text-green-500 font-medium">+<%= format_money(Money.new(78.90, params[:currency] || @user.family.currency)) %></span>
|
||||||
|
<span class="text-green-500 font-medium">(+<%= format_money(Money.new(6.39, params[:currency] || @user.family.currency)) %>)</span>
|
||||||
|
<span class="text-secondary">as of <%= format_date(Date.parse("2024-10-23"), :default, format_code: params[:date_format] || @user.family.date_format) %></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% placeholder_series_data = [
|
||||||
{ date: Date.current - 14.days, value: 200 },
|
{ date: Date.current - 14.days, value: 200 },
|
||||||
{ date: Date.current - 13.days, value: 200 },
|
{ date: Date.current - 13.days, value: 200 },
|
||||||
{ date: Date.current - 12.days, value: 220 },
|
{ date: Date.current - 12.days, value: 220 },
|
||||||
|
@ -38,53 +45,54 @@
|
||||||
{ date: Date.current, value: 265 }
|
{ date: Date.current, value: 265 }
|
||||||
] %>
|
] %>
|
||||||
|
|
||||||
<% placeholder_series = Series.from_raw_values(placeholder_series_data) %>
|
<% placeholder_series = Series.from_raw_values(placeholder_series_data) %>
|
||||||
|
|
||||||
<div class="flex items-center w-2/5">
|
<div class="flex items-center w-2/5">
|
||||||
<div class="h-12 w-full">
|
<div class="h-12 w-full">
|
||||||
<div
|
<div
|
||||||
id="previewChart"
|
id="previewChart"
|
||||||
class="h-full w-full"
|
class="h-full w-full"
|
||||||
data-controller="time-series-chart"
|
data-controller="time-series-chart"
|
||||||
data-time-series-chart-data-value="<%= placeholder_series.to_json %>"
|
data-time-series-chart-data-value="<%= placeholder_series.to_json %>"
|
||||||
data-time-series-chart-use-labels-value="false"
|
data-time-series-chart-use-labels-value="false"
|
||||||
data-time-series-chart-use-tooltip-value="false"></div>
|
data-time-series-chart-use-tooltip-value="false"></div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="text-secondary text-xs mb-4"><%= t(".preview") %></p>
|
<p class="text-secondary text-xs mb-4"><%= t(".preview") %></p>
|
||||||
|
|
||||||
<%= styled_form_with model: @user, data: { turbo: false } do |form| %>
|
<%= styled_form_with model: @user, data: { turbo: false } do |form| %>
|
||||||
<%= form.hidden_field :onboarded_at, value: Time.current %>
|
<%= form.hidden_field :set_onboarding_preferences_at, value: Time.current %>
|
||||||
<%= form.hidden_field :redirect_to, value: "home" %>
|
<%= form.hidden_field :redirect_to, value: "goals" %>
|
||||||
|
|
||||||
<div class="space-y-4 mb-4">
|
<div class="mb-4">
|
||||||
<%= form.fields_for :family do |family_form| %>
|
<%= form.select :theme, [["System", "system"], ["Light", "light"], ["Dark", "dark"]], { label: "Color theme" }, data: { action: "onboarding#setTheme" } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%= family_form.select :locale,
|
<div class="space-y-4 mb-4">
|
||||||
|
<%= form.fields_for :family do |family_form| %>
|
||||||
|
|
||||||
|
<%= family_form.select :locale,
|
||||||
language_options,
|
language_options,
|
||||||
{ label: t(".locale"), required: true, selected: params[:locale] || @user.family.locale },
|
{ label: t(".locale"), required: true, selected: params[:locale] || @user.family.locale },
|
||||||
{ data: { action: "onboarding#setLocale" } } %>
|
{ 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 ] },
|
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 },
|
{ label: t(".currency"), required: true, selected: params[:currency] || @user.family.currency },
|
||||||
{ data: { action: "onboarding#setCurrency" } } %>
|
{ data: { action: "onboarding#setCurrency" } } %>
|
||||||
|
|
||||||
<%= family_form.select :date_format,
|
<%= family_form.select :date_format,
|
||||||
Family::DATE_FORMATS,
|
Family::DATE_FORMATS,
|
||||||
{ label: t(".date_format"), required: true, selected: params[:date_format] || @user.family.date_format },
|
{ label: t(".date_format"), required: true, selected: params[:date_format] || @user.family.date_format },
|
||||||
{ data: { action: "onboarding#setDateFormat" } } %>
|
{ data: { action: "onboarding#setDateFormat" } } %>
|
||||||
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form.submit t(".submit") %>
|
<%= form.submit t(".submit") %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= render "layouts/shared/footer" %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,42 +0,0 @@
|
||||||
<div class="bg-surface min-h-screen flex flex-col justify-between">
|
|
||||||
<%= render "onboardings/header" %>
|
|
||||||
|
|
||||||
<div class="grow max-w-lg w-full mx-auto bg-surface flex flex-col justify-center md:py-0 py-6 px-4 md:px-0">
|
|
||||||
<div>
|
|
||||||
<div class="space-y-1 mb-6">
|
|
||||||
<h1 class="text-2xl font-medium md:text-2xl"><%= t(".title") %></h1>
|
|
||||||
<p class="text-secondary text-sm"><%= t(".subtitle") %></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= 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 %>
|
|
||||||
|
|
||||||
<div class="space-y-4 mb-4">
|
|
||||||
<p class="text-secondary text-xs hidden md:block"><%= t(".profile_image") %></p>
|
|
||||||
<%= render "settings/user_avatar_field", form: form, user: @user %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col md:flex-row md:justify-between md:items-center md:gap-4 space-y-4 md:space-y-0 mb-4">
|
|
||||||
<%= 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 %>
|
|
||||||
</div>
|
|
||||||
<% unless @invitation %>
|
|
||||||
<div class="space-y-4 mb-4">
|
|
||||||
<%= form.fields_for :family do |family_form| %>
|
|
||||||
<%= family_form.text_field :name, placeholder: t(".household_name"), label: t(".household_name") %>
|
|
||||||
|
|
||||||
<%= family_form.select :country,
|
|
||||||
country_options,
|
|
||||||
{ label: t(".country") }, required: true %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= form.submit t(".submit") %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= render "layouts/shared/footer" %>
|
|
||||||
</div>
|
|
|
@ -1,16 +1,51 @@
|
||||||
<div class="bg-surface">
|
<%= content_for :previous_path, onboarding_path %>
|
||||||
<div class="h-screen flex flex-col items-center py-6">
|
|
||||||
<div class="grow flex justify-center items-center flex-col max-w-sm w-full text-center">
|
|
||||||
<%= image_tag "logo-color.png", class: "w-16 mb-6" %>
|
|
||||||
<%= tag.h1 t(".title"), class: "text-3xl font-medium mb-2" %>
|
|
||||||
<%= tag.p t(".message"), class: "text-sm text-secondary mb-6" %>
|
|
||||||
|
|
||||||
<%= render LinkComponent.new(
|
<%= content_for :header_nav do %>
|
||||||
text: t(".setup"),
|
<%= render "onboardings/onboarding_nav", user: @user %>
|
||||||
href: profile_onboarding_path,
|
<% end %>
|
||||||
variant: "primary",
|
|
||||||
full_width: true
|
<%= content_for :cancel_action do %>
|
||||||
) %>
|
<%= render "onboardings/logout" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="grow max-w-lg w-full mx-auto bg-surface flex flex-col justify-center md:py-0 py-6 px-4 md:px-0">
|
||||||
|
<div>
|
||||||
|
<div class="space-y-1 mb-6 text-center">
|
||||||
|
<h1 class="text-2xl font-medium md:text-2xl">Let's set up your account</h1>
|
||||||
|
<p class="text-secondary text-sm">First things first, let's get your profile set up.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%= 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 %>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<%= render "settings/user_avatar_field", form: form, user: @user %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col md:flex-row md:justify-between md:items-center md:gap-4 space-y-4 md:space-y-0 mb-4">
|
||||||
|
<%= 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 %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% unless @invitation %>
|
||||||
|
<div class="space-y-4 mb-4">
|
||||||
|
<%= 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 %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= form.submit "Continue" %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%= render "layouts/shared/footer" %>
|
||||||
|
</div>
|
||||||
|
|
121
app/views/onboardings/trial.html.erb
Normal file
121
app/views/onboardings/trial.html.erb
Normal file
|
@ -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 %>
|
||||||
|
|
||||||
|
<div class="grow flex flex-col gap-12 items-center justify-center">
|
||||||
|
<div class="max-w-sm mx-auto flex flex-col items-center">
|
||||||
|
<%= image_tag "logo-color.png", class: "w-16 mb-6" %>
|
||||||
|
|
||||||
|
<p class="text-xl lg:text-3xl text-primary font-display font-medium">
|
||||||
|
Try Maybe for 14 days.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="text-xl lg:text-3xl font-display text-secondary font-medium mb-2">
|
||||||
|
No credit card required
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-sm text-secondary text-center mb-8">
|
||||||
|
Starting the trial activates your account for Maybe. You won't need to enter payment details.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<%= render ButtonComponent.new(
|
||||||
|
text: "Try Maybe for 14 days",
|
||||||
|
href: start_trial_subscription_path,
|
||||||
|
full_width: true
|
||||||
|
) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<h2 class="text-center text-lg lg:text-2xl font-medium text-primary">How your trial will work</h2>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="rounded-xl p-1 bg-gray-400/20 theme-dark:bg-gray-500/20 flex flex-col justify-between items-center text-secondary">
|
||||||
|
<%= render FilledIconComponent.new(icon: "unlock-keyhole", variant: :inverse) %>
|
||||||
|
<%= render FilledIconComponent.new(icon: "bell", variant: :inverse) %>
|
||||||
|
<%= render FilledIconComponent.new(icon: "credit-card", variant: :inverse) %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-12">
|
||||||
|
<div class="space-y-1.5 text-sm">
|
||||||
|
<p class="text-primary font-medium">Today</p>
|
||||||
|
<p class="text-secondary">You'll get free access to Maybe for 14 days</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5 text-sm">
|
||||||
|
<p class="text-primary font-medium">In 13 days (<%= 13.days.from_now.strftime("%B %d") %>)</p>
|
||||||
|
<p class="text-secondary">We'll notify you to remind you when your trial will end.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5 text-sm">
|
||||||
|
<p class="text-primary font-medium">In 14 days (<%= 14.days.from_now.strftime("%B %d") %>)</p>
|
||||||
|
<p class="text-secondary">Your trial ends — subscribe to continue using Maybe</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-8 max-w-2xl mx-auto">
|
||||||
|
<h2 class="text-center text-lg lg:text-2xl font-medium text-primary">Here's what's included</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-x-12 gap-y-6 text-secondary">
|
||||||
|
<div class="flex flex-col gap-4 items-center">
|
||||||
|
<%= render FilledIconComponent.new(icon: "landmark", variant: :surface) %>
|
||||||
|
<p class="text-sm text-primary text-center">More than 10,000 institutions to connect to</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 items-center">
|
||||||
|
<%= render FilledIconComponent.new(icon: "layers", variant: :surface) %>
|
||||||
|
<p class="text-sm text-primary text-center">Connect unlimited accounts and account types</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 items-center">
|
||||||
|
<%= render FilledIconComponent.new(icon: "line-chart", variant: :surface) %>
|
||||||
|
<p class="text-sm text-primary text-center">Performance and investment returns across portfolio</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 items-center">
|
||||||
|
<%= render FilledIconComponent.new(icon: "credit-card", variant: :surface) %>
|
||||||
|
<p class="text-sm text-primary text-center">Comprehensive transaction tracking experience</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="pl-2">
|
||||||
|
<%= render "chats/ai_avatar" %>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-primary text-center">Unlimited access and chats with Maybe AI</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 items-center">
|
||||||
|
<%= render FilledIconComponent.new(icon: "keyboard", variant: :surface) %>
|
||||||
|
<p class="text-sm text-primary text-center">Manual account tracking that works well</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 items-center">
|
||||||
|
<%= render FilledIconComponent.new(icon: "globe-2", variant: :surface) %>
|
||||||
|
<p class="text-sm text-primary text-center">Multiple currencies and near global coverage</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 items-center">
|
||||||
|
<%= render FilledIconComponent.new(icon: "ship", variant: :surface) %>
|
||||||
|
<p class="text-sm text-primary text-center">Early access to newly released features</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 items-center">
|
||||||
|
<%= render FilledIconComponent.new(icon: "messages-square", variant: :surface) %>
|
||||||
|
<p class="text-sm text-primary text-center">Priority human support from team</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="space-y-1 mb-6 flex gap-4 justify-between items-center lg:items-start">
|
<div class="space-y-1 mb-6 flex gap-4 justify-between items-center lg:items-start">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<h1 class="text-xl lg:text-3xl font-medium text-primary">Welcome back, <%= Current.user.first_name %></h1>
|
<h1 class="text-xl lg:text-3xl font-medium text-primary">Welcome back, <%= Current.user.first_name %></h1>
|
||||||
<p class="text-sm lg:text-base text-gray-500">Here's what's happening with your finances</p>
|
<p class="text-sm lg:text-base text-secondary">Here's what's happening with your finances</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= render LinkComponent.new(
|
<%= render LinkComponent.new(
|
||||||
|
|
|
@ -1,54 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html class="h-full" lang="en">
|
|
||||||
<head>
|
|
||||||
<title><%= content_for(:title) || "🔒 Maybe Early Access" %></title>
|
|
||||||
|
|
||||||
<%= csrf_meta_tags %>
|
|
||||||
<%= csp_meta_tag %>
|
|
||||||
|
|
||||||
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
|
|
||||||
|
|
||||||
<%= javascript_importmap_tags %>
|
|
||||||
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
|
|
||||||
|
|
||||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Maybe">
|
|
||||||
<meta name="msapplication-TileColor" content="#ffffff">
|
|
||||||
<meta name="theme-color" content="#ffffff">
|
|
||||||
|
|
||||||
<%= yield :head %>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="subpixel-antialiased h-full bg-black text-white flex flex-col bg-top bg-no-repeat bg-cover" style="background-image: url('<%= asset_path("bg-grid.png") %>');">
|
|
||||||
<div class="grow flex items-center justify-center p-4 sm:p-6 md:p-8">
|
|
||||||
<div class="w-full max-w-sm mx-auto text-center rounded-lg p-6">
|
|
||||||
<%= 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" %>
|
|
||||||
<h1 class="text-2xl font-normal my-4">Maybe Early Access</h1>
|
|
||||||
<% if @invite_codes_count > 0 %>
|
|
||||||
<p class="text-base text-subdued">There <%= @invite_codes_count == 1 ? "is" : "are" %> <span class="text-white"><%= @invite_codes_count %> invite <%= "code".pluralize(@invite_codes_count) %></span> remaining.</p>
|
|
||||||
<div class="bg-gray-900 border border-gray-800 p-2 rounded-xl my-4">
|
|
||||||
<p class="text-sm text-subdued mt-1 mb-3 sm:mb-4">Your invite code is <span class="font-mono text-white"><%= @invite_code.token %></span></p>
|
|
||||||
<p><%= 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" %></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-sm text-subdued">You may need to refresh the page to get a new invite code if someone else claimed it before you.</p>
|
|
||||||
|
|
||||||
<p class="mt-4 sm:mt-6">
|
|
||||||
<%= 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" %>
|
|
||||||
<span>Refresh page</span>
|
|
||||||
<% end %>
|
|
||||||
</p>
|
|
||||||
<% else %>
|
|
||||||
<p class="text-base text-subdued mb-6 sm:mb-8">Sorry, there are <span class="text-white">no invite codes</span> 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.</p>
|
|
||||||
<p><%= 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" %></p>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer class="pb-6 sm:pb-10 text-center text-subdued text-sm">
|
|
||||||
©2024 Maybe Finance, Inc.
|
|
||||||
</footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,40 +1,41 @@
|
||||||
<%# locals: (form:, user:) %>
|
<%# locals: (form:, user:) %>
|
||||||
|
|
||||||
<div class="flex md:flex-row flex-col md:items-center items-center gap-4" data-controller="profile-image-preview">
|
<div class="flex flex-col items-center gap-4" data-controller="profile-image-preview">
|
||||||
<div class="relative flex justify-center items-center bg-container size-26 md:size-24 rounded-full border-primary border border-dashed overflow-hidden">
|
<div class="relative">
|
||||||
|
|
||||||
<%# The image preview once user has uploaded a new file %>
|
|
||||||
<div data-profile-image-preview-target="previewImage" class="h-full w-full flex justify-center items-center hidden">
|
|
||||||
<img src="" alt="Preview" class="w-full h-full rounded-full object-cover">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%# The placeholder image for empty avatar field %>
|
|
||||||
<div data-profile-image-preview-target="placeholderImage"
|
|
||||||
class="h-full w-full flex justify-center items-center <%= user.profile_image.attached? ? "hidden" : "" %>">
|
|
||||||
<div class="h-full w-full flex justify-center items-center bg-container">
|
|
||||||
<%= icon "image-plus", size: "lg" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%# The attached image if user has already uploaded one %>
|
|
||||||
<div data-profile-image-preview-target="attachedImage"
|
|
||||||
class="h-full w-full flex justify-center items-center <%= user.profile_image.attached? ? "" : "hidden" %>">
|
|
||||||
<% if user.profile_image.attached? %>
|
|
||||||
<div class="h-full w-full">
|
|
||||||
<%= render "settings/user_avatar", avatar_url: user.profile_image.url %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button"
|
<button type="button"
|
||||||
data-profile-image-preview-target="clearBtn"
|
data-profile-image-preview-target="clearBtn"
|
||||||
data-action="click->profile-image-preview#clearFileInput"
|
data-action="click->profile-image-preview#clearFileInput"
|
||||||
class="<%= user.profile_image.attached? ? "" : "hidden" %> cursor-pointer absolute bottom-0 right-0 w-8 h-8 bg-gray-50 rounded-full flex justify-center items-center border border-white border-2">
|
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" %>
|
<%= icon "x", size: "sm" %>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="relative flex justify-center items-center bg-surface-inset size-26 md:size-24 rounded-full border-primary border border-dashed overflow-hidden">
|
||||||
|
<%# The image preview once user has uploaded a new file %>
|
||||||
|
<div data-profile-image-preview-target="previewImage" class="h-full w-full flex justify-center items-center hidden">
|
||||||
|
<img src="" alt="Preview" class="w-full h-full rounded-full object-cover">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# The placeholder image for empty avatar field %>
|
||||||
|
<div data-profile-image-preview-target="placeholderImage"
|
||||||
|
class="h-full w-full flex justify-center items-center <%= user.profile_image.attached? ? "hidden" : "" %>">
|
||||||
|
<div class="h-full w-full flex justify-center items-center bg-surface-inset">
|
||||||
|
<%= icon "image-plus", size: "lg" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# The attached image if user has already uploaded one %>
|
||||||
|
<div data-profile-image-preview-target="attachedImage"
|
||||||
|
class="h-full w-full flex justify-center items-center <%= user.profile_image.attached? ? "" : "hidden" %>">
|
||||||
|
<% if user.profile_image.attached? %>
|
||||||
|
<div class="h-full w-full">
|
||||||
|
<%= render "settings/user_avatar", avatar_url: user.profile_image.url %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="md:text-left text-center">
|
<div class="text-center">
|
||||||
<%= form.hidden_field :delete_profile_image, value: "0", data: { profile_image_preview_target: "deleteProfileImage" } %>
|
<%= 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 %>
|
<%= 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 %>
|
||||||
|
|
|
@ -4,12 +4,25 @@
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="p-3 shadow-border-xs bg-container rounded-lg flex justify-between items-center">
|
<div class="p-3 shadow-border-xs bg-container rounded-lg flex justify-between items-center">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-9 h-9 rounded-full bg-gray-25 flex justify-center items-center">
|
<%= render FilledIconComponent.new(
|
||||||
<%= icon "gem" %>
|
icon: "gem",
|
||||||
</div>
|
rounded: true,
|
||||||
|
size: "lg"
|
||||||
|
) %>
|
||||||
|
|
||||||
<div class="text-sm space-y-1">
|
<div class="text-sm space-y-1">
|
||||||
<% if @user.family.subscribed? || subscription_pending? %>
|
<% if subscription_pending? %>
|
||||||
|
<p class="text-primary">
|
||||||
|
Your subscription is pending. You can still use Maybe+ while we process your subscription.
|
||||||
|
</p>
|
||||||
|
<% elsif @user.family.trialing? %>
|
||||||
|
<p class="text-primary">
|
||||||
|
You are currently trialing <span class="font-medium">Maybe+</span>
|
||||||
|
<span class="text-secondary">
|
||||||
|
(<%= @user.family.trial_remaining_days %> days remaining)
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<% elsif @user.family.subscribed? %>
|
||||||
<p class="text-primary">You are currently subscribed to <span class="font-medium">Maybe+</span></p>
|
<p class="text-primary">You are currently subscribed to <span class="font-medium">Maybe+</span></p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="text-primary">You are currently <span class="font-medium">not subscribed</span></p>
|
<p class="text-primary">You are currently <span class="font-medium">not subscribed</span></p>
|
||||||
|
@ -18,24 +31,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if @user.family.subscribed? || subscription_pending? %>
|
<% if @user.family.subscribed? %>
|
||||||
<%= render LinkComponent.new(
|
<%= render LinkComponent.new(
|
||||||
text: "Manage",
|
text: "Manage",
|
||||||
icon: "external-link",
|
icon: "external-link",
|
||||||
variant: "primary",
|
variant: "primary",
|
||||||
icon_position: "right",
|
icon_position: "right",
|
||||||
href: subscription_path,
|
href: subscription_path,
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener"
|
rel: "noopener"
|
||||||
) %>
|
) %>
|
||||||
<% else %>
|
<% elsif @user.family.trialing? && !subscription_pending? %>
|
||||||
<%= render LinkComponent.new(
|
<%= render LinkComponent.new(
|
||||||
text: "Subscribe",
|
text: "Choose plan",
|
||||||
variant: "primary",
|
variant: "primary",
|
||||||
icon: "external-link",
|
icon: "plus",
|
||||||
icon_position: "right",
|
icon_position: "right",
|
||||||
href: new_subscription_path,
|
href: upgrade_subscription_path(view: "upgrade"),
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener") %>
|
rel: "noopener") %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
<%= render DialogComponent.new do |dialog| %>
|
|
||||||
<div class="rounded-xl" style="background-image: url('<%= asset_path("maybe-plus-background.svg") %>'); background-size: cover; background-position: center top;">
|
|
||||||
<div class="text-center rounded-xl" style="background-image: linear-gradient(to bottom, rgba(197,161,119,0.15) 0%, rgba(255,255,255,0.8) 30%, white 40%);">
|
|
||||||
<div class="p-4 pt-2 rounded-xl">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<%= image_tag "maybe-plus-logo.png", class: "w-20" %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="font-medium text-primary mb-2">Join Maybe+</h2>
|
|
||||||
|
|
||||||
<div class="text-secondary text-sm space-y-4 mb-5">
|
|
||||||
<p>Nobody likes paywalls, but we need feedback from users willing to pay for Maybe. </p>
|
|
||||||
|
|
||||||
<p>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.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= render LinkComponent.new(
|
|
||||||
text: "Upgrade to Maybe+",
|
|
||||||
href: new_subscription_path,
|
|
||||||
variant: "primary",
|
|
||||||
full_width: true
|
|
||||||
) %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
|
@ -4,7 +4,7 @@
|
||||||
data: { controller: "element-removal" } do %>
|
data: { controller: "element-removal" } do %>
|
||||||
<div class="h-5 w-5 shrink-0 p-px text-primary">
|
<div class="h-5 w-5 shrink-0 p-px text-primary">
|
||||||
<div class="flex h-full items-center justify-center rounded-full bg-destructive">
|
<div class="flex h-full items-center justify-center rounded-full bg-destructive">
|
||||||
<%= icon "x", size: "xs" %>
|
<%= icon "x", size: "xs", color: "white" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
<%# locals: (message:, description: nil) %>
|
<%# 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: {
|
data: {
|
||||||
controller: "element-removal",
|
controller: "element-removal",
|
||||||
action: "animationend->element-removal#remove"
|
action: "animationend->element-removal#remove"
|
||||||
} do %>
|
} do %>
|
||||||
<div class="h-5 w-5 shrink-0 p-px text-primary">
|
<div class="h-5 w-5 shrink-0 p-px text-primary">
|
||||||
<div class="flex h-full items-center justify-center rounded-full bg-success">
|
<div class="flex h-full items-center justify-center rounded-full bg-success">
|
||||||
<%= icon "check", size: "xs" %>
|
<%= icon "check", size: "xs", color: "white" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
31
app/views/subscriptions/_plan_choice.html.erb
Normal file
31
app/views/subscriptions/_plan_choice.html.erb
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
<%# locals: (plan:, form:, checked: false) %>
|
||||||
|
|
||||||
|
<% price = plan == "annual" ? 90 : 9 %>
|
||||||
|
<% frequency = plan == "annual" ? "/year" : "/month" %>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<%= 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 %>
|
||||||
|
<h3 class="text-sm text-secondary"><%= plan.titleize %></h3>
|
||||||
|
|
||||||
|
<div class="mt-auto flex items-end gap-1">
|
||||||
|
<p class="font-display text-xl lg:text-3xl font-medium text-primary">$<%= price %><%= frequency %></p>
|
||||||
|
|
||||||
|
<% if plan == "annual" %>
|
||||||
|
<span class="text-sm text-secondary mb-1">or <%= Money.new(price.to_f / 52).format %>/week</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
|
<% if plan == "annual" %>
|
||||||
|
Billed annually, 2 months free
|
||||||
|
<% else %>
|
||||||
|
Billed monthly
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
55
app/views/subscriptions/upgrade.html.erb
Normal file
55
app/views/subscriptions/upgrade.html.erb
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
<div class="flex flex-col h-full justify-between bg-surface">
|
||||||
|
<nav class="p-4">
|
||||||
|
<h1 class="sr-only">Upgrade</h1>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<%= render ButtonComponent.new(
|
||||||
|
text: "Sign out",
|
||||||
|
icon: "log-out",
|
||||||
|
icon_position: :right,
|
||||||
|
variant: "ghost",
|
||||||
|
href: session_path(Current.session),
|
||||||
|
method: :delete
|
||||||
|
) %>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="grow flex flex-col items-center justify-center">
|
||||||
|
<%= image_tag "logo-color.png", class: "w-16 mb-6" %>
|
||||||
|
|
||||||
|
<% if Current.family.trialing? %>
|
||||||
|
<p class="text-xl lg:text-3xl text-primary font-display font-medium">Your trial has <%= Current.family.trial_remaining_days %> days remaining</p>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-xl lg:text-3xl text-primary font-display font-medium">Your trial is over</p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<h2 class="text-xl lg:text-3xl font-display font-medium mb-2">
|
||||||
|
<span class="text-secondary">Unlock</span>
|
||||||
|
<span class="bg-gradient-to-r from-[#EABE7F] to-[#957049] bg-clip-text text-transparent">Maybe</span>
|
||||||
|
<span class="text-secondary">today</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-sm text-secondary mb-8">To continue using Maybe pick a plan below.</p>
|
||||||
|
|
||||||
|
<%= form_with url: new_subscription_path, method: :get, class: "max-w-xs", data: { turbo: false } do |form| %>
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<%= render ButtonComponent.new(
|
||||||
|
text: "Subscribe and unlock Maybe",
|
||||||
|
variant: "primary",
|
||||||
|
full_width: true
|
||||||
|
) %>
|
||||||
|
|
||||||
|
<p class="text-xs text-secondary">
|
||||||
|
In the next step, you'll be redirected to Stripe which handles our billing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= render "layouts/shared/footer" %>
|
||||||
|
</div>
|
|
@ -35,6 +35,9 @@ Rails.application.configure do
|
||||||
|
|
||||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
# 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.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
|
# Set Active Storage URL expiration time to 7 days
|
||||||
config.active_storage.urls_expire_in = 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.
|
# Raise an error on page load if there are pending migrations.
|
||||||
config.active_record.migration_error = :page_load
|
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
|
config.active_record.verbose_query_logs = true
|
||||||
|
|
||||||
# Highlight code that enqueued background job in logs.
|
|
||||||
config.active_job.verbose_enqueue_logs = true
|
config.active_job.verbose_enqueue_logs = true
|
||||||
|
|
||||||
# Raises error for missing translations.
|
# Raises error for missing translations.
|
||||||
|
|
|
@ -24,7 +24,6 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
get "changelog", to: "pages#changelog"
|
get "changelog", to: "pages#changelog"
|
||||||
get "feedback", to: "pages#feedback"
|
get "feedback", to: "pages#feedback"
|
||||||
get "early-access", to: "pages#early_access"
|
|
||||||
|
|
||||||
resource :registration, only: %i[new create]
|
resource :registration, only: %i[new create]
|
||||||
resources :sessions, only: %i[new create destroy]
|
resources :sessions, only: %i[new create destroy]
|
||||||
|
@ -39,8 +38,9 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
resource :onboarding, only: :show do
|
resource :onboarding, only: :show do
|
||||||
collection do
|
collection do
|
||||||
get :profile
|
|
||||||
get :preferences
|
get :preferences
|
||||||
|
get :goals
|
||||||
|
get :trial
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -55,7 +55,11 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
|
|
||||||
resource :subscription, only: %i[new show] do
|
resource :subscription, only: %i[new show] do
|
||||||
get :success, on: :collection
|
collection do
|
||||||
|
get :upgrade
|
||||||
|
get :success
|
||||||
|
post :start_trial
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :tags, except: :show do
|
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 "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 ("/")
|
# Defines the root path route ("/")
|
||||||
root "pages#dashboard"
|
root "pages#dashboard"
|
||||||
end
|
end
|
||||||
|
|
17
db/migrate/20250501172430_add_user_goals.rb
Normal file
17
db/migrate/20250501172430_add_user_goals.rb
Normal file
|
@ -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
|
7
db/schema.rb
generated
7
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -230,6 +230,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_16_235758) do
|
||||||
t.datetime "last_synced_at"
|
t.datetime "last_synced_at"
|
||||||
t.string "timezone"
|
t.string "timezone"
|
||||||
t.boolean "data_enrichment_enabled", default: false
|
t.boolean "data_enrichment_enabled", default: false
|
||||||
|
t.datetime "trial_started_at"
|
||||||
|
t.boolean "early_access", default: false
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
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.string "theme", default: "system"
|
||||||
t.boolean "rule_prompts_disabled", default: false
|
t.boolean "rule_prompts_disabled", default: false
|
||||||
t.datetime "rule_prompt_dismissed_at"
|
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 ["email"], name: "index_users_on_email", unique: true
|
||||||
t.index ["family_id"], name: "index_users_on_family_id"
|
t.index ["family_id"], name: "index_users_on_family_id"
|
||||||
t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id"
|
t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id"
|
||||||
|
|
|
@ -5,6 +5,11 @@ namespace :demo_data do
|
||||||
Demo::Generator.new.reset_and_clear_data!(families)
|
Demo::Generator.new.reset_and_clear_data!(families)
|
||||||
end
|
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|
|
task :reset, [ :count ] => :environment do |t, args|
|
||||||
count = (args[:count] || 1).to_i
|
count = (args[:count] || 1).to_i
|
||||||
families = count.times.map { |i| "Demo Family #{i + 1}" }
|
families = count.times.map { |i| "Demo Family #{i + 1}" }
|
||||||
|
|
|
@ -8,7 +8,6 @@ class SubscriptionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
test "redirects to settings if self hosting" do
|
test "redirects to settings if self hosting" do
|
||||||
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
||||||
get subscription_path
|
get subscription_path
|
||||||
assert_redirected_to root_path
|
assert_response :forbidden
|
||||||
assert_equal I18n.t("subscriptions.self_hosted_alert"), flash[:alert]
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
7
test/jobs/stripe_event_handler_job_test.rb
Normal file
7
test/jobs/stripe_event_handler_job_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class StripeEventHandlerJobTest < ActiveJob::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Provider::Stripe::CustomerEventProcessorTest < ActiveSupport::TestCase
|
||||||
|
# test "process" do
|
||||||
|
|
||||||
|
# end
|
||||||
|
end
|
|
@ -0,0 +1,6 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Provider::Stripe::SubscriptionEventProcessorTest < ActiveSupport::TestCase
|
||||||
|
# test "process" do
|
||||||
|
# end
|
||||||
|
end
|
|
@ -55,7 +55,6 @@ class TradesTest < ApplicationSystemTestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def open_new_trade_modal
|
def open_new_trade_modal
|
||||||
click_on "New transaction"
|
click_on "New transaction"
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue