
Rails Inertia Stripe Billing
- 1 installs
- Updated March 9, 2026
- darkamenosa/rails-inertia-stripe-billing
Helps with ai & agent building tasks.
About
rails-inertia-stripe-billing is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
- rails-inertia-stripe-billing
- AI & Agent Building
- AI-coding skill
Rails Inertia Stripe Billing by the numbers
- 1 all-time installs (skills.sh)
- Ranked #14,102 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 7, 2026 (Skillselion catalog sync)
npx skills add https://github.com/darkamenosa/rails-inertia-stripe-billing --skill rails-inertia-stripe-billingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | March 9, 2026 |
| Repository | darkamenosa/rails-inertia-stripe-billing ↗ |
What it does
Helps with ai & agent building tasks.
Files
Stripe Subscription Billing
Install Stripe subscription billing on a Rails 8 + Inertia.js + React + TypeScript project. The asset bundle is the source of truth and should overwrite the matching billing files in the target project.
Use This Skill For
- Plan PORO (Free + Pro Monthly/Yearly, Stripe price IDs from ENV)
- Account::Subscription model (Stripe state mirror)
- Account::Billing concern (plan resolution, lifecycle hooks, comping)
- Account::Limited concern (feature limits, usage tracking, overrides)
- Stripe Checkout flow (new subscriptions)
- Stripe Billing Portal (manage payment, cancel)
- Plan switching via Billing Portal flow_data (monthly <-> yearly)
- Webhook-driven subscription sync (checkout.session.completed, updated, deleted)
- Admin comping via BillingWaiver (account-scoped)
- Admin notification settings (SiteSetting, email toggles)
- Billing mailers (subscription activated, account cancellation, admin notifications)
- Identity email -> Stripe customer email sync
- Public pricing page with auth-aware CTAs
- In-app billing page with subscription state UI
- Checkout success page with polling
Prerequisites
- Draft UI skill already applied (layouts, sidebar, public pages, admin pages)
- Auth + Tenant skill already applied (Identity/User/Account pattern, Cancellable, Incineratable)
- Rails 8.1+, PostgreSQL
- A clean worktree is strongly preferred before running the installer
What It Installs
- Gem:
stripe(viabundle add) - Models (10 new): Plan, Account::Subscription, Account::BillingWaiver, Account::UsageOverride, Account::Billing, Account::Limited, SiteSetting, DashboardReport, DashboardDateRange, Identity::NotifiesAccountsOfEmailChange
- Controllers (9 new): SubscriptionsController, BillingPortalsController, Upgrades/DowngradesController, Stripe::WebhooksController, Admin::Accounts::BillingWaiversController, Admin::Settings::NotificationsController, BillingPortalConfiguration concern
- Controllers (8 modified): InertiaController (shared plan props), PagesController (pricing plans), CustomersController (billing data), DashboardsController, Authentication (pricing intent), RegistrationsController, SessionsController, BillingsController
- Mailers (3 new): SubscriptionMailer, AccountMailer, AdminNotificationMailer + 8 view templates (HTML + text)
- Jobs (1 new): Account::SyncStripeCustomerEmailJob
- Frontend (4 new): subscription/show, notifications/show, date-range-picker component, timezone lib
- Frontend (11 modified): billing/show, pricing, customers/index+show, dashboard/show, settings/show, menus/show, app-sidebar, status-badge, format-date, types/index
- Config: Stripe initializer, updated routes (patched, not overwritten for development.rb)
- Docs: stripe-billing-guide.md (comprehensive operational guide)
- Tests: 14 new test files + 2 support helpers + 1 fixture + 2 modified test files
Installer Contract
Run:
bash $SKILL_DIR/scripts/setup.sh $PROJECT_ROOTThe setup script does all of this:
1. adds the stripe gem and runs bundle install 2. verifies auth-tenant prerequisite (Account with Cancellable) 3. removes stale files from the pre-billing admin settings structure 4. copies the final billing implementation from assets/ into the project 5. patches config/environments/development.rb to enable perform_deliveries (targeted patch, not overwrite) 6. replaces Enlead/enlead with the target app name (from config/application.rb) 7. updates CLAUDE.md with the stripe-billing-guide note when that file exists 8. creates .env entries for Stripe keys when missing 9. deletes old incremental billing migrations and generates a clean baseline set
This skill intentionally overwrites the matching billing files. Modified files include the billing-enhanced versions on top of what auth-tenant provides.
Portability
The asset files use Enlead/enlead as template placeholders. The setup script replaces them with the target project's module name (extracted from config/application.rb). This covers:
- Mailer subjects (
"Your Enlead Pro subscription is active"→"Your MyApp Pro subscription is active") - Mailer defaults (
"Enlead <noreply@enlead.app>"→"MyApp <noreply@my_app.app>") - Admin notification prefixes (
"[Enlead]"→"[MyApp]") - Test assertions matching project name
- Documentation references
Files that are NOT overwritten (patched instead):
config/environments/development.rb— only addsperform_deliveries = trueCLAUDE.md— only inserts the stripe-billing-guide reference line
Migration Policy
The skill generates only these baseline migrations (consolidated from the implementation history):
1. CreateAccountSubscriptions — subscription state with scheduled plan change columns 2. CreateAccountBillingWaivers — admin comping 3. CreateAccountUsageOverrides — admin usage overrides for testing 4. CreateSiteSettings — admin notification settings
Those templates encode the final schema shape:
account_subscriptions: plan_key, stripe_customer_id (unique), stripe_subscription_id (unique, nullable), status, period dates, cancel_at, next_amount_due_in_cents, scheduled_plan_key, scheduled_starts_ataccount_billing_waivers: unique account_id, cascade FKaccount_usage_overrides: contacts, projects, team_members (all nullable integers)site_settings: notification_recipients (string), notify_new_subscription, notify_account_cancellation (booleans)
After the script finishes, run:
bin/rails db:migrateVerification
Run:
bin/rubocop --autocorrect
npm run check && npm run lint:fix
bin/rails test test/models test/integration test/mailersStripe Setup After Installation
See docs/stripe-billing-guide.md for the complete setup, including:
1. Create Stripe products and prices (monthly + yearly) via Stripe CLI or Dashboard 2. Set environment variables in .env: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_MONTHLY_PRICE_ID, STRIPE_YEARLY_PRICE_ID, STRIPE_BILLING_PORTAL_CONFIGURATION_ID 3. Configure Billing Portal in Stripe Dashboard: enable subscription updates with both prices, cancellation, payment method updates, invoice history, and schedule-at-period-end for downgrade conditions 4. Forward webhooks locally: stripe listen --forward-to localhost:3000/stripe/webhooks
Customization Points
After running the skill, customize:
- Plan definitions:
Plan::PLANSinapp/models/plan.rb(names, prices, limits, Stripe price IDs) - Feature limits: Add new features to
Plan::PLANSlimits hash +account_usage_overridescolumns +Account::Limited#compute_usage_for - Pricing page content: Feature list and FAQ in
app/frontend/pages/pages/pricing.tsx - Admin sidebar: Billing nav items in
components/admin/app-sidebar.tsx - Mailer subjects and templates:
app/mailers/andapp/views/{subscription,account,admin_notification}_mailer/ - Notification settings:
SiteSettingmodel toggles inapp/models/site_setting.rb - Stripe API version: Pinned in
config/initializers/stripe.rb— update when upgrading the stripe gem
Files Removed by Installer
These files from the pre-billing admin settings structure are deleted:
app/controllers/admin/settings/billings_controller.rbapp/controllers/admin/settings/teams_controller.rbapp/frontend/pages/admin/settings/billing/(directory)app/frontend/pages/admin/settings/team/(directory)
Source Of Truth
Treat the files in assets/ as the desired end state for billing behavior in this repo family. The docs/stripe-billing-guide.md is the architecture reference for future billing work.
# frozen_string_literal: true
module Admin
module Accounts
class BillingWaiversController < Admin::BaseController
before_action :set_account
def create
@account.comp
redirect_back fallback_location: admin_customers_path, notice: "Account comped."
end
def destroy
@account.uncomp
redirect_back fallback_location: admin_customers_path, notice: "Account uncomped."
end
private
def set_account
@account = Account.find_by!(external_account_id: params[:account_id])
end
end
end
end
# frozen_string_literal: true
module Admin
class CustomersController < BaseController
def index
base = params[:query].present? ? Identity.search(params[:query]) : Identity.all
scope = filter_by_status(base)
pagy, identities = pagy(
scope.includes(users: { account: :cancellation }).order(sort_column => sort_direction),
limit: 25
)
render inertia: "admin/customers/index", props: {
customers: identities.map { |i| customer_props(i) },
pagination: pagination_props(pagy),
counts: {
all: base.count,
active: filter_by_status(base, "active").count,
cancelled: filter_by_status(base, "cancelled").count,
suspended: filter_by_status(base, "suspended").count
},
filters: {
status: params[:status] || "all",
query: params[:query] || "",
sort: params[:sort] || "created_at",
direction: params[:direction] || "desc"
}
}
end
def show
identity = Identity.includes(users: { account: [ :cancellation, :subscription, :billing_waiver ] }).find(params[:id])
render inertia: "admin/customers/show", props: {
customer: customer_detail_props(identity),
is_self: identity == Current.identity
}
end
private
def filter_by_status(scope, status = params[:status])
case status
when "active" then scope.admin_active
when "cancelled" then scope.admin_cancelled
when "suspended" then scope.suspended
else scope
end
end
def sort_column
%w[email created_at].include?(params[:sort]) ? params[:sort] : "created_at"
end
def sort_direction
%w[asc desc].include?(params[:direction]) ? params[:direction] : "desc"
end
def customer_props(identity)
{
id: identity.id,
email: identity.email,
name: identity.display_name,
auth_method: identity.auth_method,
staff: identity.staff?,
status: identity.status,
accounts_count: identity.users.size,
created_at: identity.created_at.iso8601
}
end
def customer_detail_props(identity)
{
id: identity.id,
email: identity.email,
name: identity.display_name,
auth_method: identity.auth_method,
staff: identity.staff?,
status: identity.status,
suspended_at: identity.suspended_at&.iso8601,
created_at: identity.created_at.iso8601,
memberships: identity.users.map { |user| membership_props(user) }
}
end
def membership_props(user)
cancellation = user.account.cancellation
subscription = user.account.subscription
plan = membership_plan_for(user.account, subscription)
{
id: user.id,
account_id: user.account.external_account_id,
account_name: user.account.name,
role: user.role,
active: user.active?,
account_cancelled: user.account.cancelled?,
days_until_deletion: cancellation ? days_remaining(cancellation) : nil,
can_reactivate: user.owner? && user.account.cancelled?,
created_at: user.created_at.iso8601,
plan_name: plan.name,
plan_key: plan.key,
subscription_status: subscription&.status,
current_period_end: subscription&.current_period_end&.iso8601,
cancel_at: subscription&.cancel_at&.iso8601,
to_be_canceled: subscription&.to_be_canceled? || false,
plan_price: plan.price,
next_amount_due: subscription&.next_amount_due,
comped: user.account.comped?,
stripe_customer_url: stripe_customer_url_for(subscription)
}
end
def membership_plan_for(account, subscription)
if account.comped?
account.plan
elsif subscription
subscription.plan
else
account.plan
end
end
def stripe_customer_url_for(subscription)
return unless subscription&.stripe_customer_id
"https://dashboard.stripe.com#{stripe_dashboard_mode_prefix}/customers/#{subscription.stripe_customer_id}"
end
def stripe_dashboard_mode_prefix
if ENV["STRIPE_SECRET_KEY"]&.start_with?("sk_live_")
""
else
"/test"
end
end
def days_remaining(cancellation)
seconds = (cancellation.created_at + Account::Incineratable::INCINERATION_GRACE_PERIOD - Time.current)
[ seconds.to_i / 86400, 0 ].max
end
end
end
# frozen_string_literal: true
module Admin
class DashboardsController < BaseController
def show
render inertia: "admin/dashboard/show", props: {
date_range: date_range.to_h,
stats: InertiaRails.defer { report.stats },
comparison: InertiaRails.defer { report.comparison },
chart_data: InertiaRails.defer { report.chart_data },
recent_signups: InertiaRails.defer { recent_signups_props },
comped_accounts: InertiaRails.defer { comped_accounts_props }
}
end
private
def date_range
@date_range ||= DashboardDateRange.new(
preset_id: params[:preset_id],
start_date: params[:start_date],
end_date: params[:end_date]
)
end
def report
@report ||= DashboardReport.new(date_range)
end
def recent_signups_props
Account.order(created_at: :desc).limit(10).includes(:subscription, :billing_waiver, users: :identity).map do |account|
owner = account.users.find { |u| u.role == "owner" }
{
id: account.id,
name: account.name,
external_id: account.external_account_id,
owner_email: owner&.identity&.email,
owner_identity_id: owner&.identity_id,
plan_name: account.plan.name,
comped: account.comped?,
created_at: account.created_at.iso8601
}
end
end
def comped_accounts_props
Account::BillingWaiver.includes(account: { users: :identity }).order(created_at: :desc).limit(20).map do |waiver|
owner = waiver.account.users.find { |u| u.role == "owner" }
{
id: waiver.account.id,
name: waiver.account.name,
external_id: waiver.account.external_account_id,
owner_email: owner&.identity&.email,
owner_identity_id: owner&.identity_id,
comped_at: waiver.created_at.iso8601
}
end
end
end
end
# frozen_string_literal: true
module Admin
module Settings
class NotificationsController < Admin::BaseController
def show
settings = SiteSetting.current
render inertia: "admin/settings/notifications/show", props: {
settings: settings_props(settings)
}
end
def update
settings = SiteSetting.current
if settings.update(site_setting_params)
redirect_to admin_settings_notifications_path, notice: "Notification settings updated."
else
redirect_to admin_settings_notifications_path, inertia: inertia_errors(settings)
end
end
private
def settings_props(settings)
{
admin_notification_recipients: settings.admin_notification_recipients,
notify_admin_new_subscription: settings.notify_admin_new_subscription,
notify_admin_account_cancellation: settings.notify_admin_account_cancellation
}
end
def site_setting_params
params.expect(site_setting: [
:notify_admin_new_subscription,
:notify_admin_account_cancellation,
admin_notification_recipients: []
])
end
end
end
end
# frozen_string_literal: true
module App
class BillingPortalsController < BaseController
include BillingPortalConfiguration
before_action :ensure_admin
before_action :ensure_not_comped
before_action :ensure_has_stripe_customer
def create
portal_session = Stripe::BillingPortal::Session.create(
customer: Current.account.subscription.stripe_customer_id,
configuration: billing_portal_configuration_id,
return_url: app_billing_url
)
inertia_location portal_session.url
rescue Stripe::StripeError, BillingPortalConfiguration::MissingConfigurationError => e
redirect_to app_billing_path, alert: "Billing error: #{e.message}"
end
private
def ensure_not_comped
if Current.account.comped?
redirect_to app_billing_path, alert: "Your account has complimentary access."
end
end
def ensure_has_stripe_customer
unless Current.account.has_stripe_customer?
redirect_to app_billing_path, alert: "No billing information found."
end
end
end
end
# frozen_string_literal: true
module App
class BillingsController < BaseController
before_action :ensure_admin
def show
render inertia: "app/billing/show", props: {
subscription: subscription_props,
scheduled_plan_change: scheduled_plan_change_props,
plans: plans_props
}
end
private
def current_subscription
@current_subscription ||= Current.account.subscription
end
def subscription_props
sub = current_subscription
return nil unless sub
{
status: sub.status,
plan_key: sub.plan_key,
plan_name: sub.plan.name,
current_period_end: sub.current_period_end&.iso8601,
cancel_at: sub.cancel_at&.iso8601,
next_amount_due: sub.next_amount_due,
to_be_canceled: sub.to_be_canceled?
}
end
def scheduled_plan_change_props
sub = current_subscription
return unless sub&.scheduled_plan_change?
return if sub.scheduled_plan_key == sub.plan_key
{
plan_key: sub.scheduled_plan_key,
starts_at: sub.scheduled_starts_at.iso8601
}
end
def plans_props
Plan.visible.map do |plan|
{
key: plan.key,
name: plan.name,
price: plan.price,
monthly: plan.monthly?,
yearly: plan.yearly?,
limits: plan.limits_for_json,
annual_savings: plan.annual_savings
}
end
end
end
end
# frozen_string_literal: true
module App
class SubscriptionsController < BaseController
before_action :ensure_admin
before_action :ensure_valid_plan, only: :create
before_action :ensure_can_checkout, only: :create
def show
stripe_session = nil
selected_plan = Current.account.plan
if params[:session_id]
stripe_session = Stripe::Checkout::Session.retrieve(params[:session_id])
unless stripe_session.metadata["account_id"] == Current.account.id.to_s
render_error_response(404, "Not Found", "Session not found.")
return
end
selected_plan = Plan[stripe_session.metadata["plan_key"]] || selected_plan
end
render inertia: "app/subscription/show", props: {
stripe_session_status: stripe_session&.payment_status,
plan_name: selected_plan.name,
subscription_active: Current.account.active_paid_subscription?
}
rescue Stripe::InvalidRequestError
redirect_to app_billing_path, alert: "Checkout session not found."
end
def create
session = Stripe::Checkout::Session.create(
customer: find_or_create_stripe_customer,
mode: "subscription",
line_items: [ { price: plan_param.stripe_price_id, quantity: 1 } ],
success_url: app_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url: app_billing_url,
metadata: { account_id: Current.account.id, plan_key: plan_param.key },
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
billing_address_collection: "required",
customer_update: { address: "auto", name: "auto" }
)
inertia_location session.url
rescue Stripe::StripeError => e
redirect_to app_billing_path, alert: "Billing error: #{e.message}"
end
private
def plan_param
@plan_param ||= Plan[params[:plan_key]]
end
def ensure_valid_plan
unless plan_param&.paid? && plan_param.stripe_price_id.present?
redirect_to app_billing_path, alert: "Invalid plan selected."
end
end
def ensure_can_checkout
if Current.account.active_paid_subscription?
redirect_to app_billing_path, alert: "You already have an active subscription."
end
end
def find_or_create_stripe_customer
if Current.account.subscription&.stripe_customer_id
Current.account.subscription.update!(plan_key: plan_param.key, status: "incomplete") unless Current.account.subscription.active?
Current.account.subscription.stripe_customer_id
else
customer = Stripe::Customer.create(
email: Current.identity.email,
name: Current.account.name,
metadata: { account_id: Current.account.id }
)
Current.account.create_subscription!(
stripe_customer_id: customer.id,
plan_key: plan_param.key,
status: "incomplete"
)
customer.id
end
rescue ActiveRecord::RecordNotUnique
Current.account.reload
Current.account.subscription.stripe_customer_id
end
end
end
# frozen_string_literal: true
module App
module Subscriptions
class DowngradesController < UpdatePlanController
before_action :ensure_yearly_plan
private
def target_plan = Plan.monthly
def ensure_yearly_plan
unless Current.account.plan.yearly?
render_error_response(400, "Bad Request", "Can only downgrade from yearly plan.")
end
end
end
end
end
# frozen_string_literal: true
module App
module Subscriptions
class UpdatePlanController < App::BaseController
include BillingPortalConfiguration
before_action :ensure_admin
before_action :ensure_active_subscription
def create
portal_session = Stripe::BillingPortal::Session.create(
customer: subscription.stripe_customer_id,
configuration: billing_portal_configuration_id,
return_url: app_billing_url,
flow_data: {
type: "subscription_update_confirm",
subscription_update_confirm: {
subscription: subscription.stripe_subscription_id,
items: [ { id: stripe_subscription_item_id, price: target_plan.stripe_price_id } ]
},
after_completion: {
type: "redirect",
redirect: { return_url: app_billing_url }
}
}
)
inertia_location portal_session.url
rescue Stripe::StripeError, BillingPortalConfiguration::MissingConfigurationError => e
redirect_to app_billing_path, alert: "Billing error: #{e.message}"
end
private
def target_plan
raise NotImplementedError
end
def subscription
@subscription ||= Current.account.subscription
end
def stripe_subscription_item_id
Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id
end
def ensure_active_subscription
if Current.account.comped?
redirect_to app_billing_path, alert: "Your account has complimentary access."
elsif !(Current.account.active_paid_subscription? && subscription&.stripe_subscription_id.present?)
redirect_to app_billing_path, alert: "No active subscription."
end
end
end
end
end
# frozen_string_literal: true
module App
module Subscriptions
class UpgradesController < UpdatePlanController
before_action :ensure_monthly_plan
private
def target_plan = Plan.yearly
def ensure_monthly_plan
unless Current.account.plan.monthly?
render_error_response(400, "Bad Request", "Can only upgrade from monthly plan.")
end
end
end
end
end
# frozen_string_literal: true
module Authentication
extend ActiveSupport::Concern
included do
prepend_before_action :set_current_identity, unless: :devise_controller?
before_action :store_user_location, if: :storable_location?
before_action :authenticate_identity!, unless: -> { devise_controller? || authenticated_by_access_token? }
before_action :require_active_identity, unless: -> { devise_controller? || authenticated_by_access_token? }
before_action :configure_permitted_parameters, if: :devise_controller?
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :authenticate_identity!, **options
skip_before_action :require_active_identity, **options
allow_unauthorized_access(**options)
end
end
private
def authenticated?
Current.identity.present?
end
def set_current_identity
authenticate_by_access_token || set_identity_from_session
end
def set_identity_from_session
Current.identity = current_identity if respond_to?(:current_identity)
end
def authenticate_by_access_token
return unless bearer_token_request?
authenticate_or_request_with_http_token do |token|
identity, access_token = AccessToken.authenticate(token)
if identity&.active_for_authentication? && access_token&.allows?(request.method)
access_token.touch(:last_used_at)
Current.identity = identity
@current_access_token = access_token
end
end
end
def bearer_token_request?
request.authorization.to_s.start_with?("Bearer")
end
def authenticated_by_access_token?
@current_access_token.present?
end
def require_active_identity
if authenticated? && !Current.identity.active_for_authentication?
inactive_message = Current.identity.inactive_message
clear_stored_location_for(:identity)
sign_out(:identity)
Current.reset
redirect_to new_identity_session_path, alert: I18n.t("devise.failure.#{inactive_message}")
end
end
def storable_location?
request.get? && is_navigational_format? && !devise_controller? && !request.xhr?
end
def store_user_location
store_location_for(:identity, request.fullpath)
end
def clear_stored_location_for(resource_or_scope)
session.delete(stored_location_key_for(resource_or_scope))
end
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [])
devise_parameter_sanitizer.permit(:account_update, keys: [])
end
def after_authentication_path_for(resource)
stored_location = stored_location_for(resource)
if stored_location.present? && allowed_stored_location?(stored_location, resource)
stored_location
else
default_after_authentication_path_for(resource)
end
end
def allowed_stored_location?(location, resource)
if location.start_with?("/admin")
resource.staff?
elsif location == app_access_tokens_path
true
elsif location == app_path || location.start_with?("/app/")
resource.accessible_memberships.exists?
else
false
end
end
def default_after_authentication_path_for(resource)
if (path = pricing_intent_path_for(resource))
path
elsif resource.accessible_memberships.exists? || resource.cancelled_memberships.exists?
app_path
elsif resource.staff?
admin_dashboard_path
else
root_path
end
end
def pricing_intent_path_for(identity)
plan_key = session.delete(:pricing_intent)
return nil unless plan_key
billing_capable = identity.accessible_memberships
.where(role: %w[owner admin])
.includes(:account)
.by_role_priority
.order(created_at: :asc)
.first
return nil unless billing_capable
app_billing_path(account_id: billing_capable.account.external_account_id, plan_key: plan_key)
end
def authentication_page_props
{
google_oauth_enabled: Devise.omniauth_configs.key?(:google_oauth2),
google_oauth_authenticity_token: form_authenticity_token(
form_options: {
action: identity_google_oauth2_omniauth_authorize_path,
method: :post
}
)
}
end
end
# frozen_string_literal: true
module BillingPortalConfiguration
extend ActiveSupport::Concern
class MissingConfigurationError < StandardError; end
private
def billing_portal_configuration_id
if (configuration_id = ENV["STRIPE_BILLING_PORTAL_CONFIGURATION_ID"]).present?
configuration_id
else
raise MissingConfigurationError, "STRIPE_BILLING_PORTAL_CONFIGURATION_ID is not set."
end
end
end
# frozen_string_literal: true
module Identities
class RegistrationsController < Devise::RegistrationsController
include InertiaFlash
rate_limit to: 10, within: 3.minutes, only: :create
def new
if params[:intent] == "upgrade" && params[:plan_key].present? && Plan.visible.map(&:key).include?(params[:plan_key])
session[:pricing_intent] = params[:plan_key]
end
render inertia: "identities/registration/new", props: authentication_page_props
end
def create
build_resource(sign_up_params)
user_name = params.dig(:user, :name).presence || resource.email.to_s.split("@").first
Identity.transaction do
resource.save!
resource.mark_password_set
Account.create_with_user(identity: resource, name: user_name)
end
set_flash_message!(:notice, :signed_up)
sign_up(resource_name, resource)
redirect_to after_sign_up_path_for(resource)
rescue ActiveRecord::RecordInvalid => error
clean_up_passwords resource
redirect_to new_identity_registration_path, inertia: { errors: registration_errors(error) }
rescue ActiveRecord::RecordNotUnique
clean_up_passwords resource
redirect_to new_identity_registration_path,
inertia: { errors: duplicate_email_error }
end
protected
def after_sign_up_path_for(resource)
pricing_intent_path_for(resource) || app_path
end
private
def registration_errors(error)
errors = resource.errors.to_hash
errors.merge!(error.record.errors.to_hash) unless error.record == resource
if email_taken_error?(resource) || email_taken_error?(error.record)
errors[:email] = duplicate_email_error[:email]
end
errors
end
def email_taken_error?(record)
record&.errors&.details&.fetch(:email, [])&.any? { |detail| detail[:error] == :taken }
end
def duplicate_email_error
{ email: "We couldn't create your account. Try signing in or resetting your password." }
end
end
end
# frozen_string_literal: true
module Identities
class SessionsController < Devise::SessionsController
include InertiaFlash
rate_limit to: 10, within: 3.minutes, only: :create
def new
render inertia: "identities/session/new", props: authentication_page_props
end
def create
self.resource = warden.authenticate!(auth_options)
set_flash_message!(:notice, :signed_in)
sign_in(resource_name, resource)
redirect_to after_sign_in_path_for(resource)
end
def destroy
clear_stored_location_for(resource_name)
session.delete(:pricing_intent)
signed_out = (Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name))
set_flash_message!(:notice, :signed_out) if signed_out
redirect_to after_sign_out_path_for(resource_name), status: :see_other
end
protected
def after_sign_in_path_for(resource)
after_authentication_path_for(resource)
end
def after_sign_out_path_for(_resource)
root_path
end
end
end
# frozen_string_literal: true
class InertiaController < ApplicationController
include InertiaFlash
include InertiaUtils
# Share data with all Inertia responses
inertia_share current_user: -> { current_user_props }
inertia_share current_identity: -> { current_identity_props }
inertia_share request_context: -> { request_context_props }
inertia_share plan: -> { plan_props }
private
def current_user_props
return nil unless Current.user && Current.account
{
id: Current.user.id,
name: Current.user.name,
email: Current.user.email,
role: Current.user.role,
staff: Current.identity&.staff? || false,
account_id: Current.account.external_account_id,
account_name: Current.account.name
}
end
def current_identity_props
return nil unless Current.identity
default_membership = current_identity_default_membership
{
id: Current.identity.id,
name: Current.identity.display_name,
email: Current.identity.email,
staff: Current.identity.staff?,
default_account_id: default_membership&.account&.external_account_id,
default_account_name: default_membership&.account&.name,
default_account_role: default_membership&.role
}
end
def current_identity_default_membership
Current.identity.accessible_memberships
.includes(:account)
.by_role_priority
.order(created_at: :asc)
.first
end
def request_context_props
{
request_id: Current.request_id,
current_time: Time.current.iso8601,
timezone: Time.zone.tzinfo.name,
platform: platform.type
}
end
def plan_props
return unless Current.account
{
key: Current.account.plan.key,
name: Current.account.plan.name,
free: Current.account.plan.free?,
active_paid: Current.account.active_paid_subscription?,
has_stripe_customer: Current.account.has_stripe_customer?,
recoverable: Current.account.recoverable_subscription?,
comped: Current.account.comped?,
subscription_status: Current.account.subscription&.status,
limits: Current.account.plan.limits_for_json
}
end
end
# frozen_string_literal: true
class PagesController < InertiaController
# Public marketing pages: allow guests and reject accidental /app/:account_id scoping.
allow_unauthenticated_access
disallow_account_scope
def home
render inertia: "pages/home"
end
def about
render inertia: "pages/about"
end
def pricing
render inertia: "pages/pricing", props: {
plans: plans_props
}
end
def privacy
render inertia: "pages/privacy"
end
def terms
render inertia: "pages/terms"
end
def contact
render inertia: "pages/contact"
end
private
def plans_props
Plan.visible.map do |plan|
{
key: plan.key,
name: plan.name,
price: plan.price,
monthly: plan.monthly?,
yearly: plan.yearly?,
limits: plan.limits_for_json,
annual_savings: plan.annual_savings
}
end
end
end
# frozen_string_literal: true
class Stripe::WebhooksController < ApplicationController
allow_unauthenticated_access
skip_before_action :verify_authenticity_token
def create
if event = verify_webhook_signature
Rails.logger.info("[Stripe Webhook] Processing #{event.type} (#{event.id})")
dispatch_stripe_event(event)
head :ok
else
head :bad_request
end
end
private
def dispatch_stripe_event(event)
case event.type
when "checkout.session.completed"
sync_new_subscription(event.data.object.subscription, plan_key: event.data.object.metadata["plan_key"]) if event.data.object.mode == "subscription"
when "customer.subscription.updated", "customer.subscription.deleted"
sync_subscription(event.data.object.id)
end
end
def verify_webhook_signature
payload = request.body.read
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
Stripe::Webhook.construct_event(payload, sig_header, ENV["STRIPE_WEBHOOK_SECRET"])
rescue Stripe::SignatureVerificationError => e
Rails.logger.error("[Stripe Webhook] Signature verification failed: #{e.message}")
nil
rescue JSON::ParserError => e
Rails.logger.error("[Stripe Webhook] Malformed payload: #{e.message}")
nil
end
def sync_new_subscription(stripe_subscription_id, plan_key:)
sync_subscription(stripe_subscription_id) do |properties|
properties[:plan_key] = plan_key if plan_key
end
end
def sync_subscription(stripe_subscription_id)
stripe_subscription = Stripe::Subscription.retrieve(stripe_subscription_id)
if subscription = find_subscription_by_stripe_customer(stripe_subscription.customer)
# Guard: skip if this event is for a different (older) subscription
if subscription.stripe_subscription_id.present? && subscription.stripe_subscription_id != stripe_subscription.id
Rails.logger.info("[Stripe Webhook] Skipping event for old subscription #{stripe_subscription.id}, current is #{subscription.stripe_subscription_id}")
return
end
properties = subscription_properties_for(stripe_subscription)
yield properties if block_given?
sends_activation_email = false
subscription.with_lock do
sends_activation_email = sends_activation_email?(subscription, properties[:status])
if refreshes_subscription_started_at?(subscription, properties[:status])
# Incomplete rows are created at checkout start to persist the Stripe customer.
# Move the analytics timestamp to the first paid activation.
properties[:created_at] = Time.current
end
properties[:stripe_subscription_id] = nil if stripe_subscription.status == "canceled"
subscription.update!(properties)
end
SubscriptionMailer.activated(subscription).deliver_later if sends_activation_email && subscription.notification_email.present?
AdminNotificationMailer.new_subscription(subscription).deliver_later if sends_activation_email && AdminNotificationMailer.enabled_for?(:new_subscription)
end
end
def find_subscription_by_stripe_customer(id)
Account::Subscription.find_by(stripe_customer_id: id)
end
def current_period_end_for(stripe_subscription)
timestamp = stripe_subscription.items.data.first&.current_period_end
Time.at(timestamp) if timestamp
end
def next_amount_due_for(stripe_subscription)
return nil if stripe_subscription.status == "canceled"
preview = Stripe::Invoice.create_preview(customer: stripe_subscription.customer, subscription: stripe_subscription.id)
preview.amount_due
rescue Stripe::InvalidRequestError
nil
end
def plan_key_for(stripe_subscription)
price_id = stripe_subscription.items.data.first&.price&.id
Plan.find_by_price_id(price_id)&.key
end
def subscription_properties_for(stripe_subscription)
properties = {
stripe_subscription_id: stripe_subscription.id,
status: stripe_subscription.status,
current_period_end: current_period_end_for(stripe_subscription),
cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil,
next_amount_due_in_cents: next_amount_due_for(stripe_subscription)
}
resolved_plan_key = plan_key_for(stripe_subscription)
properties[:plan_key] = resolved_plan_key if resolved_plan_key
merge_scheduled_plan_change(properties, stripe_subscription)
properties
end
# Returns nil when the schedule lookup fails (Stripe API error),
# signaling that we should keep existing DB values unchanged.
# Returns a hash with :plan_key and :starts_at (possibly nil) on success.
def scheduled_plan_change_for(stripe_subscription, current_plan_key:)
unless stripe_subscription.schedule.present?
return { plan_key: nil, starts_at: nil }
end
schedule = Stripe::SubscriptionSchedule.retrieve(stripe_subscription.schedule)
unless schedule.status == "active" && schedule.current_phase&.end_date
return { plan_key: nil, starts_at: nil }
end
next_phase = schedule.phases.find { |phase| phase.start_date == schedule.current_phase.end_date }
unless next_phase
return { plan_key: nil, starts_at: nil }
end
price_id = next_phase.items.first&.price
price_id = price_id.id if price_id.respond_to?(:id)
plan = Plan.find_by_price_id(price_id)
unless plan && plan.key != current_plan_key
return { plan_key: nil, starts_at: nil }
end
{ plan_key: plan.key, starts_at: Time.at(next_phase.start_date) }
rescue Stripe::StripeError => e
Rails.logger.warn("[Stripe Webhook] Schedule lookup failed: #{e.message}")
nil
end
def merge_scheduled_plan_change(properties, stripe_subscription)
scheduled = scheduled_plan_change_for(stripe_subscription, current_plan_key: properties[:plan_key])
return unless scheduled # nil = Stripe error, keep existing DB values
properties[:scheduled_plan_key] = scheduled[:plan_key]
properties[:scheduled_starts_at] = scheduled[:starts_at]
end
def refreshes_subscription_started_at?(subscription, status)
subscription.incomplete? &&
subscription.stripe_subscription_id.blank? &&
subscribed_status?(status)
end
def sends_activation_email?(subscription, status)
refreshes_subscription_started_at?(subscription, status) && status == "active"
end
def subscribed_status?(status)
%w[active trialing past_due].include?(status)
end
end
import {
Activity,
BarChart3,
LayoutDashboard,
Settings2,
Users,
Zap,
} from "lucide-react"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarRail,
} from "@/components/ui/sidebar"
import { NavUser } from "@/components/admin/nav-user"
import { TeamSwitcher } from "@/components/admin/team-switcher"
import { NavMain } from "@/components/shared/nav-main"
const navOverview = [
{
title: "Dashboard",
url: "/admin/dashboard",
icon: LayoutDashboard,
},
{
title: "Customers",
url: "/admin/customers",
icon: Users,
},
]
const navAnalytics = [
{
title: "Live",
url: "/admin/analytics/live",
icon: Activity,
},
{
title: "Reports",
url: "/admin/analytics/reports",
icon: BarChart3,
},
]
const navSystem = [
{
title: "Jobs",
url: "/admin/jobs",
icon: Zap,
external: true,
},
{
title: "Settings",
url: "/admin/settings",
icon: Settings2,
},
]
export function AppSidebar(props: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<TeamSwitcher />
</SidebarHeader>
<SidebarContent>
<NavMain items={navOverview} />
<NavMain label="Analytics" items={navAnalytics} />
<NavMain label="System" items={navSystem} />
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"
import {
Calendar,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Clock,
} from "lucide-react"
import {
addDaysInTimeZone,
addMonthsInTimeZone,
endOfDayInTimeZone,
formatInTimeZone,
getZonedDayIndex,
getZonedParts,
getZonedWeekday,
startOfDayInTimeZone,
zonedPartsToUtc,
} from "@/lib/timezone"
import { cn } from "@/lib/utils"
export type DateRange = {
start: Date
end: Date
}
type DateRangePreset = {
id: string
label: string
getRange: () => DateRange
}
type DateRangePickerProps = {
value?: DateRange
presetId?: string
onChange?: (range: DateRange, meta?: { presetId: string }) => void
presets?: DateRangePreset[]
buttonLabel?: (range: DateRange, presetLabel?: string) => string
timeZone?: string
now?: string
}
type PresetGroup = {
id: string
presets: DateRangePreset[]
}
function buildPresetGroups(now: Date, timeZone?: string): PresetGroup[] {
const nowParts = getZonedParts(now, timeZone)
const startOfCurrentMonth = zonedPartsToUtc(
{
year: nowParts.year,
month: nowParts.month,
day: 1,
hour: 0,
minute: 0,
second: 0,
},
timeZone
)
return [
{
id: "quick",
presets: [
{
id: "today",
label: "Today",
getRange: () => ({ start: startOfDay(now, timeZone), end: now }),
},
{
id: "yesterday",
label: "Yesterday",
getRange: () => {
const date = addDays(now, -1, timeZone)
return {
start: startOfDay(date, timeZone),
end: endOfDay(date, timeZone),
}
},
},
{
id: "last-30-minutes",
label: "Last 30 minutes",
getRange: () => {
const end = now
const start = new Date(end.getTime() - 30 * 60 * 1000)
return { start, end }
},
},
{
id: "last-12-hours",
label: "Last 12 hours",
getRange: () => {
const end = now
const start = new Date(end.getTime() - 12 * 60 * 60 * 1000)
return { start, end }
},
},
],
},
{
id: "rolling-days",
presets: [
{
id: "last-7-days",
label: "Last 7 days",
getRange: () => ({ start: addDays(now, -7, timeZone), end: now }),
},
{
id: "last-30-days",
label: "Last 30 days",
getRange: () => ({ start: addDays(now, -30, timeZone), end: now }),
},
{
id: "last-90-days",
label: "Last 90 days",
getRange: () => ({ start: addDays(now, -90, timeZone), end: now }),
},
{
id: "last-365-days",
label: "Last 365 days",
getRange: () => ({ start: addDays(now, -365, timeZone), end: now }),
},
{
id: "last-12-months",
label: "Last 12 months",
getRange: () => {
const end = endOfDay(
addDays(startOfCurrentMonth, -1, timeZone),
timeZone
)
const startMonthAnchor = addMonths(end, -11, timeZone)
const startMonthParts = getZonedParts(startMonthAnchor, timeZone)
const start = zonedPartsToUtc(
{
year: startMonthParts.year,
month: startMonthParts.month,
day: 1,
hour: 0,
minute: 0,
second: 0,
},
timeZone
)
const endParts = getZonedParts(end, timeZone)
const endDate = zonedPartsToUtc(
{
year: endParts.year,
month: endParts.month,
day: endParts.day,
hour: 23,
minute: 59,
second: 59,
},
timeZone
)
return { start, end: endDate }
},
},
],
},
{
id: "last-periods",
presets: [
{
id: "last-week",
label: "Last week",
getRange: () => {
const anchor = addDays(now, -7, timeZone)
const start = startOfWeek(anchor, 1, timeZone)
const end = endOfWeek(anchor, timeZone)
return { start, end }
},
},
{
id: "last-month",
label: "Last month",
getRange: () => {
const end = endOfDay(
addDays(startOfCurrentMonth, -1, timeZone),
timeZone
)
const endParts = getZonedParts(end, timeZone)
const start = zonedPartsToUtc(
{
year: endParts.year,
month: endParts.month,
day: 1,
hour: 0,
minute: 0,
second: 0,
},
timeZone
)
return { start, end }
},
},
{
id: "last-quarter",
label: "Last quarter",
getRange: () => {
const currentQuarter = Math.floor((nowParts.month - 1) / 3)
const lastQuarterStartMonth =
currentQuarter === 0 ? 10 : (currentQuarter - 1) * 3 + 1
const lastQuarterYear =
currentQuarter === 0 ? nowParts.year - 1 : nowParts.year
const start = zonedPartsToUtc(
{
year: lastQuarterYear,
month: lastQuarterStartMonth,
day: 1,
hour: 0,
minute: 0,
second: 0,
},
timeZone
)
const end = endOfDay(
addDays(addMonths(start, 3, timeZone), -1, timeZone),
timeZone
)
return { start, end }
},
},
{
id: "last-year",
label: "Last year",
getRange: () => {
const start = zonedPartsToUtc(
{
year: nowParts.year - 1,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
},
timeZone
)
const end = zonedPartsToUtc(
{
year: nowParts.year - 1,
month: 12,
day: 31,
hour: 23,
minute: 59,
second: 59,
},
timeZone
)
return { start, end }
},
},
],
},
{
id: "to-date",
presets: [
{
id: "week-to-date",
label: "Week to date",
getRange: () => ({ start: startOfWeek(now, 1, timeZone), end: now }),
},
{
id: "month-to-date",
label: "Month to date",
getRange: () => ({
start: startOfDay(startOfCurrentMonth, timeZone),
end: now,
}),
},
{
id: "quarter-to-date",
label: "Quarter to date",
getRange: () => {
const currentQuarter = Math.floor((nowParts.month - 1) / 3)
const start = zonedPartsToUtc(
{
year: nowParts.year,
month: currentQuarter * 3 + 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
},
timeZone
)
return { start, end: now }
},
},
{
id: "year-to-date",
label: "Year to date",
getRange: () => {
const start = zonedPartsToUtc(
{
year: nowParts.year,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
},
timeZone
)
return { start, end: now }
},
},
],
},
]
}
function useClickOutside(
ref: React.RefObject<HTMLElement | null>,
onClose: () => void
) {
useEffect(() => {
const handleClick = (event: MouseEvent) => {
if (!ref.current || ref.current.contains(event.target as Node)) return
onClose()
}
document.addEventListener("mousedown", handleClick)
return () => document.removeEventListener("mousedown", handleClick)
}, [onClose, ref])
}
export function DateRangePicker({
value,
presetId,
onChange,
presets,
buttonLabel,
timeZone,
now,
}: DateRangePickerProps) {
const baseNow = useMemo(() => (now ? new Date(now) : new Date()), [now])
const presetGroups = useMemo(
() => buildPresetGroups(baseNow, timeZone),
[baseNow, timeZone]
)
const defaultPresets = useMemo(
() => presetGroups.flatMap((group) => group.presets),
[presetGroups]
)
const resolvedPresets = presets ?? defaultPresets
const resolvedPresetGroups = presets
? [{ id: "custom", presets }]
: presetGroups
const initialRange = value ??
resolvedPresets[0]?.getRange() ?? { start: baseNow, end: baseNow }
const initialParts = getZonedParts(initialRange.start, timeZone)
const [committedRange, setCommittedRange] = useState<DateRange>(initialRange)
const [draftRange, setDraftRange] = useState<DateRange>(initialRange)
const [rangeAnchor, setRangeAnchor] = useState<Date | null>(null)
const [open, setOpen] = useState(false)
const [activePreset, setActivePreset] = useState<string>(
presetId ?? resolvedPresets[0]?.id ?? ""
)
const [displayMonth, setDisplayMonth] = useState(initialParts.month - 1)
const [displayYear, setDisplayYear] = useState(initialParts.year)
const popoverRef = useRef<HTMLDivElement>(null)
useClickOutside(popoverRef, () => setOpen(false))
useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false)
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [open])
useEffect(() => {
if (!value) return
setCommittedRange(value)
setDraftRange(value)
}, [value])
useEffect(() => {
if (presetId) setActivePreset(presetId)
}, [presetId])
useEffect(() => {
if (!open) return
setDraftRange(committedRange)
const parts = getZonedParts(committedRange.start, timeZone)
setDisplayMonth(parts.month - 1)
setDisplayYear(parts.year)
setRangeAnchor(null)
}, [open, committedRange, timeZone])
const handlePresetSelect = (preset: DateRangePreset) => {
const range = preset.getRange()
setDraftRange(range)
setActivePreset(preset.id)
const parts = getZonedParts(range.start, timeZone)
setDisplayMonth(parts.month - 1)
setDisplayYear(parts.year)
setRangeAnchor(null)
}
const handleDaySelect = (date: Date) => {
setActivePreset("custom")
if (!rangeAnchor) {
setRangeAnchor(date)
setDraftRange({ start: date, end: date })
return
}
if (date < rangeAnchor) {
setDraftRange({ start: date, end: rangeAnchor })
} else {
setDraftRange({ start: rangeAnchor, end: date })
}
setRangeAnchor(null)
}
const handleApply = () => {
setCommittedRange(draftRange)
onChange?.(draftRange, { presetId: activePreset })
setOpen(false)
setRangeAnchor(null)
}
const handleCancel = () => {
setDraftRange(committedRange)
setOpen(false)
setRangeAnchor(null)
}
const label = buttonLabel
? buttonLabel(
committedRange,
resolvedPresets.find((preset) => preset.id === activePreset)?.label
)
: formatButtonLabel(
committedRange,
resolvedPresets.find((preset) => preset.id === activePreset)?.label,
timeZone
)
const months = useMemo(() => {
const first = { month: displayMonth, year: displayYear }
const secondDate = new Date(Date.UTC(displayYear, displayMonth + 1, 1))
const second = {
month: secondDate.getUTCMonth(),
year: secondDate.getUTCFullYear(),
}
return [first, second]
}, [displayMonth, displayYear])
return (
<div ref={popoverRef} className="relative inline-flex">
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="flex items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1.5 text-xs font-medium text-foreground shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Calendar className="size-3.5 text-muted-foreground" />
<span>{label}</span>
<ChevronDown className="size-3 text-muted-foreground" />
</button>
{open ? (
<div className="absolute top-full right-0 z-40 mt-1.5 w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-border bg-popover shadow-md sm:w-[320px] md:w-[580px]">
<div className="grid grid-cols-1 md:grid-cols-[160px_1fr]">
{/* Desktop sidebar */}
<div className="hidden max-h-[320px] overflow-y-auto border-r border-border bg-muted/30 p-2 md:block">
<div className="flex flex-col">
{resolvedPresetGroups.map((group, groupIndex) => (
<div key={group.id}>
{groupIndex > 0 && (
<div className="my-1.5 border-t border-border" />
)}
<div className="flex flex-col gap-0.5">
{group.presets.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => handlePresetSelect(preset)}
className={cn(
"flex items-center justify-between rounded-md px-2 py-1 text-left text-[11px]",
activePreset === preset.id
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
)}
>
<span>{preset.label}</span>
{activePreset === preset.id ? (
<Check className="size-3" />
) : null}
</button>
))}
</div>
</div>
))}
</div>
</div>
<div className="flex flex-col p-3">
{/* Mobile preset dropdown */}
<div className="relative mb-2 md:hidden">
<select
value={activePreset}
onChange={(e) => {
const preset = resolvedPresets.find(
(p) => p.id === e.target.value
)
if (preset) handlePresetSelect(preset)
}}
className="w-full appearance-none rounded-md border border-input bg-background px-2.5 py-1.5 pr-8 text-xs text-foreground outline-hidden focus:ring-1 focus:ring-ring"
>
{resolvedPresets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute top-1/2 right-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
</div>
<div className="flex items-center gap-2">
<DateInput
value={formatShortDate(draftRange.start, timeZone)}
/>
<span className="text-xs text-muted-foreground">→</span>
<DateInput
value={formatShortDate(draftRange.end, timeZone)}
icon={<Clock className="size-3.5" />}
/>
</div>
{/* Mobile: single calendar, Desktop: two calendars */}
<div className="mt-3 grid grid-cols-1 gap-4 md:grid-cols-2">
{/* Mobile shows only first month with both arrows */}
<div className="md:hidden">
<CalendarMonth
month={displayMonth}
year={displayYear}
range={draftRange}
onSelect={handleDaySelect}
onPrev={() => setMonthOffset(-1)}
onNext={() => setMonthOffset(1)}
showPrev
showNext
timeZone={timeZone}
today={baseNow}
/>
</div>
{/* Desktop shows both months */}
{months.map((month, i) => (
<div
key={`${month.year}-${month.month}`}
className="hidden md:block"
>
<CalendarMonth
month={month.month}
year={month.year}
range={draftRange}
onSelect={handleDaySelect}
onPrev={() => setMonthOffset(-1)}
onNext={() => setMonthOffset(1)}
showPrev={i === 0}
showNext={i === 1}
timeZone={timeZone}
today={baseNow}
/>
</div>
))}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-border px-3 py-2">
<button
type="button"
onClick={handleCancel}
className="rounded-md border border-input bg-background px-3 py-1 text-[11px] font-medium text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground"
>
Cancel
</button>
<button
type="button"
onClick={handleApply}
className="rounded-md bg-primary px-3 py-1 text-[11px] font-medium text-primary-foreground shadow-xs hover:bg-primary/90"
>
Apply
</button>
</div>
</div>
) : null}
</div>
)
function setMonthOffset(offset: number) {
const date = new Date(Date.UTC(displayYear, displayMonth + offset, 1))
setDisplayMonth(date.getUTCMonth())
setDisplayYear(date.getUTCFullYear())
}
}
function DateInput({ value, icon }: { value: string; icon?: ReactNode }) {
return (
<div className="relative w-full">
<input
type="text"
value={value}
readOnly
className="w-full rounded-md border border-input bg-background px-2 py-1 text-[11px] text-foreground outline-hidden"
/>
{icon ? (
<span className="absolute top-1/2 right-1.5 -translate-y-1/2 text-muted-foreground">
{icon}
</span>
) : null}
</div>
)
}
function CalendarMonth({
month,
year,
range,
onSelect,
onPrev,
onNext,
showPrev,
showNext,
timeZone,
today,
}: {
month: number
year: number
range: DateRange
onSelect: (date: Date) => void
onPrev: () => void
onNext: () => void
showPrev: boolean
showNext: boolean
timeZone?: string
today?: Date
}) {
const monthLabel = formatInTimeZone(
zonedPartsToUtc(
{ year, month: month + 1, day: 1, hour: 0, minute: 0, second: 0 },
timeZone
),
{ month: "long", year: "numeric" },
timeZone
)
const days = getMonthGrid(year, month, timeZone)
return (
<div>
<div className="flex items-center justify-between">
<button
type="button"
onClick={onPrev}
className={cn(
"rounded-md p-1 text-muted-foreground hover:bg-accent",
showPrev ? "" : "invisible"
)}
>
<ChevronLeft className="size-4" />
</button>
<span className="text-xs font-medium text-foreground">
{monthLabel}
</span>
<button
type="button"
onClick={onNext}
className={cn(
"rounded-md p-1 text-muted-foreground hover:bg-accent",
showNext ? "" : "invisible"
)}
>
<ChevronRight className="size-4" />
</button>
</div>
<div className="mt-2 grid grid-cols-7 text-[10px] text-muted-foreground">
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => (
<div key={day} className="flex h-5 items-center justify-center">
{day}
</div>
))}
</div>
<div className="mt-1 grid grid-cols-7 text-[11px]">
{days.map((day, index) => {
if (!day.currentMonth) {
return <div key={`empty-${index}`} className="h-6" />
}
const isFuture =
today &&
getZonedDayIndex(day.date, timeZone) >
getZonedDayIndex(today, timeZone)
const isSelectedStart = isSameDay(day.date, range.start, timeZone)
const isSelectedEnd = isSameDay(day.date, range.end, timeZone)
const isInRange = isWithinRange(day.date, range, timeZone)
const isRangeMiddle = isInRange && !isSelectedStart && !isSelectedEnd
const dayParts = getZonedParts(day.date, timeZone)
if (isFuture) {
return (
<div
key={`${day.date.toISOString()}-${index}`}
className="flex h-6 items-center justify-center text-muted-foreground/40"
>
{dayParts.day}
</div>
)
}
return (
<button
key={`${day.date.toISOString()}-${index}`}
type="button"
onClick={() => onSelect(day.date)}
className={cn(
"relative flex h-6 items-center justify-center text-foreground transition-colors",
isRangeMiddle && "bg-accent",
isSelectedStart &&
!isSelectedEnd &&
"bg-linear-to-l from-accent to-transparent",
isSelectedEnd &&
!isSelectedStart &&
"bg-linear-to-r from-accent to-transparent",
isSelectedStart && isSelectedEnd && "",
!isInRange && "rounded-xs hover:bg-accent/50"
)}
>
<span
className={cn(
"flex size-6 items-center justify-center",
(isSelectedStart || isSelectedEnd) &&
"rounded-xs bg-primary text-primary-foreground"
)}
>
{dayParts.day}
</span>
</button>
)
})}
</div>
</div>
)
}
type CalendarDay = {
date: Date
currentMonth: boolean
}
function getMonthGrid(
year: number,
month: number,
timeZone?: string
): CalendarDay[] {
const firstOfMonth = new Date(Date.UTC(year, month, 1))
const startWeekday = firstOfMonth.getUTCDay()
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
const totalCells = Math.ceil((startWeekday + daysInMonth) / 7) * 7
const days: CalendarDay[] = []
for (let i = 0; i < totalCells; i += 1) {
const dayNumber = i - startWeekday + 1
const isCurrentMonth = dayNumber >= 1 && dayNumber <= daysInMonth
if (isCurrentMonth) {
const zonedDate = zonedPartsToUtc(
{
year,
month: month + 1,
day: dayNumber,
hour: 0,
minute: 0,
second: 0,
},
timeZone
)
days.push({ date: zonedDate, currentMonth: true })
} else {
days.push({ date: new Date(0), currentMonth: false })
}
}
return days
}
function isSameDay(left: Date, right: Date, timeZone?: string) {
return getZonedDayIndex(left, timeZone) === getZonedDayIndex(right, timeZone)
}
function isWithinRange(date: Date, range: DateRange, timeZone?: string) {
const dayIndex = getZonedDayIndex(date, timeZone)
const startIndex = getZonedDayIndex(range.start, timeZone)
const endIndex = getZonedDayIndex(range.end, timeZone)
return dayIndex >= startIndex && dayIndex <= endIndex
}
function startOfDay(date: Date, timeZone?: string) {
return startOfDayInTimeZone(date, timeZone)
}
function endOfDay(date: Date, timeZone?: string) {
return endOfDayInTimeZone(date, timeZone)
}
function addDays(date: Date, days: number, timeZone?: string) {
return addDaysInTimeZone(date, days, timeZone)
}
function addMonths(date: Date, months: number, timeZone?: string) {
return addMonthsInTimeZone(date, months, timeZone)
}
function startOfWeek(date: Date, weekStartsOn = 1, timeZone?: string) {
const day = getZonedWeekday(date, timeZone)
const diff = (day < weekStartsOn ? 7 : 0) + day - weekStartsOn
return startOfDay(addDays(date, -diff, timeZone), timeZone)
}
function endOfWeek(date: Date, timeZone?: string) {
const start = startOfWeek(date, 1, timeZone)
return endOfDay(addDays(start, 6, timeZone), timeZone)
}
function formatLongDate(date: Date, timeZone?: string) {
return formatInTimeZone(
date,
{
month: "long",
day: "numeric",
year: "numeric",
},
timeZone
)
}
function formatShortDate(date: Date, timeZone?: string) {
return formatInTimeZone(
date,
{
month: "short",
day: "numeric",
year: "numeric",
},
timeZone
)
}
function formatButtonLabel(
range: DateRange,
presetLabel?: string,
timeZone?: string
) {
if (presetLabel) return presetLabel
return `${formatLongDate(range.start, timeZone)} \u2013 ${formatLongDate(range.end, timeZone)}`
}
import { cn } from "@/lib/utils"
interface StatusBadgeProps {
status: string
showDot?: boolean
children?: React.ReactNode
}
type StatusTone = "positive" | "warning" | "critical" | "neutral"
const statusTones: Record<string, StatusTone> = {
active: "positive",
published: "positive",
fulfilled: "positive",
paid: "positive",
success: "positive",
completed: "positive",
trialing: "positive",
comped: "positive",
suspended: "warning",
pending: "warning",
past_due: "warning",
paused: "warning",
draft: "neutral",
inactive: "neutral",
incomplete: "neutral",
failed: "critical",
deleted: "critical",
cancelled: "critical",
expired: "critical",
unpaid: "critical",
incomplete_expired: "critical",
}
const toneStyles: Record<StatusTone, { badge: string; dot: string }> = {
positive: {
badge:
"bg-emerald-50 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400",
dot: "bg-emerald-500",
},
warning: {
badge:
"bg-amber-50 text-amber-700 dark:bg-amber-950/50 dark:text-amber-400",
dot: "bg-amber-500",
},
critical: {
badge: "bg-red-50 text-red-700 dark:bg-red-950/50 dark:text-red-400",
dot: "bg-red-500",
},
neutral: {
badge: "bg-muted text-muted-foreground",
dot: "bg-muted-foreground/50",
},
}
function getTone(status: string): StatusTone {
return statusTones[status.toLowerCase()] ?? "neutral"
}
export function StatusBadge({
status,
showDot = true,
children,
}: StatusBadgeProps) {
const tone = getTone(status)
const styles = toneStyles[tone]
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium",
styles.badge
)}
>
{showDot && <span className={cn("size-1.5 rounded-full", styles.dot)} />}
{children ??
status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()}
</span>
)
}
import {
formatInTimeZone,
getZonedDayIndex,
getZonedParts,
} from "@/lib/timezone"
export function formatDateShort(iso: string): string {
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
}
export function formatCalendarDateTime(
iso: string,
referenceDate = new Date(),
timeZone?: string
): string {
const date = new Date(iso)
const dayDifference =
getZonedDayIndex(referenceDate, timeZone) - getZonedDayIndex(date, timeZone)
const timeLabel = formatInTimeZone(
date,
{ hour: "numeric", minute: "2-digit", hour12: true },
timeZone
).toLowerCase()
if (dayDifference === 0) return `Today at ${timeLabel}`
if (dayDifference === 1) return `Yesterday at ${timeLabel}`
if (dayDifference > 1 && dayDifference <= 7) {
const weekdayLabel = formatInTimeZone(date, { weekday: "long" }, timeZone)
return `${weekdayLabel} at ${timeLabel}`
}
const nowParts = getZonedParts(referenceDate, timeZone)
const dateParts = getZonedParts(date, timeZone)
const dateLabel = formatInTimeZone(
date,
nowParts.year === dateParts.year
? { month: "short", day: "numeric" }
: { month: "short", day: "numeric", year: "numeric" },
timeZone
)
return `${dateLabel} at ${timeLabel}`
}
type ZonedDateParts = {
year: number
month: number
day: number
hour: number
minute: number
second: number
}
const DEFAULT_LOCALE = "en-US"
function buildFormatter(
options: Intl.DateTimeFormatOptions,
timeZone?: string
) {
const resolved = timeZone ? { ...options, timeZone } : options
return new Intl.DateTimeFormat(DEFAULT_LOCALE, resolved)
}
export function formatInTimeZone(
date: Date,
options: Intl.DateTimeFormatOptions,
timeZone?: string
): string {
return buildFormatter(options, timeZone).format(date)
}
export function getZonedParts(date: Date, timeZone?: string): ZonedDateParts {
if (!timeZone) {
return {
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
hour: date.getHours(),
minute: date.getMinutes(),
second: date.getSeconds(),
}
}
const formatter = buildFormatter(
{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
},
timeZone
)
const parts = formatter.formatToParts(date)
const read = (type: string) =>
Number(parts.find((part) => part.type === type)?.value)
return {
year: read("year"),
month: read("month"),
day: read("day"),
hour: read("hour"),
minute: read("minute"),
second: read("second"),
}
}
function getTimeZoneOffset(date: Date, timeZone: string): number {
const parts = getZonedParts(date, timeZone)
const asUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second
)
return asUtc - date.getTime()
}
export function zonedPartsToUtc(
parts: ZonedDateParts,
timeZone?: string
): Date {
if (!timeZone) {
return new Date(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second
)
}
const utcGuess = new Date(
Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second
)
)
const offset = getTimeZoneOffset(utcGuess, timeZone)
return new Date(utcGuess.getTime() - offset)
}
export function getZonedDayIndex(date: Date, timeZone?: string): number {
const parts = getZonedParts(date, timeZone)
return Date.UTC(parts.year, parts.month - 1, parts.day) / 86_400_000
}
export function startOfDayInTimeZone(date: Date, timeZone?: string): Date {
const parts = getZonedParts(date, timeZone)
return zonedPartsToUtc({ ...parts, hour: 0, minute: 0, second: 0 }, timeZone)
}
export function endOfDayInTimeZone(date: Date, timeZone?: string): Date {
const parts = getZonedParts(date, timeZone)
return zonedPartsToUtc(
{ ...parts, hour: 23, minute: 59, second: 59 },
timeZone
)
}
export function addDaysInTimeZone(
date: Date,
days: number,
timeZone?: string
): Date {
const parts = getZonedParts(date, timeZone)
const utc = new Date(
Date.UTC(
parts.year,
parts.month - 1,
parts.day + days,
parts.hour,
parts.minute,
parts.second
)
)
return zonedPartsToUtc(
{
year: utc.getUTCFullYear(),
month: utc.getUTCMonth() + 1,
day: utc.getUTCDate(),
hour: parts.hour,
minute: parts.minute,
second: parts.second,
},
timeZone
)
}
export function addMonthsInTimeZone(
date: Date,
months: number,
timeZone?: string
): Date {
const parts = getZonedParts(date, timeZone)
const firstOfTarget = new Date(
Date.UTC(parts.year, parts.month - 1 + months, 1)
)
const lastDay = new Date(
Date.UTC(firstOfTarget.getUTCFullYear(), firstOfTarget.getUTCMonth() + 1, 0)
).getUTCDate()
const clampedDay = Math.min(parts.day, lastDay)
return zonedPartsToUtc(
{
year: firstOfTarget.getUTCFullYear(),
month: firstOfTarget.getUTCMonth() + 1,
day: clampedDay,
hour: parts.hour,
minute: parts.minute,
second: parts.second,
},
timeZone
)
}
export function getZonedWeekday(date: Date, timeZone?: string): number {
const parts = getZonedParts(date, timeZone)
const utc = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
return utc.getUTCDay()
}
export function formatISODateInTimeZone(date: Date, timeZone?: string): string {
const parts = getZonedParts(date, timeZone)
const pad = (value: number) => value.toString().padStart(2, "0")
return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}`
}
import { useCallback, useEffect, useRef, useState } from "react"
import { Head, Link, router, usePage } from "@inertiajs/react"
import type { AdminCustomer, PaginationData, SharedProps } from "@/types"
import { formatCalendarDateTime } from "@/lib/format-date"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
IndexFilters,
IndexTable,
type IndexTableColumn,
type IndexTablePagination,
type IndexTableSort,
} from "@/components/admin/ui/index-table"
import { StatusBadge } from "@/components/admin/ui/status-badge"
import { useSetIndexFiltersMode } from "@/components/admin/ui/use-index-filters-mode"
import AdminLayout from "@/layouts/admin-layout"
interface Counts {
all: number
active: number
cancelled: number
suspended: number
}
interface Filters {
status: string
query: string
sort: string
direction: string
}
interface Props {
customers: AdminCustomer[]
pagination: PaginationData
counts: Counts
filters: Filters
}
function buildParams(filters: Filters, page?: number) {
const params: Record<string, string> = {}
if (filters.status && filters.status !== "all") params.status = filters.status
if (filters.query) params.query = filters.query
if (filters.sort && filters.sort !== "created_at") params.sort = filters.sort
if (filters.direction && filters.direction !== "desc")
params.direction = filters.direction
if (page && page > 1) params.page = String(page)
return params
}
const STATUS_TABS = ["all", "active", "cancelled", "suspended"]
const FALLBACK_CURRENT_TIME = "1970-01-01T00:00:00Z"
export default function AdminCustomersIndex({
customers,
pagination,
counts,
filters,
}: Props) {
const { requestContext } = usePage<SharedProps>().props
const [query, setQuery] = useState(filters.query)
const [bulkDialog, setBulkDialog] = useState<{
action: string
ids: (string | number)[]
} | null>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined
)
const { mode, setMode } = useSetIndexFiltersMode("default")
const currentTime = new Date(
requestContext?.currentTime ?? FALLBACK_CURRENT_TIME
)
const timeZone = requestContext?.timezone
const tabs = [
{ id: "all", label: `All (${counts.all})` },
{ id: "active", label: `Active (${counts.active})` },
{ id: "cancelled", label: `Cancelled (${counts.cancelled})` },
{ id: "suspended", label: `Suspended (${counts.suspended})` },
]
const selectedTabIndex = Math.max(STATUS_TABS.indexOf(filters.status), 0)
const navigate = useCallback(
(overrides: Partial<Filters>, page?: number) => {
const merged = { ...filters, ...overrides }
router.get("/admin/customers", buildParams(merged, page), {
preserveState: true,
preserveScroll: true,
})
},
[filters]
)
// Debounced search
useEffect(() => {
if (query === filters.query) return
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
navigate({ query, status: filters.status })
}, 300)
return () => clearTimeout(debounceRef.current)
}, [query, filters.query, filters.status, navigate])
const handleTabChange = (index: number) => {
const tabId = STATUS_TABS[index] ?? "all"
navigate({ status: tabId, query: "" })
setQuery("")
}
const handleBulkAction = (action: string, ids: (string | number)[]) => {
setBulkDialog({ action, ids })
}
const executeBulkAction = () => {
if (!bulkDialog) return
const { action, ids } = bulkDialog
if (action === "suspend") {
router.post(
"/admin/customers/bulk_suspension",
{ ids },
{ preserveState: false }
)
} else if (action === "reactivate") {
router.delete("/admin/customers/bulk_suspension", {
data: { ids },
preserveState: false,
})
}
setBulkDialog(null)
}
const columns: IndexTableColumn[] = [
{ id: "email", label: "Customer", sortable: true },
{ id: "auth_method", label: "Auth" },
{ id: "staff", label: "Staff" },
{ id: "status", label: "Login" },
{ id: "accounts_count", label: "Accounts" },
{ id: "created_at", label: "Joined", sortable: true },
]
const sort: IndexTableSort = {
columnId: filters.sort,
direction: filters.direction as "asc" | "desc",
onChange: (columnId, direction) => {
navigate({ sort: columnId, direction })
},
}
const paginationProps: IndexTablePagination = {
label: `${pagination.from}–${pagination.to} of ${pagination.total}`,
hasPrevious: pagination.hasPrevious,
hasNext: pagination.hasNext,
onPrevious: () => navigate({}, pagination.page - 1),
onNext: () => navigate({}, pagination.page + 1),
}
return (
<AdminLayout>
<Head title="Customers" />
<div className="flex flex-col gap-4">
<h1 className="text-lg font-semibold">Customers</h1>
<div className="rounded-lg border border-border bg-card">
<IndexFilters
tabs={tabs}
selected={selectedTabIndex}
onSelect={handleTabChange}
queryValue={query}
onQueryChange={setQuery}
onQueryClear={() => {
setQuery("")
navigate({ query: "" })
}}
queryPlaceholder="Search customers..."
mode={mode}
setMode={setMode}
/>
<IndexTable
items={customers}
columns={columns}
itemId={(customer) => customer.id}
renderRow={(customer) => [
<Link
key="customer"
href={`/admin/customers/${customer.id}`}
className="group block"
>
<span className="font-medium group-hover:underline">
{customer.name || "\u2014"}
</span>
<span className="block text-xs text-muted-foreground">
{customer.email}
</span>
</Link>,
customer.authMethod,
customer.staff ? (
<Badge key="staff" variant="secondary">
Staff
</Badge>
) : null,
<StatusBadge key="status" status={customer.status} />,
customer.accountsCount,
formatCalendarDateTime(customer.createdAt, currentTime, timeZone),
]}
sort={sort}
pagination={paginationProps}
bulkActions={[
{
key: "suspend",
label: "Suspend",
onAction: (ids) => handleBulkAction("suspend", ids),
},
{
key: "reactivate",
label: "Unsuspend",
onAction: (ids) => handleBulkAction("reactivate", ids),
},
]}
emptyState={
<div>
<p className="text-muted-foreground">No customers found</p>
<p className="mt-1 text-sm text-muted-foreground">
{filters.query
? "Try a different search term."
: filters.status !== "all"
? "No customers match this filter."
: "Customers will appear here once they sign up."}
</p>
</div>
}
/>
</div>
</div>
{/* Bulk action confirmation dialog */}
<Dialog
open={bulkDialog !== null}
onOpenChange={(open) => !open && setBulkDialog(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{bulkDialog?.action === "suspend"
? "Suspend customers?"
: "Unsuspend customers?"}
</DialogTitle>
<DialogDescription>
{bulkDialog?.action === "suspend"
? `This will suspend ${bulkDialog?.ids.length} customer(s). They will not be able to sign in.`
: `This will restore sign-in access for ${bulkDialog?.ids.length} suspended customer(s).`}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDialog(null)}>
Cancel
</Button>
<Button onClick={executeBulkAction}>
{bulkDialog?.action === "suspend"
? "Yes, suspend"
: "Yes, unsuspend"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AdminLayout>
)
}
import { useState } from "react"
import { Head, Link, router } from "@inertiajs/react"
import type { AdminCustomerDetail, AdminCustomerMembership } from "@/types"
import { ChevronLeft, ExternalLink } from "lucide-react"
import { formatDateShort } from "@/lib/format-date"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { StatusBadge } from "@/components/admin/ui/status-badge"
import AdminLayout from "@/layouts/admin-layout"
interface Props {
customer: AdminCustomerDetail
isSelf: boolean
}
type IdentityAction = "suspend" | "unsuspend" | "grant_staff" | "revoke_staff"
function billingCycleLabel(planKey: string) {
if (planKey === "monthly") return "Monthly"
if (planKey === "yearly") return "Yearly"
return null
}
// ─── Overview ───────────────────────────────────────────────────────────────
function OverviewCard({ customer }: { customer: AdminCustomerDetail }) {
return (
<Card>
<CardHeader>
<CardTitle>Customer details</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 text-sm">
<div className="col-span-2">
<dt className="text-muted-foreground">Email</dt>
<dd className="mt-0.5 font-medium">{customer.email}</dd>
</div>
<div>
<dt className="text-muted-foreground">Name</dt>
<dd className="mt-0.5 font-medium">{customer.name || "—"}</dd>
</div>
<div>
<dt className="text-muted-foreground">Auth method</dt>
<dd className="mt-0.5 font-medium">{customer.authMethod}</dd>
</div>
<div>
<dt className="text-muted-foreground">Joined</dt>
<dd className="mt-0.5 font-medium">
{formatDateShort(customer.createdAt)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Accounts</dt>
<dd className="mt-0.5 font-medium">
{customer.memberships.length}
</dd>
</div>
</dl>
</CardContent>
</Card>
)
}
// ─── Subscription Detail ────────────────────────────────────────────────────
function SubscriptionInfo({
membership: m,
}: {
membership: AdminCustomerMembership
}) {
if (m.comped) {
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{m.planName}</span>
<Badge variant="secondary">Comped</Badge>
</div>
<p className="text-xs text-muted-foreground">
Complimentary access — no billing
</p>
</div>
)
}
if (!m.subscriptionStatus) {
return (
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">Free plan</span>
<p className="text-xs text-muted-foreground">No subscription</p>
</div>
)
}
const cycleLabel = billingCycleLabel(m.planKey)
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{m.planName}
{cycleLabel ? ` (${cycleLabel})` : ""}
</span>
<StatusBadge status={m.subscriptionStatus} />
</div>
{m.toBeCanceled && m.cancelAt && (
<div className="flex items-center gap-1.5 rounded-md bg-amber-50 px-2 py-1 text-xs text-amber-700 dark:bg-amber-950/50 dark:text-amber-400">
<span>Cancels on {formatDateShort(m.cancelAt)}</span>
</div>
)}
{m.subscriptionStatus === "past_due" && (
<p className="text-xs text-amber-600 dark:text-amber-400">
Payment failed — Stripe is retrying
</p>
)}
{m.subscriptionStatus === "unpaid" && (
<p className="text-xs text-red-600 dark:text-red-400">
All payment retries exhausted
</p>
)}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{m.subscriptionStatus !== "canceled" && m.currentPeriodEnd && (
<span>Renews {formatDateShort(m.currentPeriodEnd)}</span>
)}
{m.subscriptionStatus !== "canceled" && m.planPrice > 0 && (
<span>
${m.planPrice}
{m.planKey === "monthly"
? "/mo"
: m.planKey === "yearly"
? "/yr"
: ""}
</span>
)}
{m.subscriptionStatus !== "canceled" &&
m.nextAmountDue !== null &&
m.nextAmountDue !== m.planPrice && (
<span>Next invoice: ${m.nextAmountDue.toFixed(2)}</span>
)}
</div>
</div>
)
}
// ─── Membership Card ────────────────────────────────────────────────────────
function MembershipCard({
membership: m,
onReactivate,
onComp,
onUncomp,
}: {
membership: AdminCustomerMembership
onReactivate: () => void
onComp: () => void
onUncomp: () => void
}) {
return (
<div className="rounded-lg border bg-card">
{/* Header */}
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2.5">
<span className="text-sm font-semibold">{m.accountName}</span>
<Badge variant="outline" className="text-muted-foreground capitalize">
{m.role}
</Badge>
{m.accountCancelled ? (
<div className="flex items-center gap-1.5">
<StatusBadge status="cancelled" />
{m.daysUntilDeletion !== null && (
<span className="text-xs text-muted-foreground">
{m.daysUntilDeletion}d until deletion
</span>
)}
</div>
) : (
<StatusBadge status={m.active ? "active" : "inactive"} />
)}
</div>
<span className="text-xs text-muted-foreground">
Joined {formatDateShort(m.createdAt)}
</span>
</div>
{/* Body */}
<div className="flex items-start justify-between gap-4 px-4 py-3">
{/* Subscription info */}
<SubscriptionInfo membership={m} />
{/* Actions */}
<div className="flex shrink-0 items-center gap-2">
{m.accountCancelled && m.canReactivate && (
<Button
size="sm"
variant="outline"
className="h-7"
onClick={onReactivate}
>
Reactivate
</Button>
)}
{m.comped ? (
<Button
size="sm"
variant="outline"
className="h-7"
onClick={onUncomp}
>
Remove comp
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-7"
onClick={onComp}
>
Comp
</Button>
)}
{m.stripeCustomerUrl && (
<a
href={m.stripeCustomerUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
Stripe
<ExternalLink className="size-3" />
</a>
)}
</div>
</div>
</div>
)
}
// ─── Memberships ────────────────────────────────────────────────────────────
function MembershipsCard({
customer,
onReactivateAccount,
onCompAccount,
onUncompAccount,
}: {
customer: AdminCustomerDetail
onReactivateAccount: (membershipId: number, accountName: string) => void
onCompAccount: (accountId: number, accountName: string) => void
onUncompAccount: (accountId: number, accountName: string) => void
}) {
const count = customer.memberships.length
return (
<Card>
<CardHeader>
<CardTitle>Account memberships</CardTitle>
<CardDescription>
{count === 0
? "No account memberships"
: `${count} account${count !== 1 ? "s" : ""}`}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{count > 0 ? (
customer.memberships.map((m) => (
<MembershipCard
key={m.id}
membership={m}
onReactivate={() => onReactivateAccount(m.id, m.accountName)}
onComp={() => onCompAccount(m.accountId, m.accountName)}
onUncomp={() => onUncompAccount(m.accountId, m.accountName)}
/>
))
) : (
<p className="py-8 text-center text-sm text-muted-foreground">
This identity has no account memberships yet.
</p>
)}
</CardContent>
</Card>
)
}
// ─── Identity Card (sidebar) ────────────────────────────────────────────────
function IdentityCard({
customer,
isSelf,
onSuspend,
onUnsuspend,
onGrantStaff,
onRevokeStaff,
}: {
customer: AdminCustomerDetail
isSelf: boolean
onSuspend: () => void
onUnsuspend: () => void
onGrantStaff: () => void
onRevokeStaff: () => void
}) {
const isSuspended = customer.status === "suspended"
return (
<Card>
<CardHeader>
<CardTitle>Identity</CardTitle>
</CardHeader>
<CardContent className="flex flex-col">
{/* Login status */}
<div className="flex flex-col gap-2 pb-4">
<span className="text-sm font-medium">Login status</span>
<div className="flex items-center gap-2">
<StatusBadge status={customer.status} />
{isSuspended && customer.suspendedAt && (
<span className="text-xs text-muted-foreground">
since {formatDateShort(customer.suspendedAt)}
</span>
)}
</div>
<p className="text-sm text-muted-foreground">
{isSuspended
? "Suspended. Cannot sign in."
: "Can sign in normally."}
</p>
{!isSelf &&
(isSuspended ? (
<Button
size="sm"
variant="outline"
className="w-fit"
onClick={onUnsuspend}
>
Unsuspend
</Button>
) : (
<Button
size="sm"
variant="outline"
className="w-fit"
onClick={onSuspend}
>
Suspend
</Button>
))}
</div>
<div className="border-t" />
{/* Staff access */}
<div className="flex flex-col gap-2 py-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Staff access</span>
<Badge
variant={customer.staff ? "secondary" : "outline"}
className="text-muted-foreground"
>
{customer.staff ? "Staff" : "No access"}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{customer.staff
? "Has access to the admin panel."
: "Cannot access the admin panel."}
</p>
{!isSelf &&
(customer.staff ? (
<Button
size="sm"
variant="outline"
className="w-fit"
onClick={onRevokeStaff}
>
Revoke access
</Button>
) : (
<Button
size="sm"
variant="outline"
className="w-fit"
onClick={onGrantStaff}
>
Grant access
</Button>
))}
</div>
</CardContent>
</Card>
)
}
// ─── Page ───────────────────────────────────────────────────────────────────
export default function AdminCustomerShow({ customer, isSelf }: Props) {
const [actionOpen, setActionOpen] = useState(false)
const [pendingAction, setPendingAction] = useState<IdentityAction | null>(
null
)
const [actionProcessing, setActionProcessing] = useState(false)
const [reactivateAccount, setReactivateAccount] = useState<{
membershipId: number
accountName: string
} | null>(null)
const [compAccount, setCompAccount] = useState<{
accountId: number
accountName: string
} | null>(null)
const [uncompAccount, setUncompAccount] = useState<{
accountId: number
accountName: string
} | null>(null)
const actionMeta = (() => {
if (pendingAction === "suspend") {
return {
title: "Suspend this customer?",
description:
"This customer will no longer be able to sign in until unsuspended.",
confirmLabel: "Yes, suspend",
confirmVariant: "destructive" as const,
}
}
if (pendingAction === "unsuspend") {
return {
title: "Unsuspend this customer?",
description: "This customer will regain sign-in access immediately.",
confirmLabel: "Yes, unsuspend",
confirmVariant: "default" as const,
}
}
if (pendingAction === "grant_staff") {
return {
title: "Grant staff access?",
description:
"This customer will gain access to the admin panel immediately.",
confirmLabel: "Yes, grant access",
confirmVariant: "default" as const,
}
}
if (pendingAction === "revoke_staff") {
return {
title: "Revoke staff access?",
description: "This customer will lose access to the admin panel.",
confirmLabel: "Yes, revoke access",
confirmVariant: "destructive" as const,
}
}
return null
})()
function openActionDialog(action: IdentityAction) {
setPendingAction(action)
setActionOpen(true)
}
function handleActionOpenChange(open: boolean) {
setActionOpen(open)
if (!open && !actionProcessing) {
setPendingAction(null)
}
}
function handleActionConfirm() {
if (!pendingAction) return
const requestOptions = {
onSuccess: () => {
setActionOpen(false)
setPendingAction(null)
},
onFinish: () => setActionProcessing(false),
}
setActionProcessing(true)
if (pendingAction === "suspend") {
router.post(
`/admin/customers/${customer.id}/suspension`,
{},
requestOptions
)
} else if (pendingAction === "unsuspend") {
router.delete(
`/admin/customers/${customer.id}/suspension`,
requestOptions
)
} else if (pendingAction === "grant_staff") {
router.post(
`/admin/customers/${customer.id}/staff_access`,
{},
requestOptions
)
} else {
router.delete(
`/admin/customers/${customer.id}/staff_access`,
requestOptions
)
}
}
function handleReactivateAccount() {
if (!reactivateAccount) return
router.post(
`/admin/customers/${customer.id}/account_reactivation`,
{ membership_id: reactivateAccount.membershipId },
{
onSuccess: () => setReactivateAccount(null),
}
)
}
function handleCompAccount() {
if (!compAccount) return
router.post(
`/admin/accounts/${compAccount.accountId}/billing_waiver`,
{},
{
onSuccess: () => setCompAccount(null),
}
)
}
function handleUncompAccount() {
if (!uncompAccount) return
router.delete(`/admin/accounts/${uncompAccount.accountId}/billing_waiver`, {
onSuccess: () => setUncompAccount(null),
})
}
return (
<AdminLayout>
<Head title={customer.name || customer.email} />
<div className="flex flex-col gap-4">
{/* Page header */}
<div className="flex items-center gap-2.5">
<Link
href="/admin/customers"
aria-label="Back to customers"
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ChevronLeft className="size-4" />
</Link>
<h1 className="min-w-0 truncate text-lg font-semibold">
{customer.name || customer.email}
</h1>
<StatusBadge status={customer.status} />
</div>
{/* Main + sidebar grid */}
<div className="grid items-start gap-4 lg:grid-cols-5">
<div className="flex flex-col gap-4 lg:col-span-3">
<OverviewCard customer={customer} />
<MembershipsCard
customer={customer}
onReactivateAccount={(membershipId, accountName) =>
setReactivateAccount({ membershipId, accountName })
}
onCompAccount={(accountId, accountName) =>
setCompAccount({ accountId, accountName })
}
onUncompAccount={(accountId, accountName) =>
setUncompAccount({ accountId, accountName })
}
/>
</div>
<div className="lg:col-span-2">
<IdentityCard
customer={customer}
isSelf={isSelf}
onSuspend={() => openActionDialog("suspend")}
onUnsuspend={() => openActionDialog("unsuspend")}
onGrantStaff={() => openActionDialog("grant_staff")}
onRevokeStaff={() => openActionDialog("revoke_staff")}
/>
</div>
</div>
</div>
{/* Identity action confirmation */}
<Dialog open={actionOpen} onOpenChange={handleActionOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{actionMeta?.title}</DialogTitle>
<DialogDescription>{actionMeta?.description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => handleActionOpenChange(false)}
disabled={actionProcessing}
>
Cancel
</Button>
<Button
variant={actionMeta?.confirmVariant}
onClick={handleActionConfirm}
disabled={actionProcessing}
>
{actionMeta?.confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Account reactivation confirmation */}
<Dialog
open={reactivateAccount !== null}
onOpenChange={(open) => !open && setReactivateAccount(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Reactivate “{reactivateAccount?.accountName}”?
</DialogTitle>
<DialogDescription>
This will cancel the scheduled deletion. The account and all its
data will be preserved.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setReactivateAccount(null)}
>
Cancel
</Button>
<Button onClick={handleReactivateAccount}>
Reactivate account
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Comp account confirmation */}
<Dialog
open={compAccount !== null}
onOpenChange={(open) => !open && setCompAccount(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Comp “{compAccount?.accountName}”?
</DialogTitle>
<DialogDescription>
This account will receive a complimentary subscription and bypass
billing.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setCompAccount(null)}>
Cancel
</Button>
<Button onClick={handleCompAccount}>Yes, comp account</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Uncomp account confirmation */}
<Dialog
open={uncompAccount !== null}
onOpenChange={(open) => !open && setUncompAccount(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Remove comp from “{uncompAccount?.accountName}”?
</DialogTitle>
<DialogDescription>
This account will lose its complimentary subscription and will
need to pay for a plan.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setUncompAccount(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleUncompAccount}>
Yes, remove comp
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AdminLayout>
)
}
import type { FormEvent } from "react"
import { useState } from "react"
import { Head, Link, useForm } from "@inertiajs/react"
import { ArrowLeft, Mail, Plus, X } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import AdminLayout from "@/layouts/admin-layout"
interface Props {
settings: {
adminNotificationRecipients: string[]
notifyAdminNewSubscription: boolean
notifyAdminAccountCancellation: boolean
}
}
const MAX_RECIPIENTS = 5
function NotificationToggle({
id,
title,
description,
checked,
onCheckedChange,
}: {
id: string
title: string
description: string
checked: boolean
onCheckedChange: (checked: boolean) => void
}) {
return (
<label
htmlFor={id}
className="flex cursor-pointer items-start gap-3 p-4 transition-colors hover:bg-muted/30"
>
<Checkbox
id={id}
className="mt-0.5"
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
/>
<div className="flex-1">
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</label>
)
}
export default function AdminSettingsNotifications({ settings }: Props) {
const { data, setData, patch, processing, errors, isDirty, transform } =
useForm({
admin_notification_recipients: settings.adminNotificationRecipients,
notify_admin_new_subscription: settings.notifyAdminNewSubscription,
notify_admin_account_cancellation:
settings.notifyAdminAccountCancellation,
})
const [newEmail, setNewEmail] = useState("")
const [emailError, setEmailError] = useState<string | null>(null)
const hasDraftEmail = newEmail.trim().length > 0
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const draftEmail = newEmail.trim().toLowerCase()
let recipients = data.admin_notification_recipients
if (draftEmail) {
if (!validateEmail(draftEmail)) {
setEmailError("Please enter a valid email address before saving")
return
}
if (!recipients.includes(draftEmail)) {
if (recipients.length >= MAX_RECIPIENTS) {
setEmailError(`Maximum ${MAX_RECIPIENTS} recipients allowed`)
return
}
recipients = [...recipients, draftEmail]
setData("admin_notification_recipients", recipients)
}
setNewEmail("")
setEmailError(null)
}
transform((formData) => ({
site_setting: {
admin_notification_recipients: recipients,
notify_admin_new_subscription: formData.notify_admin_new_subscription,
notify_admin_account_cancellation:
formData.notify_admin_account_cancellation,
},
}))
patch("/admin/settings/notifications", { preserveScroll: true })
}
function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
function addRecipient() {
const email = newEmail.trim().toLowerCase()
setEmailError(null)
if (!email) {
setEmailError("Please enter an email address")
return
}
if (!validateEmail(email)) {
setEmailError("Please enter a valid email address")
return
}
if (data.admin_notification_recipients.includes(email)) {
setEmailError("This email is already added")
return
}
if (data.admin_notification_recipients.length >= MAX_RECIPIENTS) {
setEmailError(`Maximum ${MAX_RECIPIENTS} recipients allowed`)
return
}
setData("admin_notification_recipients", [
...data.admin_notification_recipients,
email,
])
setNewEmail("")
}
function removeRecipient(email: string) {
setData(
"admin_notification_recipients",
data.admin_notification_recipients.filter((e) => e !== email)
)
}
function handleKeyDown(event: React.KeyboardEvent) {
if (event.key === "Enter") {
event.preventDefault()
addRecipient()
}
}
return (
<AdminLayout>
<Head title="Notification Settings" />
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* Page Header */}
<div className="flex items-center gap-3">
<Link
href="/admin/settings"
className="rounded-sm p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="size-4" />
</Link>
<h1 className="text-lg font-semibold">Notifications</h1>
</div>
{/* Notification Recipients */}
<div className="rounded-lg border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<Mail className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">
Notification Recipients
</span>
</div>
</div>
<div className="p-4">
<div className="flex flex-col gap-4">
<p className="text-xs text-muted-foreground">
Add up to {MAX_RECIPIENTS} email addresses to receive admin
notifications. Leave empty to disable all admin notifications.
</p>
{/* Current Recipients */}
{data.admin_notification_recipients.length > 0 && (
<div className="flex flex-wrap gap-2">
{data.admin_notification_recipients.map((email) => (
<Badge
key={email}
variant="secondary"
className="gap-1.5 px-3 py-1 text-xs"
>
{email}
<button
type="button"
onClick={() => removeRecipient(email)}
aria-label={`Remove ${email}`}
className="rounded-full p-0.5 transition-colors hover:bg-foreground/10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
)}
{/* Add New Email */}
{data.admin_notification_recipients.length < MAX_RECIPIENTS && (
<div className="flex flex-col gap-1.5">
<div className="flex gap-2">
<Input
type="email"
value={newEmail}
onChange={(e) => {
setNewEmail(e.target.value)
setEmailError(null)
}}
onKeyDown={handleKeyDown}
placeholder="Enter email address"
aria-invalid={emailError ? "true" : "false"}
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={addRecipient}
className="shrink-0"
>
<Plus className="size-3" />
Add
</Button>
</div>
{emailError && (
<p className="text-xs text-destructive">{emailError}</p>
)}
</div>
)}
{errors.admin_notification_recipients && (
<p className="text-xs text-destructive">
{errors.admin_notification_recipients}
</p>
)}
</div>
</div>
</div>
{/* Billing Events */}
<div className="rounded-lg border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<span className="text-sm font-medium">Billing Events</span>
</div>
<div className="divide-y divide-border">
<NotificationToggle
id="notify-admin-new-subscription"
title="New subscription"
description="Email admins when a paid subscription becomes active for the first time after checkout."
checked={data.notify_admin_new_subscription}
onCheckedChange={(checked) =>
setData("notify_admin_new_subscription", checked)
}
/>
<NotificationToggle
id="notify-admin-account-cancellation"
title="Account cancellation"
description="Email admins when an account is scheduled for deletion from the settings flow."
checked={data.notify_admin_account_cancellation}
onCheckedChange={(checked) =>
setData("notify_admin_account_cancellation", checked)
}
/>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<Button
type="submit"
disabled={processing || (!isDirty && !hasDraftEmail)}
>
{processing ? "Saving..." : "Save changes"}
</Button>
</div>
</form>
</AdminLayout>
)
}
import { Head, Link } from "@inertiajs/react"
import { Bell, ChevronRight } from "lucide-react"
import AdminLayout from "@/layouts/admin-layout"
const settingsItems = [
{
title: "Notifications",
description:
"Configure email notification recipients and billing event alerts",
href: "/admin/settings/notifications",
icon: Bell,
iconBg: "bg-orange-100 dark:bg-orange-950",
iconColor: "text-orange-600 dark:text-orange-400",
},
]
export default function AdminSettings() {
return (
<AdminLayout>
<Head title="Settings" />
<div className="flex flex-col gap-4">
<h1 className="text-lg font-semibold">Settings</h1>
<div className="flex flex-col gap-3">
{settingsItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-muted/30"
>
<div
className={`flex size-10 shrink-0 items-center justify-center rounded-lg ${item.iconBg}`}
>
<item.icon className={`size-5 ${item.iconColor}`} />
</div>
<div className="flex-1">
<h3 className="text-sm font-medium">{item.title}</h3>
<p className="text-xs text-muted-foreground">
{item.description}
</p>
</div>
<ChevronRight className="size-4 text-muted-foreground" />
</Link>
))}
</div>
</div>
</AdminLayout>
)
}
import { useState } from "react"
import { Head, Link, router } from "@inertiajs/react"
import { AlertTriangle, Building2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
interface Account {
id: number
name: string
role: string
}
interface CancelledAccount {
membershipId: number
accountId: number
name: string
role: string
daysUntilDeletion: number
}
interface Props {
accounts: Account[]
cancelledAccounts: CancelledAccount[]
}
export default function MenusShow({ accounts, cancelledAccounts }: Props) {
const [reactivating, setReactivating] = useState<CancelledAccount | null>(
null
)
const [processing, setProcessing] = useState(false)
function handleReactivate() {
if (!reactivating || processing) return
setProcessing(true)
router.post(
"/app/account_reactivation",
{ membership_id: reactivating.membershipId },
{
onSuccess: () => setReactivating(null),
onFinish: () => setProcessing(false),
}
)
}
return (
<>
<Head title="Select Account" />
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="w-full max-w-md space-y-6 px-4">
<div className="text-center">
<h1 className="text-2xl font-semibold tracking-tight">
{accounts.length > 0 ? "Select an account" : "No active accounts"}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
{accounts.length > 0
? "Choose which account to open"
: "You can reactivate a cancelled account below"}
</p>
</div>
{accounts.length > 0 && (
<div className="space-y-2">
{accounts.map((account) => (
<Link
key={account.id}
href={`/app/${account.id}/dashboard`}
className="flex items-center gap-3 rounded-lg border p-4 transition-colors hover:bg-accent"
>
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10">
<Building2 className="size-5 text-primary" />
</div>
<div className="flex-1">
<div className="font-medium">{account.name}</div>
<div className="text-sm text-muted-foreground capitalize">
{account.role}
</div>
</div>
</Link>
))}
</div>
)}
{cancelledAccounts.length > 0 && (
<>
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<span className="text-xs font-medium text-muted-foreground">
Cancelled
</span>
<div className="h-px flex-1 bg-border" />
</div>
<div className="space-y-2">
{cancelledAccounts.map((account) => (
<div
key={account.accountId}
className="flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50/50 p-4 dark:border-amber-800/50 dark:bg-amber-950/20"
>
<div className="flex size-10 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/50">
<AlertTriangle className="size-5 text-amber-600 dark:text-amber-400" />
</div>
<div className="flex-1">
<div className="font-medium">{account.name}</div>
<div className="text-sm text-amber-600 dark:text-amber-400">
{account.daysUntilDeletion > 0
? `${account.daysUntilDeletion} days until deletion`
: "Deletion pending"}
</div>
</div>
{account.role === "owner" && (
<Button
size="sm"
variant="outline"
onClick={() => setReactivating(account)}
>
Reactivate
</Button>
)}
</div>
))}
</div>
</>
)}
</div>
</div>
<Dialog
open={reactivating !== null}
onOpenChange={(open) => !open && setReactivating(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Reactivate “{reactivating?.name}”?
</DialogTitle>
<DialogDescription>
This will cancel the scheduled deletion and restore full access to
your account immediately.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setReactivating(null)}
disabled={processing}
>
Cancel
</Button>
<Button onClick={handleReactivate} disabled={processing}>
{processing ? "Reactivating..." : "Reactivate account"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
import { useEffect } from "react"
import { Head, Link, router, usePage } from "@inertiajs/react"
import type { SharedProps } from "@/types"
import { CheckCircle2, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import AppLayout from "@/layouts/app-layout"
interface Props {
stripeSessionStatus: string | null
planName: string
subscriptionActive: boolean
}
export default function SubscriptionShow({
stripeSessionStatus,
planName,
subscriptionActive,
}: Props) {
const page = usePage<SharedProps>()
const basePath = page.url.replace(/\/subscription.*$/, "")
const billingPath = `${basePath}/billing`
const dashboardPath = basePath
const isActive = stripeSessionStatus === "paid" || subscriptionActive
useEffect(() => {
if (isActive || !page.url.includes("session_id=")) return
const timeoutId = window.setTimeout(() => {
router.visit(page.url, {
preserveScroll: true,
preserveState: true,
replace: true,
})
}, 3000)
return () => window.clearTimeout(timeoutId)
}, [isActive, page.url])
return (
<AppLayout>
<Head title={isActive ? "Subscription Active" : "Processing"} />
<div className="mx-auto max-w-md py-12">
<Card>
<CardContent className="flex flex-col items-center gap-4 pt-6 text-center">
{isActive ? (
<>
<CheckCircle2 className="size-12 text-green-500" />
<h1 className="text-2xl font-semibold">
Welcome to {planName}!
</h1>
<p className="text-muted-foreground">
Your subscription is now active. You have access to all Pro
features.
</p>
</>
) : (
<>
<Loader2 className="size-12 animate-spin text-muted-foreground" />
<h1 className="text-2xl font-semibold">Processing...</h1>
<p className="text-muted-foreground">
Your payment is still processing. We'll refresh this page
automatically.
</p>
</>
)}
{isActive ? (
<div className="mt-2 flex flex-col items-center gap-2">
<Button nativeButton={false} render={<Link href={dashboardPath} />}>
Start using Pro
</Button>
<Button
variant="link"
size="sm"
nativeButton={false}
className="text-muted-foreground"
render={<Link href={billingPath} />}
>
View billing details
</Button>
</div>
) : (
<Button nativeButton={false} className="mt-2" variant="outline" render={<Link href={billingPath} />}>
Go to Billing
</Button>
)}
</CardContent>
</Card>
</div>
</AppLayout>
)
}
# frozen_string_literal: true
class Account::SyncStripeCustomerEmailJob < ApplicationJob
queue_as :default
retry_on Stripe::StripeError, wait: :polynomially_longer
def perform(subscription)
subscription.sync_customer_email_to_stripe
end
end
# frozen_string_literal: true
class AccountMailer < ApplicationMailer
def cancellation(cancellation)
@cancellation = cancellation
@account = cancellation.account
@deletion_date = cancellation.created_at + Account::Incineratable::INCINERATION_GRACE_PERIOD
@sign_in_url = new_identity_session_url
mail(
to: cancellation.notification_email,
subject: "Your Enlead account was scheduled for deletion"
)
end
end
class ApplicationMailer < ActionMailer::Base
default from: ENV.fetch("DEVISE_MAILER_SENDER", "Enlead <noreply@enlead.app>")
layout "mailer"
end
# frozen_string_literal: true
class SubscriptionMailer < ApplicationMailer
def activated(subscription)
@subscription = subscription
@account = subscription.account
@plan = subscription.plan
@billing_url = app_billing_url(account_id: @account.external_account_id)
mail(
to: subscription.notification_email,
subject: "Your Enlead #{@plan.name} subscription is active"
)
end
end
# frozen_string_literal: true
class Account::BillingWaiver < ApplicationRecord
belongs_to :account
def subscription
@subscription ||= Account::Subscription.new(plan: Plan.yearly)
end
end
# frozen_string_literal: true
class Account::Cancellation < ApplicationRecord
self.table_name = "account_cancellations"
belongs_to :account
belongs_to :initiated_by, class_name: "User", optional: true
validates :account_id, uniqueness: true
def notification_email
initiated_by&.email || account.owner&.email
end
end
# frozen_string_literal: true
class Account::UsageOverride < ApplicationRecord
belongs_to :account
end