diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx
index 8c1cd9f..df2476b 100644
--- a/app/(dashboard)/dashboard/page.tsx
+++ b/app/(dashboard)/dashboard/page.tsx
@@ -14,6 +14,14 @@ export default async function DashboardPage({
redirect('/login');
}
+ const userOnboarding = await db.user.findUnique({
+ where: { id: session.user.id },
+ select: { onboardingCompletedAt: true },
+ });
+ if (!userOnboarding?.onboardingCompletedAt) {
+ redirect('/onboarding');
+ }
+
const resolvedSearchParams = await searchParams;
const { ws, sort, page: pageParam } = resolvedSearchParams || {};
diff --git a/app/api/onboarding/complete/route.ts b/app/api/onboarding/complete/route.ts
new file mode 100644
index 0000000..46eba41
--- /dev/null
+++ b/app/api/onboarding/complete/route.ts
@@ -0,0 +1,17 @@
+import { auth } from '@/lib/auth';
+import { db } from '@/lib/db';
+import { apiErrors, successResponse } from '@/lib/api-response';
+
+export async function POST() {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return apiErrors.unauthorized();
+ }
+
+ await db.user.update({
+ where: { id: session.user.id },
+ data: { onboardingCompletedAt: new Date() },
+ });
+
+ return successResponse({ completed: true });
+}
diff --git a/app/onboarding/layout.tsx b/app/onboarding/layout.tsx
new file mode 100644
index 0000000..19313a4
--- /dev/null
+++ b/app/onboarding/layout.tsx
@@ -0,0 +1,7 @@
+export default function OnboardingLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/onboarding/onboarding-wizard.tsx b/app/onboarding/onboarding-wizard.tsx
new file mode 100644
index 0000000..2ff8488
--- /dev/null
+++ b/app/onboarding/onboarding-wizard.tsx
@@ -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: ,
+ },
+ {
+ value: 'INVITE',
+ label: 'Invite Only',
+ description: 'Share with specific people via email',
+ icon: ,
+ },
+ {
+ value: 'PUBLIC',
+ label: 'Public',
+ description: 'Anyone with the link can view',
+ icon: ,
+ },
+];
+
+function ToggleButton({
+ enabled,
+ onToggle,
+ label,
+ description,
+}: {
+ enabled: boolean;
+ onToggle: () => void;
+ label: string;
+ description?: string;
+}) {
+ return (
+
+
+
{label}
+ {description && (
+
{description}
+ )}
+
+
+
+ );
+}
+
+// ─── Step 1: Welcome ───────────────────────────────────────────────────────────
+
+function StepWelcome({ userName, onNext }: { userName: string; onNext: () => void }) {
+ return (
+
+
+
+
+
+
Welcome to OpenFrame, {userName.split(' ')[0]}!
+
+ OpenFrame is your collaborative video review platform. Collect timestamped feedback, manage versions, and streamline approvals — all in one place.
+
+
+
+ Get Started
+
+
+
+ );
+}
+
+// ─── 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 (
+
+
+
+
+
+
Create your workspace
+
+ Workspaces organize your projects and team members.
+
+
+
+
+
+ );
+}
+
+// ─── 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 (
+
+
+
+
+
+
Create your first project
+
+ Projects hold your videos and collected feedback.
+
+
+
+ {!workspaceId ? (
+
+
+
+ You skipped workspace creation. Projects require a workspace — you can create both from the dashboard later.
+
+
+ Continue
+
+
+
+ ) : (
+
+
+
+ Project Name
+
+ setFormData({ ...formData, name: e.target.value })}
+ required
+ disabled={isLoading}
+ className="h-11"
+ />
+
+
+
+ Description (optional)
+
+ setFormData({ ...formData, description: e.target.value })}
+ rows={3}
+ disabled={isLoading}
+ className="resize-none"
+ />
+
+
+
+
Who can access?
+
+ {visibilityOptions.map((option) => (
+
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'
+ )}
+ >
+
+ {option.icon}
+
+
+
{option.label}
+
{option.description}
+
+
+ {formData.visibility === option.value && (
+
+ )}
+
+
+ ))}
+
+
+
+ {error && (
+
+ )}
+
+
+
+ {isLoading ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ 'Create Project'
+ )}
+
+
+ Skip this step
+
+
+
+ )}
+
+ );
+}
+
+// ─── Step 4: Add First Video (informational) ───────────────────────────────────
+
+function StepVideo({ onNext }: { onNext: () => void }) {
+ return (
+
+
+
+
Adding videos
+
+ OpenFrame supports two ways to add video content to your projects.
+
+
+
+
+
+
+
+ YouTube link
+
+
+ Paste a link to any YouTube video. OpenFrame will pull in the title, thumbnail, and duration automatically — no file upload needed.
+
+
+
+
+
+ Direct upload
+
+
+ Upload video files directly from your device. Files are processed and delivered via CDN for fast, reliable playback worldwide.
+
+
+
+
+
+ Got it, continue
+
+
+
+ );
+}
+
+// ─── Step 5: Notification Preferences ─────────────────────────────────────────
+
+function StepNotifications({ onFinish }: { onFinish: () => Promise }) {
+ 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 (
+
+
+
+
+
+
Notification preferences
+
+ Choose when you want to be notified. Email and Telegram are both supported — you can configure Telegram anytime in Settings.
+
+
+
+
+
+
+
+ Channels
+
+
setEmailEnabled((v) => !v)}
+ label="Email notifications"
+ description="Receive emails to your account address"
+ />
+
+
+
+
+
+ Events
+
+
setEvents((e) => ({ ...e, onNewVideo: !e.onNewVideo }))}
+ label="New Video Added"
+ description="When a new video is added to one of your projects"
+ />
+ setEvents((e) => ({ ...e, onNewComment: !e.onNewComment }))}
+ label="New Comment"
+ description="When someone leaves a comment on your videos"
+ />
+ setEvents((e) => ({ ...e, onNewReply: !e.onNewReply }))}
+ label="New Reply"
+ description="When someone replies to a comment thread"
+ />
+
+
+
+
+
+ {isSaving ? (
+ <>
+
+ Saving...
+ >
+ ) : (
+ 'Save & finish'
+ )}
+
+
+ Skip
+
+
+
+ );
+}
+
+// ─── Wizard Shell ──────────────────────────────────────────────────────────────
+
+export function OnboardingWizard({ userName }: { userName: string }) {
+ const router = useRouter();
+ const [currentStep, setCurrentStep] = useState(1);
+ const [createdWorkspaceId, setCreatedWorkspaceId] = useState(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 (
+
+ {/* Top bar */}
+
+ {/* Step dots */}
+
+ {Array.from({ length: TOTAL_STEPS }, (_, i) => i + 1).map((step) => (
+
+ ))}
+
+
+ {/* Skip button */}
+ {currentStep > 1 && (
+
+ {isCompleting ? : null}
+ Skip setup
+
+ )}
+
+
+ {/* Step content */}
+
+
+ {currentStep === 1 && (
+
+ )}
+ {currentStep === 2 && (
+
+ )}
+ {currentStep === 3 && (
+ {}} />
+ )}
+ {currentStep === 4 && (
+
+ )}
+ {currentStep === 5 && (
+
+ )}
+
+
+
+ {/* Step label */}
+
+ Step {currentStep} of {TOTAL_STEPS}
+
+
+ );
+}
diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx
new file mode 100644
index 0000000..6082449
--- /dev/null
+++ b/app/onboarding/page.tsx
@@ -0,0 +1,24 @@
+import { auth } from '@/lib/auth';
+import { db } from '@/lib/db';
+import { redirect } from 'next/navigation';
+import { OnboardingWizard } from './onboarding-wizard';
+
+export default async function OnboardingPage() {
+ const session = await auth();
+ if (!session?.user?.id) {
+ redirect('/login');
+ }
+
+ const user = await db.user.findUnique({
+ where: { id: session.user.id },
+ select: { onboardingCompletedAt: true, name: true, email: true },
+ });
+
+ if (user?.onboardingCompletedAt) {
+ redirect('/dashboard');
+ }
+
+ const userName = user?.name || user?.email?.split('@')[0] || 'there';
+
+ return ;
+}
diff --git a/prisma/migrations/20260227120000_add_onboarding/migration.sql b/prisma/migrations/20260227120000_add_onboarding/migration.sql
new file mode 100644
index 0000000..dd59e7a
--- /dev/null
+++ b/prisma/migrations/20260227120000_add_onboarding/migration.sql
@@ -0,0 +1 @@
+ALTER TABLE users ADD COLUMN IF NOT EXISTS "onboardingCompletedAt" TIMESTAMP;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 253ccb6..31debd5 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -21,6 +21,7 @@ model User {
password String? // Hashed password for email/password auth
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+ onboardingCompletedAt DateTime?
// Relations
accounts Account[]
@@ -70,12 +71,14 @@ enum DownloadEgressSource {
enum VideoAssetKind {
IMAGE
VIDEO
+ AUDIO
}
enum VideoAssetProvider {
R2_IMAGE
YOUTUBE
BUNNY
+ R2_AUDIO
}
model DownloadEgressEvent {