Initial commit: SmoothSchedule multi-tenant scheduling platform
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>
This commit is contained in:
329
frontend/src/components/OnboardingWizard.tsx
Normal file
329
frontend/src/components/OnboardingWizard.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user