"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
ArrowLeft,
ArrowRight,
Briefcase,
Check,
Code2,
Copy,
Link2,
Megaphone,
Monitor,
Moon,
PenTool,
Rocket,
Settings2,
Sun,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type OnboardingResult = {
workspace: string;
slug: string;
teamSize: string;
role: string;
useCases: string[];
invites: string[];
appearance: "light" | "dark" | "system";
accent: string;
};
export type RoleOption = { id: string; label: string; hint: string; icon: React.ComponentType<{ className?: string }> };
export interface OnboardingWizardProps {
brand?: string;
domain?: string;
roles?: RoleOption[];
useCases?: string[];
accents?: string[];
/** Slugs that are already taken (demo validation). */
takenSlugs?: string[];
onComplete?: (result: OnboardingResult) => void;
className?: string;
}
const DEFAULT_ROLES: RoleOption[] = [
{ id: "engineering", label: "Engineering", hint: "Ship features & fix bugs", icon: Code2 },
{ id: "design", label: "Design", hint: "Craft flows & systems", icon: PenTool },
{ id: "product", label: "Product", hint: "Plan roadmaps & specs", icon: Rocket },
{ id: "marketing", label: "Marketing", hint: "Launch & grow", icon: Megaphone },
{ id: "operations", label: "Operations", hint: "Keep the machine running", icon: Settings2 },
{ id: "founder", label: "Founder", hint: "A bit of everything", icon: Briefcase },
];
const DEFAULT_USE_CASES = ["Project tracking", "Docs & wiki", "Roadmaps", "Bug tracking", "Sprint planning", "Client portals"];
const DEFAULT_ACCENTS = ["#6366f1", "#0ea5e9", "#10b981", "#f59e0b", "#f43f5e", "#a855f7"];
const TEAM_SIZES = ["Just me", "2–10", "11–50", "51–200", "200+"];
const STEPS = [
{ id: "workspace", title: "Workspace", hint: "Name & address" },
{ id: "role", title: "About you", hint: "Role & goals" },
{ id: "invite", title: "Invite team", hint: "Better together" },
{ id: "theme", title: "Appearance", hint: "Make it yours" },
{ id: "done", title: "All set", hint: "Start working" },
] as const;
const slugify = (s: string) =>
s
.toLowerCase()
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 32);
const emailOk = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v);
export function OnboardingWizard({
brand = "Orbit",
domain = "orbit.app",
roles = DEFAULT_ROLES,
useCases = DEFAULT_USE_CASES,
accents = DEFAULT_ACCENTS,
takenSlugs = ["acme", "admin", "app", "northwind"],
onComplete,
className,
}: OnboardingWizardProps) {
const reduce = useReducedMotion();
const uid = React.useId();
const [step, setStep] = React.useState(0);
const [dir, setDir] = React.useState(1);
const [tried, setTried] = React.useState(false);
const [workspace, setWorkspace] = React.useState("");
const [slug, setSlug] = React.useState("");
const [slugEdited, setSlugEdited] = React.useState(false);
const [teamSize, setTeamSize] = React.useState(TEAM_SIZES[1]);
const [role, setRole] = React.useState("");
const [picked, setPicked] = React.useState<string[]>([]);
const [invites, setInvites] = React.useState<string[]>([]);
const [appearance, setAppearance] = React.useState<"light" | "dark" | "system">("system");
const [accent, setAccent] = React.useState(accents[0]);
const effectiveSlug = slugEdited ? slug : slugify(workspace);
const slugError = !effectiveSlug
? "Pick an address for your workspace."
: effectiveSlug.length < 3
? "Use at least 3 characters."
: takenSlugs.includes(effectiveSlug)
? `${effectiveSlug}.${domain} is taken.`
: "";
const errors: Record<number, string> = {
0: workspace.trim().length < 2 ? "Give your workspace a name." : slugError,
1: !role ? "Choose the role that fits you best." : "",
2: invites.some((e) => !emailOk(e)) ? "Remove or fix the invalid email addresses." : "",
};
const go = (to: number) => {
setDir(to > step ? 1 : -1);
setTried(false);
setStep(to);
};
const next = (e?: React.FormEvent) => {
e?.preventDefault();
if (errors[step]) {
setTried(true);
return;
}
if (step === STEPS.length - 2) {
onComplete?.({ workspace: workspace.trim(), slug: effectiveSlug, teamSize, role, useCases: picked, invites, appearance, accent });
}
go(Math.min(STEPS.length - 1, step + 1));
};
const restart = () => {
setWorkspace("");
setSlug("");
setSlugEdited(false);
setRole("");
setPicked([]);
setInvites([]);
setAppearance("system");
setAccent(accents[0]);
go(0);
};
const variants = {
enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * 40 }),
center: { opacity: 1, x: 0 },
exit: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * -40 }),
};
const current = STEPS[step];
const isDone = current.id === "done";
const err = tried ? errors[step] : "";
return (
<section className={cn("relative w-full overflow-hidden bg-muted/40 px-4 py-6 text-foreground sm:px-8 sm:py-10", className)}>
<div className="mx-auto grid min-h-[700px] w-full max-w-5xl overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-xl shadow-black/5 md:grid-cols-[250px_minmax(0,1fr)]">
{/* Stepper */}
<aside className="border-b bg-muted/40 p-5 md:border-b-0 md:border-r md:p-6">
<div className="flex items-center gap-2">
<span className="grid size-7 place-items-center rounded-lg text-white" style={{ background: accent }}>
<svg viewBox="0 0 24 24" className="size-4" aria-hidden>
<circle cx="12" cy="12" r="3.5" fill="currentColor" />
<ellipse cx="12" cy="12" rx="9.5" ry="4" fill="none" stroke="currentColor" strokeWidth="1.8" transform="rotate(-25 12 12)" />
</svg>
</span>
<span className="font-semibold tracking-tight">{brand}</span>
<span className="ml-auto text-xs font-medium text-muted-foreground md:hidden">
Step {step + 1} of {STEPS.length}
</span>
</div>
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-muted md:hidden" aria-hidden>
<motion.div className="h-full rounded-full" style={{ background: accent }} animate={{ width: `${((step + 1) / STEPS.length) * 100}%` }} />
</div>
<ol className="mt-8 hidden space-y-1 md:block" aria-label="Onboarding progress">
{STEPS.map((s, i) => {
const state = i < step ? "done" : i === step ? "current" : "todo";
return (
<li key={s.id} className="relative">
{i < STEPS.length - 1 && (
<span className="absolute left-[15px] top-9 h-[calc(100%-1.75rem)] w-px bg-border" aria-hidden>
<motion.span className="block w-full origin-top" style={{ background: accent }} initial={false} animate={{ height: i < step ? "100%" : "0%" }} transition={{ duration: 0.35 }} />
</span>
)}
<button
type="button"
disabled={i > step || isDone}
onClick={() => go(i)}
aria-current={state === "current" ? "step" : undefined}
className="flex w-full items-start gap-3 rounded-lg p-1.5 text-left transition enabled:hover:bg-accent disabled:cursor-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className={cn(
"grid size-[19px] shrink-0 translate-x-[3px] translate-y-0.5 place-items-center rounded-full border-2 text-[10px] font-semibold transition-colors",
state === "todo" && "border-border bg-card text-muted-foreground",
)}
style={state !== "todo" ? { borderColor: accent, background: state === "done" ? accent : "transparent", color: state === "done" ? "white" : accent } : undefined}
>
{state === "done" ? <Check className="size-3" strokeWidth={3.5} /> : state === "current" ? <span className="size-1.5 rounded-full" style={{ background: accent }} /> : null}
</span>
<span>
<span className={cn("block text-sm font-medium", state === "todo" && "text-muted-foreground")}>{s.title}</span>
<span className="block text-xs text-muted-foreground">{s.hint}</span>
</span>
</button>
</li>
);
})}
</ol>
</aside>
{/* Step body */}
<div className="relative flex min-w-0 flex-col">
<form noValidate onSubmit={next} className="flex flex-1 flex-col">
<div className="relative flex-1 overflow-hidden p-5 sm:p-8 lg:p-10">
<AnimatePresence mode="wait" custom={dir} initial={false}>
<motion.div key={current.id} custom={dir} variants={variants} initial="enter" animate="center" exit="exit" transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}>
{current.id === "workspace" && (
<StepShell title="Create your workspace" sub="This is where your team's projects, docs and conversations live.">
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
<span className="grid size-16 shrink-0 place-items-center rounded-2xl text-xl font-semibold text-white shadow-sm" style={{ background: `linear-gradient(135deg, ${accent}, color-mix(in oklab, ${accent} 55%, black))` }} aria-hidden>
{(workspace.trim() || "W")
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase()}
</span>
<div className="min-w-0 flex-1 space-y-5">
<div>
<label htmlFor={`${uid}-ws`} className="text-sm font-medium">
Workspace name
</label>
<input
id={`${uid}-ws`}
autoFocus
value={workspace}
onChange={(e) => setWorkspace(e.target.value)}
placeholder="e.g. Northwind Studio"
aria-invalid={tried && workspace.trim().length < 2}
className={inputCls(tried && workspace.trim().length < 2)}
/>
</div>
<div>
<label htmlFor={`${uid}-slug`} className="text-sm font-medium">
Workspace URL
</label>
<div className={cn("mt-1.5 flex h-11 items-center overflow-hidden rounded-lg border bg-background text-sm shadow-xs transition focus-within:border-ring focus-within:ring-4 focus-within:ring-ring/15", tried && slugError && "border-destructive")}>
<span className="hidden h-full items-center border-r bg-muted/60 px-3 text-muted-foreground sm:flex">https://</span>
<input
id={`${uid}-slug`}
value={effectiveSlug}
onChange={(e) => {
setSlugEdited(true);
setSlug(slugify(e.target.value.replace(/\s/g, "-")) + (e.target.value.endsWith("-") ? "-" : ""));
}}
aria-invalid={!!(effectiveSlug && slugError)}
aria-describedby={`${uid}-slug-hint`}
placeholder="your-team"
className="h-full min-w-0 flex-1 bg-transparent px-3 outline-none"
/>
<span className="flex h-full items-center border-l bg-muted/60 px-3 text-muted-foreground">.{domain}</span>
</div>
<p id={`${uid}-slug-hint`} className={cn("mt-2 flex items-center gap-1.5 text-xs", effectiveSlug && !slugError ? "text-emerald-600 dark:text-emerald-400" : effectiveSlug && slugError ? "text-destructive" : "text-muted-foreground")} aria-live="polite">
{effectiveSlug && !slugError ? (
<>
<Check className="size-3.5" /> {effectiveSlug}.{domain} is available
</>
) : effectiveSlug ? (
<>
{slugError}
{takenSlugs.includes(effectiveSlug) && (
<button
type="button"
onClick={() => {
setSlugEdited(true);
setSlug(`${effectiveSlug}-hq`);
}}
className="font-medium text-foreground underline underline-offset-4"
>
Use {effectiveSlug}-hq
</button>
)}
</>
) : (
"Lowercase letters, numbers and dashes. Try “Acme” to see a taken name."
)}
</p>
</div>
<fieldset>
<legend className="text-sm font-medium">Team size</legend>
<div className="mt-2 flex flex-wrap gap-2">
{TEAM_SIZES.map((t) => (
<label key={t} className="cursor-pointer">
<input type="radio" name={`${uid}-size`} value={t} checked={teamSize === t} onChange={() => setTeamSize(t)} className="peer sr-only" />
<span className="inline-flex h-8 items-center rounded-full border px-3 text-sm transition peer-checked:border-transparent peer-checked:bg-foreground peer-checked:text-background peer-focus-visible:ring-2 peer-focus-visible:ring-ring hover:bg-accent">
{t}
</span>
</label>
))}
</div>
</fieldset>
</div>
</div>
</StepShell>
)}
{current.id === "role" && (
<StepShell title="What do you do?" sub="We'll tailor templates and defaults to your work.">
<fieldset>
<legend className="sr-only">Your role</legend>
<div className="grid grid-cols-2 gap-2.5 lg:grid-cols-3">
{roles.map((r) => {
const on = role === r.id;
const Icon = r.icon;
return (
<label key={r.id} className="relative cursor-pointer">
<input type="radio" name={`${uid}-role`} value={r.id} checked={on} onChange={() => setRole(r.id)} className="peer sr-only" />
<span
className={cn(
"flex h-full flex-col gap-2 rounded-xl border bg-background p-3.5 transition peer-focus-visible:ring-2 peer-focus-visible:ring-ring hover:border-foreground/25",
on && "shadow-sm",
)}
style={on ? { borderColor: accent, boxShadow: `0 0 0 3px color-mix(in oklab, ${accent} 18%, transparent)` } : undefined}
>
<span className="grid size-8 place-items-center rounded-lg bg-muted transition" style={on ? { background: accent, color: "white" } : undefined}>
<Icon className="size-4" />
</span>
<span className="text-sm font-medium">{r.label}</span>
<span className="text-xs text-muted-foreground">{r.hint}</span>
</span>
<AnimatePresence>
{on && (
<motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="absolute right-2.5 top-2.5 grid size-5 place-items-center rounded-full text-white" style={{ background: accent }}>
<Check className="size-3" strokeWidth={3.5} />
</motion.span>
)}
</AnimatePresence>
</label>
);
})}
</div>
</fieldset>
<fieldset className="mt-6">
<legend className="text-sm font-medium">
What will you use {brand} for? <span className="font-normal text-muted-foreground">(pick any)</span>
</legend>
<div className="mt-2.5 flex flex-wrap gap-2">
{useCases.map((u) => {
const on = picked.includes(u);
return (
<label key={u} className="cursor-pointer">
<input type="checkbox" checked={on} onChange={() => setPicked((p) => (on ? p.filter((x) => x !== u) : [...p, u]))} className="peer sr-only" />
<span
className="inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-sm transition peer-focus-visible:ring-2 peer-focus-visible:ring-ring hover:bg-accent"
style={on ? { borderColor: accent, background: `color-mix(in oklab, ${accent} 12%, transparent)` } : undefined}
>
{on && <Check className="size-3.5" style={{ color: accent }} />}
{u}
</span>
</label>
);
})}
</div>
</fieldset>
</StepShell>
)}
{current.id === "invite" && (
<StepShell title="Invite your teammates" sub={`${brand} works best with your team. You can always do this later.`}>
<InviteInput id={`${uid}-invite`} invites={invites} setInvites={setInvites} accent={accent} />
<div className="mt-6 flex flex-col gap-3 rounded-xl border bg-muted/40 p-4 sm:flex-row sm:items-center">
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-background ring-1 ring-border">
<Link2 className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Or share an invite link</p>
<p className="truncate font-mono text-xs text-muted-foreground">
https://{effectiveSlug || "team"}.{domain}/join/k7Qm2x
</p>
</div>
<CopyButton text={`https://${effectiveSlug || "team"}.${domain}/join/k7Qm2x`} />
</div>
</StepShell>
)}
{current.id === "theme" && (
<StepShell title="Pick your look" sub="Preview updates live. Change it anytime in Settings.">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
<div className="space-y-6">
<fieldset>
<legend className="text-sm font-medium">Appearance</legend>
<div className="mt-2 grid grid-cols-3 gap-2">
{(
[
["light", "Light", Sun],
["dark", "Dark", Moon],
["system", "System", Monitor],
] as const
).map(([v, l, Icon]) => (
<label key={v} className="cursor-pointer">
<input type="radio" name={`${uid}-appearance`} checked={appearance === v} onChange={() => setAppearance(v)} className="peer sr-only" />
<span
className="flex h-16 flex-col items-center justify-center gap-1 rounded-xl border bg-background text-xs font-medium transition peer-focus-visible:ring-2 peer-focus-visible:ring-ring hover:bg-accent"
style={appearance === v ? { borderColor: accent, boxShadow: `0 0 0 3px color-mix(in oklab, ${accent} 18%, transparent)` } : undefined}
>
<Icon className="size-4" />
{l}
</span>
</label>
))}
</div>
</fieldset>
<fieldset>
<legend className="text-sm font-medium">Accent colour</legend>
<div className="mt-2.5 flex flex-wrap gap-2.5">
{accents.map((c, i) => (
<label key={c} className="cursor-pointer">
<input type="radio" name={`${uid}-accent`} checked={accent === c} onChange={() => setAccent(c)} className="peer sr-only" aria-label={`Accent ${i + 1}`} />
<span className="grid size-9 place-items-center rounded-full ring-offset-2 ring-offset-card transition peer-focus-visible:ring-2 peer-focus-visible:ring-ring hover:scale-110" style={{ background: c }}>
{accent === c && (
<motion.span layoutId={`${uid}-acc`} className="grid size-full place-items-center rounded-full ring-2 ring-offset-2 ring-offset-card" style={{ ["--tw-ring-color" as string]: c }}>
<Check className="size-4 text-white" strokeWidth={3} />
</motion.span>
)}
</span>
</label>
))}
</div>
</fieldset>
</div>
<ThemePreview appearance={appearance} accent={accent} workspace={workspace.trim() || "Northwind"} />
</div>
</StepShell>
)}
{current.id === "done" && (
<div className="relative flex min-h-[460px] flex-col items-center justify-center text-center">
<Confetti colors={[accent, ...accents.filter((a) => a !== accent)]} />
<motion.span
initial={reduce ? false : { scale: 0, rotate: -30 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 260, damping: 14, delay: 0.1 }}
className="grid size-16 place-items-center rounded-2xl text-white shadow-lg"
style={{ background: accent, boxShadow: `0 12px 30px -10px ${accent}` }}
>
<Check className="size-8" strokeWidth={3} />
</motion.span>
<h2 className="mt-6 text-2xl font-semibold tracking-tight sm:text-3xl">{workspace.trim() || "Your workspace"} is ready</h2>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
{invites.length ? `We've sent ${invites.length} invite${invites.length > 1 ? "s" : ""}. ` : ""}Your workspace lives at{" "}
<span className="whitespace-nowrap font-medium text-foreground">
{effectiveSlug}.{domain}
</span>
</p>
<dl className="mt-6 grid w-full max-w-md grid-cols-3 divide-x rounded-xl border bg-background text-left text-sm">
{[
["Role", roles.find((r) => r.id === role)?.label ?? "—"],
["Members", String(invites.length + 1)],
["Theme", appearance[0].toUpperCase() + appearance.slice(1)],
].map(([k, v]) => (
<div key={k} className="p-3">
<dt className="text-xs text-muted-foreground">{k}</dt>
<dd className="mt-0.5 truncate font-medium">{v}</dd>
</div>
))}
</dl>
<div className="mt-8 flex flex-col gap-2 sm:flex-row">
<button type="button" className="inline-flex h-11 items-center justify-center gap-2 rounded-lg px-5 text-sm font-medium text-white shadow-sm transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" style={{ background: accent }}>
Open {workspace.trim() || "workspace"} <ArrowRight className="size-4" />
</button>
<button type="button" onClick={restart} className="inline-flex h-11 items-center justify-center rounded-lg border px-5 text-sm font-medium transition hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
Restart demo
</button>
</div>
</div>
)}
</motion.div>
</AnimatePresence>
</div>
{!isDone && (
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-2 border-t bg-card px-5 py-4 sm:flex-nowrap sm:px-8">
<p className="min-w-0 basis-full text-xs font-medium text-destructive empty:hidden sm:flex-1 sm:basis-auto sm:empty:block" role="alert">
{err}
</p>
{step > 0 && (
<button type="button" onClick={() => go(step - 1)} className="inline-flex h-10 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-muted-foreground transition hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<ArrowLeft className="size-4" /> Back
</button>
)}
{current.id === "invite" && !invites.length && (
<button type="button" onClick={() => go(step + 1)} className="inline-flex h-10 items-center rounded-lg px-3 text-sm font-medium text-muted-foreground transition hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
Skip
</button>
)}
<button
type="submit"
className="inline-flex h-10 items-center gap-1.5 rounded-lg px-4 text-sm font-medium text-white shadow-sm transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
style={{ background: accent }}
>
{current.id === "invite" && invites.length ? `Send ${invites.length} invite${invites.length > 1 ? "s" : ""}` : current.id === "theme" ? "Finish setup" : "Continue"}
<ArrowRight className="size-4" />
</button>
</div>
)}
</form>
</div>
</div>
</section>
);
}
function inputCls(invalid: boolean) {
return cn(
"mt-1.5 h-11 w-full rounded-lg border bg-background px-3.5 text-sm shadow-xs outline-none transition placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/15",
invalid && "border-destructive",
);
}
function StepShell({ title, sub, children }: { title: string; sub: string; children: React.ReactNode }) {
return (
<div>
<h2 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h2>
<p className="mt-1.5 text-sm text-muted-foreground">{sub}</p>
<div className="mt-7">{children}</div>
</div>
);
}
function InviteInput({ id, invites, setInvites, accent }: { id: string; invites: string[]; setInvites: React.Dispatch<React.SetStateAction<string[]>>; accent: string }) {
const [draft, setDraft] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const add = (raw: string) => {
const parts = raw
.split(/[\s,;]+/)
.map((p) => p.trim().toLowerCase())
.filter(Boolean);
if (!parts.length) return;
setInvites((list) => [...list, ...parts.filter((p) => !list.includes(p))].slice(0, 20));
setDraft("");
};
const invalid = invites.filter((e) => !emailOk(e)).length;
return (
<div>
<label htmlFor={id} className="text-sm font-medium">
Email addresses
</label>
<div
className="mt-1.5 flex min-h-[7.5rem] cursor-text flex-wrap content-start gap-1.5 rounded-xl border bg-background p-2.5 shadow-xs transition focus-within:border-ring focus-within:ring-4 focus-within:ring-ring/15"
onClick={() => inputRef.current?.focus()}
>
<AnimatePresence initial={false}>
{invites.map((e) => {
const ok = emailOk(e);
return (
<motion.span
key={e}
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className={cn("inline-flex h-7 max-w-full items-center gap-1.5 rounded-full border pl-1 pr-1 text-sm", ok ? "bg-muted/60" : "border-destructive/40 bg-destructive/10 text-destructive")}
>
<span className="grid size-5 shrink-0 place-items-center rounded-full text-[10px] font-semibold uppercase text-white" style={{ background: ok ? accent : "var(--destructive)" }} aria-hidden>
{e[0]}
</span>
<span className="truncate">{e}</span>
<button
type="button"
onClick={(ev) => {
ev.stopPropagation();
setInvites((l) => l.filter((x) => x !== e));
}}
aria-label={`Remove ${e}`}
className="grid size-5 shrink-0 place-items-center rounded-full text-muted-foreground transition hover:bg-foreground/10 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3" />
</button>
</motion.span>
);
})}
</AnimatePresence>
<input
ref={inputRef}
id={id}
type="email"
value={draft}
onChange={(e) => {
const v = e.target.value;
if (/[\s,;]$/.test(v)) add(v);
else setDraft(v);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && draft.trim()) {
e.preventDefault();
add(draft);
} else if (e.key === "Backspace" && !draft && invites.length) {
setInvites((l) => l.slice(0, -1));
}
}}
onBlur={() => draft.trim() && add(draft)}
onPaste={(e) => {
e.preventDefault();
add(draft + " " + e.clipboardData.getData("text"));
}}
aria-describedby={`${id}-hint`}
placeholder={invites.length ? "Add another…" : "[email protected], [email protected]"}
className="h-7 min-w-[12rem] flex-1 bg-transparent px-1.5 text-sm outline-none placeholder:text-muted-foreground/70"
/>
</div>
<p id={`${id}-hint`} className={cn("mt-2 text-xs", invalid ? "font-medium text-destructive" : "text-muted-foreground")} aria-live="polite">
{invalid ? `${invalid} address${invalid > 1 ? "es look" : " looks"} invalid.` : `Press Enter or comma to add. Paste a list to add many at once. ${invites.length}/20 added.`}
</p>
</div>
);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = React.useState(false);
return (
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
} catch {
/* clipboard may be blocked; still show feedback */
}
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}}
className="inline-flex h-9 shrink-0 items-center justify-center gap-1.5 rounded-lg border bg-background px-3 text-sm font-medium transition hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-live="polite"
>
{copied ? <Check className="size-4 text-emerald-500" /> : <Copy className="size-4" />}
{copied ? "Copied" : "Copy link"}
</button>
);
}
function ThemePreview({ appearance, accent, workspace }: { appearance: "light" | "dark" | "system"; accent: string; workspace: string }) {
const dark = appearance === "dark";
const split = appearance === "system";
const pane = (d: boolean) => {
const bg = d ? "#0f1015" : "#ffffff";
const side = d ? "#16171d" : "#f6f6f8";
const line = d ? "rgba(255,255,255,.08)" : "rgba(0,0,0,.07)";
const fg = d ? "rgba(255,255,255,.85)" : "rgba(15,16,21,.85)";
const mute = d ? "rgba(255,255,255,.14)" : "rgba(15,16,21,.1)";
return (
<div className="flex h-full" style={{ background: bg }}>
<div className="flex w-[34%] flex-col gap-1.5 p-2.5" style={{ background: side, borderRight: `1px solid ${line}` }}>
<div className="mb-1 flex items-center gap-1.5">
<span className="size-3.5 rounded" style={{ background: accent }} />
<span className="truncate text-[9px] font-semibold" style={{ color: fg }}>
{workspace}
</span>
</div>
{[1, 0, 0, 0].map((a, i) => (
<span key={i} className="flex h-4 items-center gap-1 rounded px-1" style={{ background: a ? `color-mix(in oklab, ${accent} 20%, transparent)` : "transparent" }}>
<span className="size-1.5 rounded-full" style={{ background: a ? accent : mute }} />
<span className="h-1 flex-1 rounded-full" style={{ background: a ? accent : mute, opacity: a ? 0.7 : 1 }} />
</span>
))}
</div>
<div className="flex-1 space-y-2 p-2.5">
<div className="flex items-center justify-between">
<span className="h-1.5 w-12 rounded-full" style={{ background: fg, opacity: 0.8 }} />
<span className="h-3.5 w-9 rounded" style={{ background: accent }} />
</div>
{[0.9, 0.6, 0.75].map((w, i) => (
<div key={i} className="flex items-center gap-1.5 rounded-md p-1.5" style={{ border: `1px solid ${line}` }}>
<span className="size-2.5 rounded-full border" style={{ borderColor: i === 0 ? accent : mute, background: i === 0 ? accent : "transparent" }} />
<span className="h-1 rounded-full" style={{ width: `${w * 100}%`, background: mute }} />
</div>
))}
<div className="flex items-end gap-1 pt-1">
{[40, 65, 50, 80, 60, 90].map((h, i) => (
<span key={i} className="w-full rounded-sm" style={{ height: h * 0.35, background: accent, opacity: 0.35 + i * 0.1 }} />
))}
</div>
</div>
</div>
);
};
return (
<div className="rounded-xl border bg-muted/50 p-3" aria-label="Live theme preview" role="img">
<div className="overflow-hidden rounded-lg shadow-sm ring-1 ring-black/5">
<div className="flex h-5 items-center gap-1 px-2" style={{ background: dark ? "#1c1d24" : "#ececf0" }}>
{["#f87171", "#fbbf24", "#34d399"].map((c) => (
<span key={c} className="size-1.5 rounded-full" style={{ background: c }} />
))}
</div>
<motion.div key={appearance + accent} initial={{ opacity: 0.4 }} animate={{ opacity: 1 }} className="relative h-44">
{split ? (
<div className="grid h-full grid-cols-2">
<div className="overflow-hidden">
<div className="h-full w-[200%]">
{pane(false)}
</div>
</div>
<div className="overflow-hidden">
<div className="-ml-[100%] h-full w-[200%]">
{pane(true)}
</div>
</div>
</div>
) : (
pane(dark)
)}
</motion.div>
</div>
<p className="mt-2 text-center text-xs text-muted-foreground">{split ? "Follows your system setting" : `${dark ? "Dark" : "Light"} mode`}</p>
</div>
);
}
/** Deterministic PRNG so the burst is identical on server and client. */
function mulberry32(seed: number) {
return () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function Confetti({ colors, count = 70 }: { colors: string[]; count?: number }) {
const reduce = useReducedMotion();
const pieces = React.useMemo(() => {
const r = mulberry32(42);
return Array.from({ length: count }, (_, i) => {
const angle = -Math.PI / 2 + (r() - 0.5) * Math.PI * 1.3;
const power = 180 + r() * 220;
return {
i,
x: Math.cos(angle) * power,
y: Math.sin(angle) * power,
fall: 220 + r() * 200,
rot: (r() - 0.5) * 900,
w: 5 + r() * 6,
h: r() > 0.5 ? 5 + r() * 4 : 10 + r() * 6,
round: r() > 0.7,
delay: r() * 0.15,
dur: 1.6 + r() * 1,
color: colors[i % colors.length],
};
});
}, [colors, count]);
if (reduce) return null;
return (
<div className="pointer-events-none absolute left-1/2 top-[38%] z-10" aria-hidden>
{pieces.map((p) => (
<motion.span
key={p.i}
className="absolute block"
style={{ width: p.w, height: p.h, background: p.color, borderRadius: p.round ? 999 : 2, left: -p.w / 2, top: -p.h / 2 }}
initial={{ x: 0, y: 0, rotate: 0, opacity: 1, scale: 0.4 }}
animate={{ x: [0, p.x, p.x * 1.15], y: [0, p.y, p.y + p.fall], rotate: p.rot, opacity: [1, 1, 0], scale: 1 }}
transition={{ duration: p.dur, delay: p.delay, times: [0, 0.35, 1], ease: ["easeOut", "easeIn"] }}
/>
))}
</div>
);
}