- Add CustomerBilling page for customers to view payment history and manage cards - Create AddPaymentMethodModal with Stripe Elements for secure card saving - Support both Stripe Connect and direct API payment modes - Auto-set first payment method as default when no default exists - Add dark mode support for Stripe card input styling - Add customer billing API endpoints for payment history and saved cards - Add stripe_customer_id field to User model for Stripe customer tracking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
/**
|
|
* Embedded Stripe Connect Onboarding Component
|
|
*
|
|
* Uses Stripe's Connect embedded components to provide a seamless
|
|
* onboarding experience without redirecting users away from the app.
|
|
*/
|
|
|
|
import React, { useState, useCallback } from 'react';
|
|
import {
|
|
ConnectComponentsProvider,
|
|
ConnectAccountOnboarding,
|
|
} from '@stripe/react-connect-js';
|
|
import { loadConnectAndInitialize } from '@stripe/connect-js';
|
|
import type { StripeConnectInstance } from '@stripe/connect-js';
|
|
import {
|
|
CheckCircle,
|
|
AlertCircle,
|
|
Loader2,
|
|
CreditCard,
|
|
Wallet,
|
|
Building2,
|
|
} from 'lucide-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { createAccountSession, refreshConnectStatus, ConnectAccountInfo } from '../api/payments';
|
|
|
|
interface ConnectOnboardingEmbedProps {
|
|
connectAccount: ConnectAccountInfo | null;
|
|
tier: string;
|
|
onComplete?: () => void;
|
|
onError?: (error: string) => void;
|
|
}
|
|
|
|
type LoadingState = 'idle' | 'loading' | 'ready' | 'error' | 'complete';
|
|
|
|
const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|
connectAccount,
|
|
tier,
|
|
onComplete,
|
|
onError,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const [stripeConnectInstance, setStripeConnectInstance] = useState<StripeConnectInstance | null>(null);
|
|
const [loadingState, setLoadingState] = useState<LoadingState>('idle');
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
const isActive = connectAccount?.status === 'active' && connectAccount?.charges_enabled;
|
|
|
|
// Initialize Stripe Connect
|
|
const initializeStripeConnect = useCallback(async () => {
|
|
if (loadingState === 'loading' || loadingState === 'ready') return;
|
|
|
|
setLoadingState('loading');
|
|
setErrorMessage(null);
|
|
|
|
try {
|
|
// Fetch account session from our backend
|
|
const response = await createAccountSession();
|
|
const { client_secret, publishable_key } = response.data;
|
|
|
|
// Initialize the Connect instance
|
|
const instance = await loadConnectAndInitialize({
|
|
publishableKey: publishable_key,
|
|
fetchClientSecret: async () => client_secret,
|
|
appearance: {
|
|
overlays: 'drawer',
|
|
variables: {
|
|
colorPrimary: '#635BFF',
|
|
colorBackground: '#ffffff',
|
|
colorText: '#1a1a1a',
|
|
colorDanger: '#df1b41',
|
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
fontSizeBase: '14px',
|
|
spacingUnit: '12px',
|
|
borderRadius: '8px',
|
|
},
|
|
},
|
|
});
|
|
|
|
setStripeConnectInstance(instance);
|
|
setLoadingState('ready');
|
|
} catch (err: any) {
|
|
console.error('Failed to initialize Stripe Connect:', err);
|
|
const message = err.response?.data?.error || err.message || t('payments.failedToInitializePayment');
|
|
setErrorMessage(message);
|
|
setLoadingState('error');
|
|
onError?.(message);
|
|
}
|
|
}, [loadingState, onError, t]);
|
|
|
|
// Handle onboarding completion
|
|
const handleOnboardingExit = useCallback(async () => {
|
|
// Refresh status from Stripe to sync the local database
|
|
try {
|
|
await refreshConnectStatus();
|
|
} catch (err) {
|
|
console.error('Failed to refresh Connect status:', err);
|
|
}
|
|
setLoadingState('complete');
|
|
onComplete?.();
|
|
}, [onComplete]);
|
|
|
|
// Handle errors from the Connect component
|
|
const handleLoadError = useCallback((loadError: { error: { message?: string }; elementTagName: string }) => {
|
|
console.error('Connect component load error:', loadError);
|
|
const message = loadError.error.message || t('payments.failedToLoadPaymentComponent');
|
|
setErrorMessage(message);
|
|
setLoadingState('error');
|
|
onError?.(message);
|
|
}, [onError, t]);
|
|
|
|
// Account type display
|
|
const getAccountTypeLabel = () => {
|
|
switch (connectAccount?.account_type) {
|
|
case 'standard':
|
|
return t('payments.standardConnect');
|
|
case 'express':
|
|
return t('payments.expressConnect');
|
|
case 'custom':
|
|
return t('payments.customConnect');
|
|
default:
|
|
return t('payments.connect');
|
|
}
|
|
};
|
|
|
|
// If account is already active, show status
|
|
if (isActive) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<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 dark:text-green-400 shrink-0 mt-0.5" size={20} />
|
|
<div className="flex-1">
|
|
<h4 className="font-medium text-green-800 dark:text-green-300">{t('payments.stripeConnected')}</h4>
|
|
<p className="text-sm text-green-700 dark:text-green-400 mt-1">
|
|
{t('payments.stripeConnectedDesc')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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">{t('payments.accountDetails')}</h4>
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600 dark:text-gray-400">{t('payments.accountType')}:</span>
|
|
<span className="text-gray-900 dark:text-white">{getAccountTypeLabel()}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600 dark:text-gray-400">{t('payments.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 dark:text-gray-400">{t('payments.charges')}:</span>
|
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
|
<CreditCard size={14} />
|
|
{t('payments.enabled')}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-gray-600 dark:text-gray-400">{t('payments.payouts')}:</span>
|
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
|
<Wallet size={14} />
|
|
{connectAccount.payouts_enabled ? t('payments.enabled') : t('payments.pending')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Completion state
|
|
if (loadingState === 'complete') {
|
|
return (
|
|
<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">{t('payments.onboardingComplete')}</h4>
|
|
<p className="text-sm text-green-700 dark:text-green-400 mt-2">
|
|
{t('payments.stripeSetupComplete')}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Error state
|
|
if (loadingState === 'error') {
|
|
return (
|
|
<div className="space-y-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 dark:text-red-400 shrink-0 mt-0.5" size={20} />
|
|
<div className="flex-1">
|
|
<h4 className="font-medium text-red-800 dark:text-red-300">{t('payments.setupFailed')}</h4>
|
|
<p className="text-sm text-red-700 dark:text-red-400 mt-1">{errorMessage}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setLoadingState('idle');
|
|
setErrorMessage(null);
|
|
}}
|
|
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"
|
|
>
|
|
{t('payments.tryAgain')}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Idle state - show start button
|
|
if (loadingState === 'idle') {
|
|
return (
|
|
<div className="space-y-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 dark:text-blue-400 shrink-0 mt-0.5" size={20} />
|
|
<div className="flex-1">
|
|
<h4 className="font-medium text-blue-800 dark:text-blue-300">{t('payments.setUpPayments')}</h4>
|
|
<p className="text-sm text-blue-700 dark:text-blue-400 mt-1">
|
|
{t('payments.tierPaymentDescriptionWithOnboarding', { tier })}
|
|
</p>
|
|
<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} />
|
|
{t('payments.securePaymentProcessing')}
|
|
</li>
|
|
<li className="flex items-center gap-2">
|
|
<CheckCircle size={14} />
|
|
{t('payments.automaticPayouts')}
|
|
</li>
|
|
<li className="flex items-center gap-2">
|
|
<CheckCircle size={14} />
|
|
{t('payments.pciCompliance')}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={initializeStripeConnect}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-[#635BFF] rounded-lg hover:bg-[#5851ea] transition-colors"
|
|
>
|
|
<CreditCard size={18} />
|
|
{t('payments.startPaymentSetup')}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Loading state
|
|
if (loadingState === 'loading') {
|
|
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 dark:text-gray-400">{t('payments.initializingPaymentSetup')}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Ready state - show embedded onboarding
|
|
if (loadingState === 'ready' && stripeConnectInstance) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<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">{t('payments.completeAccountSetup')}</h4>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
{t('payments.fillOutInfoForPayment')}
|
|
</p>
|
|
</div>
|
|
|
|
<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}
|
|
onLoadError={handleLoadError}
|
|
/>
|
|
</ConnectComponentsProvider>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
export default ConnectOnboardingEmbed;
|