mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
fix(onboarding): handle completion API failure instead of silently redirecting
Check res.ok and catch network errors in completeOnboarding; surface a toast and bail early rather than pushing to /dashboard on failure, preventing a redirect loop back to /onboarding. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3aedc46351
commit
d4f4220520
@@ -0,0 +1,624 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Video,
|
||||
Building2,
|
||||
FolderPlus,
|
||||
PlayCircle,
|
||||
Bell,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Lock,
|
||||
UserPlus,
|
||||
Globe,
|
||||
Youtube,
|
||||
Upload,
|
||||
Mail,
|
||||
AlertCircle,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC';
|
||||
|
||||
const TOTAL_STEPS = 5;
|
||||
|
||||
const visibilityOptions: { value: Visibility; label: string; description: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
value: 'PRIVATE',
|
||||
label: 'Private',
|
||||
description: 'Only workspace members and project members can access',
|
||||
icon: <Lock className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
value: 'INVITE',
|
||||
label: 'Invite Only',
|
||||
description: 'Share with specific people via email',
|
||||
icon: <UserPlus className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
value: 'PUBLIC',
|
||||
label: 'Public',
|
||||
description: 'Anyone with the link can view',
|
||||
icon: <Globe className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
function ToggleButton({
|
||||
enabled,
|
||||
onToggle,
|
||||
label,
|
||||
description,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
label: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'flex items-center justify-between w-full p-3 rounded-lg border transition-colors text-left',
|
||||
enabled ? 'border-primary/50 bg-primary/5' : 'border-border hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0 pr-4">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn('w-10 h-6 shrink-0 rounded-full relative transition-colors', enabled ? 'bg-primary' : 'bg-muted')}>
|
||||
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white transition-transform', enabled ? 'translate-x-5' : 'translate-x-1')} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 1: Welcome ───────────────────────────────────────────────────────────
|
||||
|
||||
function StepWelcome({ userName, onNext }: { userName: string; onNext: () => void }) {
|
||||
return (
|
||||
<div className="text-center space-y-8">
|
||||
<div className="mx-auto w-24 h-24 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Video className="h-12 w-12 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Welcome to OpenFrame, {userName.split(' ')[0]}!</h2>
|
||||
<p className="text-base text-muted-foreground max-w-md mx-auto">
|
||||
OpenFrame is your collaborative video review platform. Collect timestamped feedback, manage versions, and streamline approvals — all in one place.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onNext} size="lg" className="w-full sm:w-auto px-10 h-12 text-base">
|
||||
Get Started
|
||||
<ChevronRight className="h-5 w-5 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 2: Create Workspace ──────────────────────────────────────────────────
|
||||
|
||||
function StepWorkspace({
|
||||
onNext,
|
||||
onWorkspaceCreated,
|
||||
}: {
|
||||
onNext: () => void;
|
||||
onWorkspaceCreated: (id: string) => void;
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [formData, setFormData] = useState({ name: '', description: '' });
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to create workspace');
|
||||
return;
|
||||
}
|
||||
onWorkspaceCreated(data.data.id);
|
||||
onNext();
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
|
||||
<Building2 className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Create your workspace</h2>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Workspaces organize your projects and team members.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ws-name" className="text-sm font-medium">
|
||||
Workspace Name
|
||||
</Label>
|
||||
<Input
|
||||
id="ws-name"
|
||||
placeholder="e.g., My Studio"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ws-desc" className="text-sm font-medium">
|
||||
Description <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="ws-desc"
|
||||
placeholder="What is this workspace for?"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<Button type="submit" className="w-full h-11" disabled={isLoading || !formData.name.trim()}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Workspace'
|
||||
)}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onNext} disabled={isLoading} className="w-full text-muted-foreground">
|
||||
Skip this step
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 3: Create Project ────────────────────────────────────────────────────
|
||||
|
||||
function StepProject({
|
||||
workspaceId,
|
||||
onNext,
|
||||
onProjectCreated,
|
||||
}: {
|
||||
workspaceId: string | null;
|
||||
onNext: () => void;
|
||||
onProjectCreated: (id: string) => void;
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
visibility: 'PRIVATE' as Visibility,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceId) return;
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...formData, workspaceId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to create project');
|
||||
return;
|
||||
}
|
||||
onProjectCreated(data.data.id);
|
||||
onNext();
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
|
||||
<FolderPlus className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Create your first project</h2>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Projects hold your videos and collected feedback.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!workspaceId ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>You skipped workspace creation. Projects require a workspace — you can create both from the dashboard later.</span>
|
||||
</div>
|
||||
<Button onClick={onNext} className="w-full h-11">
|
||||
Continue
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="proj-name" className="text-sm font-medium">
|
||||
Project Name
|
||||
</Label>
|
||||
<Input
|
||||
id="proj-name"
|
||||
placeholder="e.g., Product Demo Q1"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="proj-desc" className="text-sm font-medium">
|
||||
Description <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="proj-desc"
|
||||
placeholder="Brief description of what this project is about..."
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Who can access?</Label>
|
||||
<div className="grid gap-2.5">
|
||||
{visibilityOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, visibility: option.value })}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-4 p-4 rounded-xl border-2 text-left transition-all',
|
||||
formData.visibility === option.value
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border hover:border-border/80 hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'shrink-0 w-10 h-10 rounded-lg flex items-center justify-center',
|
||||
formData.visibility === option.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}>
|
||||
{option.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{option.label}</div>
|
||||
<div className="text-sm text-muted-foreground">{option.description}</div>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center',
|
||||
formData.visibility === option.value ? 'border-primary bg-primary' : 'border-muted-foreground/30'
|
||||
)}>
|
||||
{formData.visibility === option.value && (
|
||||
<div className="w-2 h-2 rounded-full bg-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<Button type="submit" className="w-full h-11" disabled={isLoading || !formData.name.trim()}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Project'
|
||||
)}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onNext} disabled={isLoading} className="w-full text-muted-foreground">
|
||||
Skip this step
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 4: Add First Video (informational) ───────────────────────────────────
|
||||
|
||||
function StepVideo({ onNext }: { onNext: () => void }) {
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
|
||||
<PlayCircle className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Adding videos</h2>
|
||||
<p className="text-base text-muted-foreground">
|
||||
OpenFrame supports two ways to add video content to your projects.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-xl border p-5 space-y-2">
|
||||
<div className="flex items-center gap-2.5 font-semibold">
|
||||
<Youtube className="h-5 w-5 text-red-500" />
|
||||
YouTube link
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Paste a link to any YouTube video. OpenFrame will pull in the title, thumbnail, and duration automatically — no file upload needed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border p-5 space-y-2">
|
||||
<div className="flex items-center gap-2.5 font-semibold">
|
||||
<Upload className="h-5 w-5 text-primary" />
|
||||
Direct upload
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload video files directly from your device. Files are processed and delivered via CDN for fast, reliable playback worldwide.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={onNext} className="w-full h-11">
|
||||
Got it, continue
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 5: Notification Preferences ─────────────────────────────────────────
|
||||
|
||||
function StepNotifications({ onFinish }: { onFinish: () => Promise<void> }) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [emailEnabled, setEmailEnabled] = useState(false);
|
||||
const [events, setEvents] = useState({
|
||||
onNewVideo: true,
|
||||
onNewComment: true,
|
||||
onNewReply: true,
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await fetch('/api/settings/notifications', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
emailEnabled,
|
||||
telegramEnabled: false,
|
||||
telegramBotToken: null,
|
||||
telegramChatId: null,
|
||||
onNewVideo: events.onNewVideo,
|
||||
onNewVersion: true,
|
||||
onNewComment: events.onNewComment,
|
||||
onNewReply: events.onNewReply,
|
||||
onApprovalEvents: true,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// best-effort; don't block finishing
|
||||
}
|
||||
await onFinish();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
|
||||
<Bell className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Notification preferences</h2>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Choose when you want to be notified. Email and Telegram are both supported — you can configure Telegram anytime in Settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Mail className="h-4 w-4" />
|
||||
Channels
|
||||
</div>
|
||||
<ToggleButton
|
||||
enabled={emailEnabled}
|
||||
onToggle={() => setEmailEnabled((v) => !v)}
|
||||
label="Email notifications"
|
||||
description="Receive emails to your account address"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Bell className="h-4 w-4" />
|
||||
Events
|
||||
</div>
|
||||
<ToggleButton
|
||||
enabled={events.onNewVideo}
|
||||
onToggle={() => setEvents((e) => ({ ...e, onNewVideo: !e.onNewVideo }))}
|
||||
label="New Video Added"
|
||||
description="When a new video is added to one of your projects"
|
||||
/>
|
||||
<ToggleButton
|
||||
enabled={events.onNewComment}
|
||||
onToggle={() => setEvents((e) => ({ ...e, onNewComment: !e.onNewComment }))}
|
||||
label="New Comment"
|
||||
description="When someone leaves a comment on your videos"
|
||||
/>
|
||||
<ToggleButton
|
||||
enabled={events.onNewReply}
|
||||
onToggle={() => setEvents((e) => ({ ...e, onNewReply: !e.onNewReply }))}
|
||||
label="New Reply"
|
||||
description="When someone replies to a comment thread"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<Button onClick={handleSave} className="w-full h-11" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save & finish'
|
||||
)}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onFinish} disabled={isSaving} className="w-full text-muted-foreground">
|
||||
Skip
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Wizard Shell ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function OnboardingWizard({ userName }: { userName: string }) {
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [createdWorkspaceId, setCreatedWorkspaceId] = useState<string | null>(null);
|
||||
const [isCompleting, setIsCompleting] = useState(false);
|
||||
|
||||
const goNext = () => setCurrentStep((s) => Math.min(s + 1, TOTAL_STEPS));
|
||||
|
||||
const completeOnboarding = async () => {
|
||||
setIsCompleting(true);
|
||||
try {
|
||||
const res = await fetch('/api/onboarding/complete', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
toast.error('Failed to complete setup. Please try again.');
|
||||
setIsCompleting(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
toast.error('Something went wrong. Please try again.');
|
||||
setIsCompleting(false);
|
||||
return;
|
||||
}
|
||||
router.push('/dashboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl">
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
{/* Step dots */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
{Array.from({ length: TOTAL_STEPS }, (_, i) => i + 1).map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
className={cn(
|
||||
'rounded-full transition-all',
|
||||
step === currentStep
|
||||
? 'w-7 h-3 bg-primary'
|
||||
: step < currentStep
|
||||
? 'w-3 h-3 bg-primary/40'
|
||||
: 'w-3 h-3 bg-muted'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Skip button */}
|
||||
{currentStep > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={completeOnboarding}
|
||||
disabled={isCompleting}
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{isCompleting ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||||
Skip setup
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<Card className="border-border/50 shadow-lg">
|
||||
<CardContent className="pt-10 pb-10 px-10">
|
||||
{currentStep === 1 && (
|
||||
<StepWelcome userName={userName} onNext={goNext} />
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<StepWorkspace onNext={goNext} onWorkspaceCreated={setCreatedWorkspaceId} />
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<StepProject workspaceId={createdWorkspaceId} onNext={goNext} onProjectCreated={() => {}} />
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
<StepVideo onNext={goNext} />
|
||||
)}
|
||||
{currentStep === 5 && (
|
||||
<StepNotifications onFinish={completeOnboarding} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Step label */}
|
||||
<p className="text-center text-sm text-muted-foreground mt-5">
|
||||
Step {currentStep} of {TOTAL_STEPS}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user