feat: Stripe subscriptions, tier-based permissions, dark mode, and UX improvements

- Fix Stripe SDK v14 compatibility (bracket notation for subscription items)
- Fix subscription period dates from subscription items instead of subscription object
- Add tier-based permissions (can_accept_payments, etc.) on tenant signup
- Add stripe_customer_id field to Tenant model
- Add clickable logo on login page (navigates to /)
- Add database setup message during signup wizard
- Add dark mode support for payment settings and Connect onboarding
- Add subscription management endpoints (cancel, reactivate)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-02 20:50:18 -05:00
parent 08b51d1a5f
commit ef58e9fc94
12 changed files with 1337 additions and 167 deletions

View File

@@ -48,6 +48,8 @@ export interface ConnectAccountInfo {
export interface PaymentConfig {
payment_mode: PaymentMode;
tier: string;
tier_allows_payments: boolean;
stripe_configured: boolean;
can_accept_payments: boolean;
api_keys: ApiKeysInfo | null;
connect_account: ConnectAccountInfo | null;
@@ -431,3 +433,114 @@ export const getTransactionDetail = (id: number) =>
*/
export const refundTransaction = (transactionId: number, request?: RefundRequest) =>
apiClient.post<RefundResponse>(`/payments/transactions/${transactionId}/refund/`, request || {});
// ============================================================================
// Subscription Plans & Add-ons
// ============================================================================
export interface SubscriptionPlan {
id: number;
name: string;
description: string;
plan_type: 'base' | 'addon';
business_tier: string;
price_monthly: number | null;
price_yearly: number | null;
features: string[];
permissions: Record<string, boolean>;
limits: Record<string, number>;
transaction_fee_percent: number;
transaction_fee_fixed: number;
is_most_popular: boolean;
show_price: boolean;
stripe_price_id: string;
}
export interface SubscriptionPlansResponse {
current_tier: string;
current_plan: SubscriptionPlan | null;
plans: SubscriptionPlan[];
addons: SubscriptionPlan[];
}
export interface CheckoutResponse {
checkout_url: string;
session_id: string;
}
/**
* Get available subscription plans and add-ons.
*/
export const getSubscriptionPlans = () =>
apiClient.get<SubscriptionPlansResponse>('/payments/plans/');
/**
* Create a checkout session for upgrading or purchasing add-ons.
*/
export const createCheckoutSession = (planId: number, billingPeriod: 'monthly' | 'yearly' = 'monthly') =>
apiClient.post<CheckoutResponse>('/payments/checkout/', {
plan_id: planId,
billing_period: billingPeriod,
});
// ============================================================================
// Active Subscriptions
// ============================================================================
export interface ActiveSubscription {
id: string;
plan_name: string;
plan_type: 'base' | 'addon';
status: 'active' | 'past_due' | 'canceled' | 'incomplete' | 'trialing';
current_period_start: string;
current_period_end: string;
cancel_at_period_end: boolean;
cancel_at: string | null;
canceled_at: string | null;
amount: number;
amount_display: string;
interval: 'month' | 'year';
stripe_subscription_id: string;
}
export interface SubscriptionsResponse {
subscriptions: ActiveSubscription[];
has_active_subscription: boolean;
}
export interface CancelSubscriptionResponse {
success: boolean;
message: string;
cancel_at_period_end: boolean;
current_period_end: string;
}
export interface ReactivateSubscriptionResponse {
success: boolean;
message: string;
}
/**
* Get active subscriptions for the current tenant.
*/
export const getSubscriptions = () =>
apiClient.get<SubscriptionsResponse>('/payments/subscriptions/');
/**
* Cancel a subscription.
* @param subscriptionId - Stripe subscription ID
* @param immediate - If true, cancel immediately. If false, cancel at period end.
*/
export const cancelSubscription = (subscriptionId: string, immediate: boolean = false) =>
apiClient.post<CancelSubscriptionResponse>('/payments/subscriptions/cancel/', {
subscription_id: subscriptionId,
immediate,
});
/**
* Reactivate a subscription that was set to cancel at period end.
*/
export const reactivateSubscription = (subscriptionId: string) =>
apiClient.post<ReactivateSubscriptionResponse>('/payments/subscriptions/reactivate/', {
subscription_id: subscriptionId,
});

View File

@@ -124,41 +124,41 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
if (isActive) {
return (
<div className="space-y-6">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} />
<CheckCircle className="text-green-600 dark:text-green-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-green-800">Stripe Connected</h4>
<p className="text-sm text-green-700 mt-1">
<h4 className="font-medium text-green-800 dark:text-green-300">Stripe Connected</h4>
<p className="text-sm text-green-700 dark:text-green-400 mt-1">
Your Stripe account is connected and ready to accept payments.
</p>
</div>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Account Details</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Account Type:</span>
<span className="text-gray-900">{getAccountTypeLabel()}</span>
<span className="text-gray-600 dark:text-gray-400">Account Type:</span>
<span className="text-gray-900 dark:text-white">{getAccountTypeLabel()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Status:</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">
<span className="text-gray-600 dark:text-gray-400">Status:</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300">
{connectAccount.status}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Charges:</span>
<span className="flex items-center gap-1 text-green-600">
<span className="text-gray-600 dark:text-gray-400">Charges:</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CreditCard size={14} />
Enabled
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Payouts:</span>
<span className="flex items-center gap-1 text-green-600">
<span className="text-gray-600 dark:text-gray-400">Payouts:</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Wallet size={14} />
{connectAccount.payouts_enabled ? 'Enabled' : 'Pending'}
</span>
@@ -172,10 +172,10 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
// Completion state
if (loadingState === 'complete') {
return (
<div className="bg-green-50 border border-green-200 rounded-lg p-6 text-center">
<CheckCircle className="mx-auto text-green-600 mb-3" size={48} />
<h4 className="font-medium text-green-800 text-lg">Onboarding Complete!</h4>
<p className="text-sm text-green-700 mt-2">
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6 text-center">
<CheckCircle className="mx-auto text-green-600 dark:text-green-400 mb-3" size={48} />
<h4 className="font-medium text-green-800 dark:text-green-300 text-lg">Onboarding Complete!</h4>
<p className="text-sm text-green-700 dark:text-green-400 mt-2">
Your Stripe account has been set up. You can now accept payments.
</p>
</div>
@@ -186,12 +186,12 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
if (loadingState === 'error') {
return (
<div className="space-y-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="text-red-600 shrink-0 mt-0.5" size={20} />
<AlertCircle className="text-red-600 dark:text-red-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-red-800">Setup Failed</h4>
<p className="text-sm text-red-700 mt-1">{errorMessage}</p>
<h4 className="font-medium text-red-800 dark:text-red-300">Setup Failed</h4>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">{errorMessage}</p>
</div>
</div>
</div>
@@ -200,7 +200,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
setLoadingState('idle');
setErrorMessage(null);
}}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
>
Try Again
</button>
@@ -212,16 +212,16 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
if (loadingState === 'idle') {
return (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<Building2 className="text-blue-600 shrink-0 mt-0.5" size={20} />
<Building2 className="text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-blue-800">Set Up Payments</h4>
<p className="text-sm text-blue-700 mt-1">
<h4 className="font-medium text-blue-800 dark:text-blue-300">Set Up Payments</h4>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-1">
As a {tier} tier business, you'll use Stripe Connect to accept payments.
Complete the onboarding process to start accepting payments from your customers.
</p>
<ul className="mt-3 space-y-1 text-sm text-blue-700">
<ul className="mt-3 space-y-1 text-sm text-blue-700 dark:text-blue-400">
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Secure payment processing
@@ -255,7 +255,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
return (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="animate-spin text-[#635BFF] mb-4" size={40} />
<p className="text-gray-600">Initializing payment setup...</p>
<p className="text-gray-600 dark:text-gray-400">Initializing payment setup...</p>
</div>
);
}
@@ -264,15 +264,15 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
if (loadingState === 'ready' && stripeConnectInstance) {
return (
<div className="space-y-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-2">Complete Your Account Setup</h4>
<p className="text-sm text-gray-600">
<div className="bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Complete Your Account Setup</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Fill out the information below to finish setting up your payment account.
Your information is securely handled by Stripe.
</p>
</div>
<div className="border border-gray-200 rounded-lg overflow-hidden bg-white p-4">
<div className="border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-800 p-4">
<ConnectComponentsProvider connectInstance={stripeConnectInstance}>
<ConnectAccountOnboarding
onExit={handleOnboardingExit}

View File

@@ -5,6 +5,7 @@
*/
import React from 'react';
import { useNavigate } from 'react-router-dom';
import {
CreditCard,
CheckCircle,
@@ -12,6 +13,8 @@ import {
Loader2,
FlaskConical,
Zap,
ArrowUpRight,
Sparkles,
} from 'lucide-react';
import { Business } from '../types';
import { usePaymentConfig } from '../hooks/usePayments';
@@ -25,6 +28,7 @@ interface PaymentSettingsSectionProps {
type PaymentModeType = 'direct_api' | 'connect' | 'none';
const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ business }) => {
const navigate = useNavigate();
const { data: config, isLoading, error, refetch } = usePaymentConfig();
if (isLoading) {
@@ -56,6 +60,8 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
}
const paymentMode = (config?.payment_mode || 'none') as PaymentModeType;
const tierAllowsPayments = config?.tier_allows_payments || false;
const stripeConfigured = config?.stripe_configured || false;
const canAcceptPayments = config?.can_accept_payments || false;
const tier = config?.tier || business.plan || 'Free';
const isFreeTier = tier === 'Free';
@@ -72,16 +78,24 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
// Status badge component
const StatusBadge = () => {
if (!tierAllowsPayments) {
return (
<span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-full">
<AlertCircle size={12} />
Upgrade Required
</span>
);
}
if (canAcceptPayments) {
return (
<span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">
<span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 rounded-full">
<CheckCircle size={12} />
Ready
</span>
);
}
return (
<span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">
<span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 rounded-full">
<AlertCircle size={12} />
Setup Required
</span>
@@ -97,17 +111,17 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
};
return (
<div className="bg-white rounded-lg shadow">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
{/* Header */}
<div className="p-6 border-b border-gray-200">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-lg">
<CreditCard className="text-purple-600" size={24} />
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<CreditCard className="text-purple-600 dark:text-purple-400" size={24} />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Payment Configuration</h2>
<p className="text-sm text-gray-500">{getModeDescription()}</p>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Payment Configuration</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">{getModeDescription()}</p>
</div>
</div>
<StatusBadge />
@@ -162,22 +176,22 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
{/* Content */}
<div className="p-6">
{/* Tier info banner */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-gray-600">Current Plan:</span>
<span className="text-sm text-gray-600 dark:text-gray-400">Current Plan:</span>
<span className={`ml-2 px-2 py-0.5 text-xs font-semibold rounded-full ${
tier === 'Enterprise' ? 'bg-purple-100 text-purple-800' :
tier === 'Business' ? 'bg-blue-100 text-blue-800' :
tier === 'Professional' ? 'bg-green-100 text-green-800' :
'bg-gray-100 text-gray-800'
tier === 'Enterprise' ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300' :
tier === 'Business' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300' :
tier === 'Professional' ? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300' :
'bg-gray-100 dark:bg-gray-600 text-gray-800 dark:text-gray-200'
}`}>
{tier}
</span>
</div>
<div className="text-sm text-gray-600">
<div className="text-sm text-gray-600 dark:text-gray-400">
Payment Mode:{' '}
<span className="font-medium text-gray-900">
<span className="font-medium text-gray-900 dark:text-white">
{paymentMode === 'direct_api' ? 'Direct API Keys' :
paymentMode === 'connect' ? 'Stripe Connect' :
'Not Configured'}
@@ -186,31 +200,86 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
</div>
</div>
{/* Tier-specific content */}
{isFreeTier ? (
<StripeApiKeysForm
apiKeys={config?.api_keys || null}
onSuccess={() => refetch()}
/>
) : (
<ConnectOnboardingEmbed
connectAccount={config?.connect_account || null}
tier={tier}
onComplete={() => refetch()}
/>
)}
{/* Upgrade notice for free tier with deprecated keys */}
{isFreeTier && config?.api_keys?.status === 'deprecated' && (
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="font-medium text-blue-800 mb-1">
Upgraded to a Paid Plan?
</h4>
<p className="text-sm text-blue-700">
If you've recently upgraded, your API keys have been deprecated.
Please contact support to complete your Stripe Connect setup.
</p>
{/* Upgrade prompt when tier doesn't allow payments */}
{!tierAllowsPayments ? (
<div className="bg-gradient-to-br from-purple-50 to-indigo-50 border border-purple-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="p-3 bg-purple-100 rounded-xl">
<Sparkles className="text-purple-600" size={28} />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Unlock Online Payments
</h3>
<p className="text-gray-600 mb-4">
Your current plan doesn't include online payment processing. Upgrade your subscription
or add the Online Payments add-on to start accepting payments from your customers.
</p>
<ul className="space-y-2 mb-6 text-sm text-gray-600">
<li className="flex items-center gap-2">
<CheckCircle size={16} className="text-green-500" />
Accept credit cards, debit cards, and digital wallets
</li>
<li className="flex items-center gap-2">
<CheckCircle size={16} className="text-green-500" />
Automatic invoicing and payment reminders
</li>
<li className="flex items-center gap-2">
<CheckCircle size={16} className="text-green-500" />
Secure PCI-compliant payment processing
</li>
<li className="flex items-center gap-2">
<CheckCircle size={16} className="text-green-500" />
Detailed transaction history and analytics
</li>
</ul>
<div className="flex flex-wrap gap-3">
<button
onClick={() => navigate('/settings/billing')}
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
>
<ArrowUpRight size={16} />
View Upgrade Options
</button>
<a
href="mailto:support@smoothschedule.com?subject=Online Payments Add-on"
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-purple-700 bg-purple-100 rounded-lg hover:bg-purple-200 transition-colors"
>
Contact Sales
</a>
</div>
</div>
</div>
</div>
) : (
<>
{/* Tier-specific content */}
{isFreeTier ? (
<StripeApiKeysForm
apiKeys={config?.api_keys || null}
onSuccess={() => refetch()}
/>
) : (
<ConnectOnboardingEmbed
connectAccount={config?.connect_account || null}
tier={tier}
onComplete={() => refetch()}
/>
)}
{/* Upgrade notice for free tier with deprecated keys */}
{isFreeTier && config?.api_keys?.status === 'deprecated' && (
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="font-medium text-blue-800 mb-1">
Upgraded to a Paid Plan?
</h4>
<p className="text-sm text-blue-700">
If you've recently upgraded, your API keys have been deprecated.
Please contact support to complete your Stripe Connect setup.
</p>
</div>
)}
</>
)}
</div>
</div>

View File

@@ -911,6 +911,7 @@
"back": "Back",
"next": "Next",
"creating": "Creating account...",
"creatingNote": "We're setting up your database. This may take up to a minute.",
"createAccount": "Create Account",
"haveAccount": "Already have an account?",
"signIn": "Sign in"

View File

@@ -6,7 +6,7 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLogin } from '../hooks/useAuth';
import { useNavigate } from 'react-router-dom';
import { useNavigate, Link } from 'react-router-dom';
import SmoothScheduleLogo from '../components/SmoothScheduleLogo';
import OAuthButtons from '../components/OAuthButtons';
import LanguageSelector from '../components/LanguageSelector';
@@ -141,10 +141,10 @@ const LoginPage: React.FC = () => {
<div className="relative z-10 flex flex-col justify-between w-full p-12">
<div>
<div className="flex items-center gap-3 text-white/90">
<Link to="/" className="flex items-center gap-3 text-white/90 hover:text-white transition-colors">
<SmoothScheduleLogo className="w-8 h-8 text-brand-500" />
<span className="font-bold text-xl tracking-tight">Smooth Schedule</span>
</div>
</Link>
</div>
<div className="space-y-6 max-w-md">
@@ -171,9 +171,9 @@ const LoginPage: React.FC = () => {
<div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8 lg:w-1/2 xl:px-24 bg-gray-50 dark:bg-gray-900">
<div className="mx-auto w-full max-w-sm lg:max-w-md">
<div className="text-center lg:text-left mb-10">
<div className="lg:hidden flex justify-center mb-6">
<Link to="/" className="lg:hidden flex justify-center mb-6">
<SmoothScheduleLogo className="w-12 h-12 text-brand-600" />
</div>
</Link>
<h2 className="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">
{t('auth.welcomeBack')}
</h2>

View File

@@ -914,24 +914,31 @@ const SignupPage: React.FC = () => {
<ArrowRight className="w-4 h-4" />
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
className="flex items-center gap-2 px-6 py-3 bg-brand-600 text-white rounded-xl hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('marketing.signup.creating')}
</>
) : (
<>
<CheckCircle className="w-4 h-4" />
{t('marketing.signup.createAccount')}
</>
<div className="flex flex-col items-end gap-2">
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
className="flex items-center gap-2 px-6 py-3 bg-brand-600 text-white rounded-xl hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('marketing.signup.creating')}
</>
) : (
<>
<CheckCircle className="w-4 h-4" />
{t('marketing.signup.createAccount')}
</>
)}
</button>
{isSubmitting && (
<p className="text-sm text-gray-500 dark:text-gray-400 text-right">
{t('marketing.signup.creatingNote')}
</p>
)}
</button>
</div>
)}
</div>
</div>

View File

@@ -1,41 +1,28 @@
/**
* Billing Settings Page
*
* Manage subscription plan, payment methods, and view invoices.
* Manage subscription plan, add-ons, payment methods, and view invoices.
*/
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { useOutletContext, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import {
CreditCard, Crown, Plus, Trash2, Check, AlertCircle,
FileText, ExternalLink, Wallet, Star
FileText, ExternalLink, Wallet, Star, Loader2, Sparkles,
ArrowRight, Package, Zap, X, RotateCcw, Calendar
} from 'lucide-react';
import { Business, User } from '../../types';
// Plan details for display
const planDetails: Record<string, { name: string; price: string; features: string[] }> = {
Free: {
name: 'Free',
price: '$0/month',
features: ['Up to 10 resources', 'Basic scheduling', 'Email support'],
},
Starter: {
name: 'Starter',
price: '$29/month',
features: ['Up to 50 resources', 'Custom branding', 'Priority email support', 'API access'],
},
Professional: {
name: 'Professional',
price: '$79/month',
features: ['Unlimited resources', 'Custom domains', 'Phone support', 'Advanced analytics', 'Team permissions'],
},
Enterprise: {
name: 'Enterprise',
price: 'Custom',
features: ['All Professional features', 'Dedicated account manager', 'Custom integrations', 'SLA guarantee'],
},
};
import {
getSubscriptionPlans,
createCheckoutSession,
getSubscriptions,
cancelSubscription,
reactivateSubscription,
SubscriptionPlan,
ActiveSubscription,
} from '../../api/payments';
const BillingSettings: React.FC = () => {
const { t } = useTranslation();
@@ -43,16 +30,111 @@ const BillingSettings: React.FC = () => {
business: Business;
user: User;
}>();
const [searchParams] = useSearchParams();
const [showAddCard, setShowAddCard] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<SubscriptionPlan | null>(null);
const [cancellingSubscription, setCancellingSubscription] = useState<ActiveSubscription | null>(null);
const isOwner = user.role === 'owner';
// Check for checkout success/cancel
const checkoutStatus = searchParams.get('checkout');
const [showCheckoutMessage, setShowCheckoutMessage] = useState(!!checkoutStatus);
useEffect(() => {
if (checkoutStatus) {
// Clear the URL params after showing message
const timer = setTimeout(() => {
setShowCheckoutMessage(false);
window.history.replaceState({}, '', '/settings/billing');
}, 5000);
return () => clearTimeout(timer);
}
}, [checkoutStatus]);
// Fetch subscription plans
const { data: plansData, isLoading: plansLoading } = useQuery({
queryKey: ['subscriptionPlans'],
queryFn: async () => {
const response = await getSubscriptionPlans();
return response.data;
},
});
// Checkout mutation
const checkoutMutation = useMutation({
mutationFn: async (plan: SubscriptionPlan) => {
const response = await createCheckoutSession(plan.id);
return response.data;
},
onSuccess: (data) => {
// Redirect to Stripe Checkout
window.location.href = data.checkout_url;
},
});
// Fetch active subscriptions
const { data: subscriptionsData, isLoading: subscriptionsLoading, refetch: refetchSubscriptions } = useQuery({
queryKey: ['subscriptions'],
queryFn: async () => {
const response = await getSubscriptions();
return response.data;
},
});
// Cancel subscription mutation
const cancelMutation = useMutation({
mutationFn: async ({ subscriptionId, immediate }: { subscriptionId: string; immediate: boolean }) => {
const response = await cancelSubscription(subscriptionId, immediate);
return response.data;
},
onSuccess: () => {
setCancellingSubscription(null);
refetchSubscriptions();
},
});
// Reactivate subscription mutation
const reactivateMutation = useMutation({
mutationFn: async (subscriptionId: string) => {
const response = await reactivateSubscription(subscriptionId);
return response.data;
},
onSuccess: () => {
refetchSubscriptions();
},
});
// Mock payment methods - in a real app, these would come from Stripe
const [paymentMethods] = useState([
{ id: 'pm_1', brand: 'visa', last4: '4242', expMonth: 12, expYear: 2025, isDefault: true },
]);
const currentPlan = planDetails[business.plan || 'Free'] || planDetails.Free;
const handleUpgrade = (plan: SubscriptionPlan) => {
setSelectedPlan(plan);
if (plan.stripe_price_id) {
checkoutMutation.mutate(plan);
} else {
// Plan not configured for purchase yet
alert('This plan is not available for purchase yet. Please contact support.');
}
};
const formatPrice = (price: number | null) => {
if (price === null) return 'Contact Us';
return `$${price}/mo`;
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const activeSubscriptions = subscriptionsData?.subscriptions || [];
if (!isOwner) {
return (
@@ -64,8 +146,34 @@ const BillingSettings: React.FC = () => {
);
}
const currentTier = plansData?.current_tier || business.plan || 'Free';
const currentPlan = plansData?.current_plan;
const availablePlans = plansData?.plans || [];
const availableAddons = plansData?.addons || [];
return (
<div className="space-y-6">
{/* Checkout Status Message */}
{showCheckoutMessage && (
<div className={`p-4 rounded-lg ${
checkoutStatus === 'success'
? 'bg-green-50 border border-green-200 text-green-800'
: 'bg-yellow-50 border border-yellow-200 text-yellow-800'
}`}>
{checkoutStatus === 'success' ? (
<div className="flex items-center gap-2">
<Check size={20} />
<span>Your subscription has been updated! Changes may take a few minutes to apply.</span>
</div>
) : (
<div className="flex items-center gap-2">
<AlertCircle size={20} />
<span>Checkout was cancelled. No changes were made to your subscription.</span>
</div>
)}
</div>
)}
{/* Header */}
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
@@ -73,7 +181,7 @@ const BillingSettings: React.FC = () => {
{t('settings.billing.title', 'Plan & Billing')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Manage your subscription, payment methods, and billing history.
Manage your subscription, add-ons, payment methods, and billing history.
</p>
</div>
@@ -88,29 +196,177 @@ const BillingSettings: React.FC = () => {
<div className="mt-4">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-gray-900 dark:text-white">
{currentPlan.name}
</span>
<span className="text-gray-500 dark:text-gray-400">
{currentPlan.price}
{currentTier}
</span>
{currentPlan && (
<span className="text-gray-500 dark:text-gray-400">
{formatPrice(currentPlan.price_monthly)}
</span>
)}
</div>
<ul className="mt-4 space-y-2">
{currentPlan.features.map((feature, idx) => (
<li key={idx} className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<Check size={16} className="text-green-500" />
{feature}
</li>
))}
</ul>
{currentPlan?.features && currentPlan.features.length > 0 && (
<ul className="mt-4 space-y-2">
{currentPlan.features.slice(0, 5).map((feature, idx) => (
<li key={idx} className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<Check size={16} className="text-green-500" />
{feature}
</li>
))}
</ul>
)}
</div>
</div>
<button className="px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all flex items-center gap-2">
<button
onClick={() => setShowUpgradeModal(true)}
className="px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all flex items-center gap-2"
>
<Crown size={16} />
Upgrade Plan
{currentTier === 'Enterprise' ? 'Manage Plan' : 'Upgrade Plan'}
</button>
</div>
</section>
{/* Active Subscriptions */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-4">
<Calendar size={20} className="text-blue-500" />
Active Subscriptions
</h3>
{subscriptionsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="animate-spin text-gray-400" size={24} />
</div>
) : activeSubscriptions.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<Calendar size={40} className="mx-auto mb-2 opacity-30" />
<p>No active subscriptions.</p>
<p className="text-sm mt-1">Subscribe to a plan to get started.</p>
</div>
) : (
<div className="space-y-4">
{activeSubscriptions.map((subscription) => (
<div
key={subscription.id}
className={`border rounded-lg p-4 ${
subscription.cancel_at_period_end
? 'border-yellow-300 bg-yellow-50 dark:border-yellow-700 dark:bg-yellow-900/20'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900 dark:text-white">
{subscription.plan_name}
</h4>
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${
subscription.plan_type === 'addon'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
}`}>
{subscription.plan_type === 'addon' ? 'Add-on' : 'Plan'}
</span>
{subscription.cancel_at_period_end && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">
Cancelling
</span>
)}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400 space-y-1">
<p>
<span className="font-medium">{subscription.amount_display}</span>
<span className="text-gray-500">/{subscription.interval}</span>
</p>
<p>
{subscription.cancel_at_period_end ? (
<>Cancels on {formatDate(subscription.current_period_end)}</>
) : (
<>Next billing: {formatDate(subscription.current_period_end)}</>
)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{subscription.cancel_at_period_end ? (
<button
onClick={() => reactivateMutation.mutate(subscription.stripe_subscription_id)}
disabled={reactivateMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-green-600 border border-green-600 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors disabled:opacity-50"
>
{reactivateMutation.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RotateCcw size={14} />
)}
Reactivate
</button>
) : (
<button
onClick={() => setCancellingSubscription(subscription)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-red-600 border border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<X size={14} />
Cancel
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</section>
{/* Available Add-ons */}
{availableAddons.length > 0 && (
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-4">
<Package size={20} className="text-purple-500" />
Available Add-ons
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Enhance your plan with additional features
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{availableAddons.map((addon) => (
<div
key={addon.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-purple-300 dark:hover:border-purple-700 transition-colors"
>
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="font-medium text-gray-900 dark:text-white">{addon.name}</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{addon.description || 'Enhance your subscription'}
</p>
</div>
<Sparkles size={20} className="text-purple-500" />
</div>
<div className="flex items-center justify-between mt-4">
<span className="text-lg font-semibold text-gray-900 dark:text-white">
{formatPrice(addon.price_monthly)}
</span>
<button
onClick={() => handleUpgrade(addon)}
disabled={checkoutMutation.isPending}
className="px-3 py-1.5 text-sm font-medium text-purple-600 border border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-colors disabled:opacity-50"
>
{checkoutMutation.isPending && selectedPlan?.id === addon.id ? (
<Loader2 size={16} className="animate-spin" />
) : (
'Add'
)}
</button>
</div>
</div>
))}
</div>
</section>
)}
{/* Wallet / Credits Summary */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-4">
@@ -235,24 +491,140 @@ const BillingSettings: React.FC = () => {
</div>
</section>
{/* Notice for Free Plan */}
{business.plan === 'Free' && (
<section className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 p-6 rounded-xl border border-amber-200 dark:border-amber-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
<AlertCircle size={24} className="text-amber-600 dark:text-amber-400" />
{/* Upgrade Modal */}
{showUpgradeModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Crown className="text-amber-500" />
Choose Your Plan
</h3>
<button
onClick={() => setShowUpgradeModal(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
×
</button>
</div>
</div>
<div>
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">You're on the Free Plan</h4>
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
Upgrade to unlock custom domains, advanced features, and priority support.
</p>
<button className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all">
<Crown size={16} /> View Plans
<div className="p-6">
{plansLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin text-gray-400" size={32} />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{availablePlans.map((plan) => {
const isCurrentPlan = plan.business_tier === currentTier;
const isUpgrade = (plan.price_monthly || 0) > (currentPlan?.price_monthly || 0);
return (
<div
key={plan.id}
className={`relative border rounded-xl p-5 ${
plan.is_most_popular
? 'border-purple-500 ring-2 ring-purple-500/20'
: 'border-gray-200 dark:border-gray-700'
} ${isCurrentPlan ? 'bg-gray-50 dark:bg-gray-900/50' : ''}`}
>
{plan.is_most_popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="px-3 py-1 text-xs font-semibold text-white bg-purple-500 rounded-full">
Most Popular
</span>
</div>
)}
<div className="text-center mb-4">
<h4 className="text-lg font-semibold text-gray-900 dark:text-white">
{plan.name}
</h4>
<div className="mt-2">
{plan.show_price ? (
<>
<span className="text-3xl font-bold text-gray-900 dark:text-white">
${plan.price_monthly || 0}
</span>
<span className="text-gray-500 dark:text-gray-400">/mo</span>
</>
) : (
<span className="text-xl font-semibold text-gray-900 dark:text-white">
Contact Us
</span>
)}
</div>
</div>
<ul className="space-y-2 mb-6">
{(plan.features || []).slice(0, 6).map((feature, idx) => (
<li key={idx} className="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
<Check size={16} className="text-green-500 shrink-0 mt-0.5" />
{feature}
</li>
))}
</ul>
<button
onClick={() => {
if (!isCurrentPlan) {
handleUpgrade(plan);
}
}}
disabled={isCurrentPlan || checkoutMutation.isPending}
className={`w-full py-2.5 rounded-lg font-medium transition-colors flex items-center justify-center gap-2 ${
isCurrentPlan
? 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
: plan.is_most_popular
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100'
}`}
>
{checkoutMutation.isPending && selectedPlan?.id === plan.id ? (
<Loader2 size={18} className="animate-spin" />
) : isCurrentPlan ? (
'Current Plan'
) : isUpgrade ? (
<>
Upgrade <ArrowRight size={16} />
</>
) : (
'Select'
)}
</button>
</div>
);
})}
</div>
)}
{/* Transaction Fee Notice */}
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div className="flex items-start gap-3">
<Zap className="text-blue-600 shrink-0 mt-0.5" size={20} />
<div>
<h4 className="font-medium text-blue-800 dark:text-blue-300">Transaction Fees</h4>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-1">
Higher tier plans include lower transaction fees on payment processing.
Enterprise plans include custom pricing - contact us for details.
</p>
</div>
</div>
</div>
</div>
<div className="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button
onClick={() => setShowUpgradeModal(false)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Close
</button>
</div>
</div>
</section>
</div>
)}
{/* Add Card Modal Placeholder */}
@@ -281,6 +653,85 @@ const BillingSettings: React.FC = () => {
</div>
</div>
)}
{/* Cancel Subscription Confirmation Modal */}
{cancellingSubscription && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-full">
<AlertCircle className="text-red-600 dark:text-red-400" size={24} />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Cancel Subscription
</h3>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Are you sure you want to cancel <strong>{cancellingSubscription.plan_name}</strong>?
</p>
<div className="space-y-3 mb-6">
<div className="p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
<p className="text-sm text-gray-700 dark:text-gray-300">
<strong>Cancel at period end:</strong> Your subscription will remain active until{' '}
<strong>{formatDate(cancellingSubscription.current_period_end)}</strong>, then it will be cancelled.
You can reactivate at any time before that date.
</p>
</div>
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-sm text-red-700 dark:text-red-300">
<strong>Cancel immediately:</strong> Your subscription will be cancelled right now and you may lose access to features immediately.
</p>
</div>
</div>
{cancelMutation.isError && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-700 dark:text-red-300">
Failed to cancel subscription. Please try again.
</div>
)}
<div className="flex flex-col gap-2">
<button
onClick={() => cancelMutation.mutate({
subscriptionId: cancellingSubscription.stripe_subscription_id,
immediate: false,
})}
disabled={cancelMutation.isPending}
className="w-full px-4 py-2.5 text-sm font-medium text-yellow-700 bg-yellow-100 rounded-lg hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-300 dark:hover:bg-yellow-900/50 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{cancelMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
'Cancel at Period End'
)}
</button>
<button
onClick={() => cancelMutation.mutate({
subscriptionId: cancellingSubscription.stripe_subscription_id,
immediate: true,
})}
disabled={cancelMutation.isPending}
className="w-full px-4 py-2.5 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{cancelMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
'Cancel Immediately'
)}
</button>
<button
onClick={() => setCancellingSubscription(null)}
disabled={cancelMutation.isPending}
className="w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Keep Subscription
</button>
</div>
</div>
</div>
)}
</div>
);
};