"use client"; import { ChevronDown, PartyPopper, X } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { Button, StatefulButton } from "@/components/motion/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/motion/collapsible"; import { Progress, ProgressCircle } from "@/components/motion/progress"; import { EASE_OUT, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type OnboardingStep = { id: string; title: string; description?: ReactNode; /** CTA label inside the expanded step. Default "Mark as done". */ actionLabel?: string; /** Runs when the CTA is pressed; the step completes once it resolves. */ onAction?: () => void | Promise; }; export interface OnboardingChecklistProps { steps: OnboardingStep[]; /** Controlled completed step ids. */ completed?: string[]; defaultCompleted?: string[]; onCompletedChange?: (completed: string[]) => void; /** Controlled visibility. Dismissing sets it to false. */ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; dismissible?: boolean; title?: ReactNode; description?: ReactNode; celebrationTitle?: ReactNode; celebrationDescription?: ReactNode; className?: string; } const NEXT_OPEN_DELAY_MS = 420; const CONFETTI_COLORS = ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4", "bg-chart-5"]; // Deterministic burst: evenly spaced angles, alternating distances. const CONFETTI = Array.from({ length: 16 }, (_, i) => { const angle = (i / 16) * Math.PI * 2; const distance = i % 2 === 0 ? 74 : 54; return { id: i, x: Math.round(Math.cos(angle) * distance), y: Math.round(Math.sin(angle) * distance), color: CONFETTI_COLORS[i % CONFETTI_COLORS.length], round: i % 3 === 0, }; }); function CheckMark({ done }: { done: boolean }) { const reduce = useReducedMotion(); return ( ); } /** * Getting-started card: a progress ring and bar, steps that expand (via * `Collapsible`) to show a description and CTA, a completed step pops its * check, strikes its title and auto-opens the next open step, an optional * dismiss, and a confetti celebration state once everything is done. */ export function OnboardingChecklist({ steps, completed: completedProp, defaultCompleted = [], onCompletedChange, open: openProp, defaultOpen = true, onOpenChange, dismissible = true, title = "Get started", description = "A few steps to set up your workspace.", celebrationTitle = "You're all set!", celebrationDescription = "Your workspace is ready. Time to build something.", className, }: OnboardingChecklistProps) { const reduce = useReducedMotion(); const [internalCompleted, setInternalCompleted] = useState(defaultCompleted); const completed = completedProp ?? internalCompleted; const [internalOpen, setInternalOpen] = useState(defaultOpen); const visible = openProp ?? internalOpen; const [expanded, setExpanded] = useState( () => steps.find((s) => !defaultCompleted.includes(s.id))?.id ?? null, ); const [pending, setPending] = useState(null); const timer = useRef(undefined); useEffect(() => () => window.clearTimeout(timer.current), []); const doneCount = steps.filter((s) => completed.includes(s.id)).length; const allDone = steps.length > 0 && doneCount === steps.length; const percent = steps.length ? Math.round((doneCount / steps.length) * 100) : 0; const commit = (next: string[]) => { if (completedProp === undefined) setInternalCompleted(next); onCompletedChange?.(next); }; const setDone = (id: string, done: boolean) => { const next = done ? [...completed.filter((c) => c !== id), id] : completed.filter((c) => c !== id); commit(next); if (!done) return; window.clearTimeout(timer.current); // Let the check land before the next step unfolds. timer.current = window.setTimeout(() => { const index = steps.findIndex((s) => s.id === id); const following = [...steps.slice(index + 1), ...steps.slice(0, index)].find( (s) => !next.includes(s.id), ); setExpanded(following?.id ?? null); }, NEXT_OPEN_DELAY_MS); }; const runAction = async (step: OnboardingStep) => { setPending(step.id); try { await step.onAction?.(); setDone(step.id, true); } finally { setPending(null); } }; const dismiss = () => { if (openProp === undefined) setInternalOpen(false); onOpenChange?.(false); }; return ( {visible ? (

{title}

{allDone ? "Everything's done." : description}{" "} {doneCount}/{steps.length}

{dismissible ? ( ) : null}
{allDone ? ( {reduce ? null : CONFETTI.map((piece) => ( ))}

{celebrationTitle}

{celebrationDescription}

{dismissible ? ( ) : null}
) : ( {steps.map((step) => { const done = completed.includes(step.id); const isOpen = expanded === step.id; return (
  • setExpanded(next ? step.id : null)} >
    {step.title} {/* Strike line draws left→right on an HTML span. */}
    {step.description ? (

    {step.description}

    ) : null} {done ? (

    Completed

    ) : ( void runAction(step)} > {step.actionLabel ?? "Mark as done"} )}
  • ); })}
    )}
    ) : null}
    ); }