Fazekit

Code

"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>
  );
}

More in Auth & Onboarding

View all →