This commit includes: - Django backend with multi-tenancy (django-tenants) - React + TypeScript frontend with Vite - Platform administration API with role-based access control - Authentication system with token-based auth - Quick login dev tools for testing different user roles - CORS and CSRF configuration for local development - Docker development environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
/**
|
|
* Onboarding Wizard Component
|
|
* Multi-step wizard for paid-tier businesses to complete post-signup setup
|
|
* Step 1: Welcome/Overview
|
|
* Step 2: Stripe Connect setup (embedded)
|
|
* Step 3: Completion
|
|
*/
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
CheckCircle,
|
|
CreditCard,
|
|
Rocket,
|
|
ArrowRight,
|
|
Sparkles,
|
|
Loader2,
|
|
X,
|
|
AlertCircle,
|
|
} from 'lucide-react';
|
|
import { Business } from '../types';
|
|
import { usePaymentConfig } from '../hooks/usePayments';
|
|
import { useUpdateBusiness } from '../hooks/useBusiness';
|
|
import ConnectOnboardingEmbed from './ConnectOnboardingEmbed';
|
|
|
|
interface OnboardingWizardProps {
|
|
business: Business;
|
|
onComplete: () => void;
|
|
onSkip?: () => void;
|
|
}
|
|
|
|
type OnboardingStep = 'welcome' | 'stripe' | 'complete';
|
|
|
|
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
|
business,
|
|
onComplete,
|
|
onSkip,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [currentStep, setCurrentStep] = useState<OnboardingStep>('welcome');
|
|
|
|
const { data: paymentConfig, isLoading: configLoading, refetch: refetchConfig } = usePaymentConfig();
|
|
const updateBusinessMutation = useUpdateBusiness();
|
|
|
|
// Check if Stripe Connect is complete
|
|
const isStripeConnected = paymentConfig?.connect_account?.status === 'active' &&
|
|
paymentConfig?.connect_account?.charges_enabled;
|
|
|
|
// Handle return from Stripe Connect (for fallback redirect flow)
|
|
useEffect(() => {
|
|
const connectStatus = searchParams.get('connect');
|
|
if (connectStatus === 'complete' || connectStatus === 'refresh') {
|
|
// User returned from Stripe, refresh the config
|
|
refetchConfig();
|
|
// Clear the search params
|
|
setSearchParams({});
|
|
// Show stripe step to verify completion
|
|
setCurrentStep('stripe');
|
|
}
|
|
}, [searchParams, refetchConfig, setSearchParams]);
|
|
|
|
// Auto-advance to complete step when Stripe is connected
|
|
useEffect(() => {
|
|
if (isStripeConnected && currentStep === 'stripe') {
|
|
setCurrentStep('complete');
|
|
}
|
|
}, [isStripeConnected, currentStep]);
|
|
|
|
// Handle embedded onboarding completion
|
|
const handleEmbeddedOnboardingComplete = () => {
|
|
refetchConfig();
|
|
setCurrentStep('complete');
|
|
};
|
|
|
|
// Handle embedded onboarding error
|
|
const handleEmbeddedOnboardingError = (error: string) => {
|
|
console.error('Embedded onboarding error:', error);
|
|
};
|
|
|
|
const handleCompleteOnboarding = async () => {
|
|
try {
|
|
await updateBusinessMutation.mutateAsync({ initialSetupComplete: true });
|
|
onComplete();
|
|
} catch (err) {
|
|
console.error('Failed to complete onboarding:', err);
|
|
onComplete(); // Still call onComplete even if the update fails
|
|
}
|
|
};
|
|
|
|
const handleSkip = async () => {
|
|
try {
|
|
await updateBusinessMutation.mutateAsync({ initialSetupComplete: true });
|
|
} catch (err) {
|
|
console.error('Failed to skip onboarding:', err);
|
|
}
|
|
if (onSkip) {
|
|
onSkip();
|
|
} else {
|
|
onComplete();
|
|
}
|
|
};
|
|
|
|
const steps = [
|
|
{ key: 'welcome', label: t('onboarding.steps.welcome') },
|
|
{ key: 'stripe', label: t('onboarding.steps.payments') },
|
|
{ key: 'complete', label: t('onboarding.steps.complete') },
|
|
];
|
|
|
|
const currentStepIndex = steps.findIndex(s => s.key === currentStep);
|
|
|
|
// Step indicator component
|
|
const StepIndicator = () => (
|
|
<div className="flex items-center justify-center gap-2 mb-8">
|
|
{steps.map((step, index) => (
|
|
<React.Fragment key={step.key}>
|
|
<div
|
|
className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium transition-colors ${
|
|
index < currentStepIndex
|
|
? 'bg-green-500 text-white'
|
|
: index === currentStepIndex
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{index < currentStepIndex ? (
|
|
<CheckCircle size={16} />
|
|
) : (
|
|
index + 1
|
|
)}
|
|
</div>
|
|
{index < steps.length - 1 && (
|
|
<div
|
|
className={`w-12 h-0.5 ${
|
|
index < currentStepIndex ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
|
|
}`}
|
|
/>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// Welcome step
|
|
const WelcomeStep = () => (
|
|
<div className="text-center">
|
|
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mb-6">
|
|
<Sparkles className="text-white" size={32} />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
|
{t('onboarding.welcome.title', { businessName: business.name })}
|
|
</h2>
|
|
<p className="text-gray-600 dark:text-gray-400 mb-6 max-w-md mx-auto">
|
|
{t('onboarding.welcome.subtitle')}
|
|
</p>
|
|
|
|
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 mb-6 max-w-md mx-auto">
|
|
<h3 className="font-medium text-gray-900 dark:text-white mb-3 text-left">
|
|
{t('onboarding.welcome.whatsIncluded')}
|
|
</h3>
|
|
<ul className="space-y-2 text-left">
|
|
<li className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
|
|
<CreditCard size={18} className="text-blue-500 shrink-0" />
|
|
<span>{t('onboarding.welcome.connectStripe')}</span>
|
|
</li>
|
|
<li className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
|
|
<CheckCircle size={18} className="text-green-500 shrink-0" />
|
|
<span>{t('onboarding.welcome.automaticPayouts')}</span>
|
|
</li>
|
|
<li className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
|
|
<CheckCircle size={18} className="text-green-500 shrink-0" />
|
|
<span>{t('onboarding.welcome.pciCompliance')}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3 max-w-xs mx-auto">
|
|
<button
|
|
onClick={() => setCurrentStep('stripe')}
|
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
|
>
|
|
{t('onboarding.welcome.getStarted')}
|
|
<ArrowRight size={18} />
|
|
</button>
|
|
<button
|
|
onClick={handleSkip}
|
|
className="w-full px-6 py-2 text-gray-500 dark:text-gray-400 text-sm hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
|
>
|
|
{t('onboarding.welcome.skip')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Stripe Connect step - uses embedded onboarding
|
|
const StripeStep = () => (
|
|
<div>
|
|
<div className="text-center mb-6">
|
|
<div className="mx-auto w-16 h-16 bg-[#635BFF] rounded-full flex items-center justify-center mb-6">
|
|
<CreditCard className="text-white" size={32} />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
|
{t('onboarding.stripe.title')}
|
|
</h2>
|
|
<p className="text-gray-600 dark:text-gray-400 max-w-md mx-auto">
|
|
{t('onboarding.stripe.subtitle', { plan: business.plan })}
|
|
</p>
|
|
</div>
|
|
|
|
{configLoading ? (
|
|
<div className="flex items-center justify-center gap-2 py-8">
|
|
<Loader2 className="animate-spin text-gray-400" size={24} />
|
|
<span className="text-gray-500">{t('onboarding.stripe.checkingStatus')}</span>
|
|
</div>
|
|
) : isStripeConnected ? (
|
|
<div className="space-y-4 max-w-md mx-auto">
|
|
<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-center gap-3">
|
|
<CheckCircle className="text-green-600 dark:text-green-400" size={24} />
|
|
<div className="text-left">
|
|
<h4 className="font-medium text-green-800 dark:text-green-300">
|
|
{t('onboarding.stripe.connected.title')}
|
|
</h4>
|
|
<p className="text-sm text-green-700 dark:text-green-400">
|
|
{t('onboarding.stripe.connected.subtitle')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setCurrentStep('complete')}
|
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
|
>
|
|
{t('onboarding.stripe.continue')}
|
|
<ArrowRight size={18} />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="max-w-md mx-auto">
|
|
<ConnectOnboardingEmbed
|
|
connectAccount={paymentConfig?.connect_account || null}
|
|
tier={business.plan}
|
|
onComplete={handleEmbeddedOnboardingComplete}
|
|
onError={handleEmbeddedOnboardingError}
|
|
/>
|
|
<button
|
|
onClick={handleSkip}
|
|
className="w-full mt-4 px-6 py-2 text-gray-500 dark:text-gray-400 text-sm hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
|
>
|
|
{t('onboarding.stripe.doLater')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// Complete step
|
|
const CompleteStep = () => (
|
|
<div className="text-center">
|
|
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-green-400 to-green-600 rounded-full flex items-center justify-center mb-6">
|
|
<Rocket className="text-white" size={32} />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
|
{t('onboarding.complete.title')}
|
|
</h2>
|
|
<p className="text-gray-600 dark:text-gray-400 mb-6 max-w-md mx-auto">
|
|
{t('onboarding.complete.subtitle')}
|
|
</p>
|
|
|
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-6 max-w-md mx-auto">
|
|
<ul className="space-y-2 text-left">
|
|
<li className="flex items-center gap-2 text-green-700 dark:text-green-300">
|
|
<CheckCircle size={16} className="shrink-0" />
|
|
<span>{t('onboarding.complete.checklist.accountCreated')}</span>
|
|
</li>
|
|
<li className="flex items-center gap-2 text-green-700 dark:text-green-300">
|
|
<CheckCircle size={16} className="shrink-0" />
|
|
<span>{t('onboarding.complete.checklist.stripeConfigured')}</span>
|
|
</li>
|
|
<li className="flex items-center gap-2 text-green-700 dark:text-green-300">
|
|
<CheckCircle size={16} className="shrink-0" />
|
|
<span>{t('onboarding.complete.checklist.readyForPayments')}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleCompleteOnboarding}
|
|
disabled={updateBusinessMutation.isPending}
|
|
className="px-8 py-3 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
{updateBusinessMutation.isPending ? (
|
|
<Loader2 size={18} className="animate-spin" />
|
|
) : (
|
|
t('onboarding.complete.goToDashboard')
|
|
)}
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-auto">
|
|
{/* Header with close button */}
|
|
<div className="flex justify-end p-4 pb-0">
|
|
<button
|
|
onClick={handleSkip}
|
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
title={t('onboarding.skipForNow')}
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="px-8 pb-8">
|
|
<StepIndicator />
|
|
|
|
{currentStep === 'welcome' && <WelcomeStep />}
|
|
{currentStep === 'stripe' && <StripeStep />}
|
|
{currentStep === 'complete' && <CompleteStep />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default OnboardingWizard;
|