"use client"; import { ArrowLeftIcon, ArrowRightIcon, CheckIcon, RotateCcwIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type FormEvent, type ReactNode, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; export interface StepFlowData { name: string; email: string; workspace: string; slug: string; teamSize: string; goals: string[]; } export interface StepFlowProps { takenSlugs?: string[]; teamSizes?: string[]; goals?: string[]; onComplete?: (data: StepFlowData) => void | Promise; className?: string; } const STEPS = ["Profile", "Workspace", "Goals"] as const; const EMPTY: StepFlowData = { name: "", email: "", workspace: "", slug: "", teamSize: "", goals: [] }; const slugify = (s: string) => s .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 32); type Errors = Partial>; function validate(step: number, data: StepFlowData, taken: string[]): Errors { const errors: Errors = {}; if (step === 0) { if (data.name.trim().length < 2) errors.name = "Tell us what to call you."; if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(data.email)) errors.email = "That email doesn’t look right."; } if (step === 1) { if (data.workspace.trim().length < 2) errors.workspace = "Give your workspace a name."; if (!/^[a-z0-9][a-z0-9-]{1,31}$/.test(data.slug)) errors.slug = "Use 2–32 lowercase letters, numbers or dashes."; else if (taken.includes(data.slug)) errors.slug = `${data.slug}.devl.app is taken. Try another.`; if (!data.teamSize) errors.teamSize = "Pick a team size."; } if (step === 2 && data.goals.length === 0) errors.goals = "Choose at least one."; return errors; } export function StepFlow({ takenSlugs = ["acme", "studio", "team"], teamSizes = ["Just me", "2–10", "11–50", "51+"], goals: goalOptions = ["Ship faster", "Plan sprints", "Track bugs", "Write docs", "Run standups", "Share roadmaps"], onComplete, className, }: StepFlowProps) { const [step, setStep] = useState(0); const [direction, setDirection] = useState(1); const [data, setData] = useState(EMPTY); const [touched, setTouched] = useState>>({}); const [slugEdited, setSlugEdited] = useState(false); const [submitting, setSubmitting] = useState(false); const [done, setDone] = useState(false); const reduce = useReducedMotion(); const id = useId(); const errors = validate(step, data, takenSlugs); const shown = (key: keyof StepFlowData) => (touched[key] ? errors[key] : undefined); const set = (key: K, value: StepFlowData[K]) => setData((d) => { const next = { ...d, [key]: value }; if (key === "workspace" && !slugEdited) next.slug = slugify(value as string); return next; }); const go = (to: number) => { setDirection(to > step ? 1 : -1); setStep(to); }; const submit = async (event: FormEvent) => { event.preventDefault(); if (Object.keys(errors).length) { setTouched((t) => ({ ...t, ...Object.fromEntries(Object.keys(errors).map((k) => [k, true])) })); return; } if (step < STEPS.length - 1) return go(step + 1); setSubmitting(true); await onComplete?.(data); setSubmitting(false); setDirection(1); setDone(true); }; const reset = () => { setData(EMPTY); setTouched({}); setSlugEdited(false); setDone(false); setDirection(-1); setStep(0); }; const contentRef = useRef(null); const [height, setHeight] = useState("auto"); useLayoutEffect(() => { const el = contentRef.current; if (!el) return; const observer = new ResizeObserver(() => setHeight(el.offsetHeight)); observer.observe(el); return () => observer.disconnect(); }, []); const stepKey = done ? "done" : `step-${step}`; const shownKey = useRef(stepKey); useEffect(() => { if (shownKey.current === stepKey) return; const t = window.setTimeout(() => { shownKey.current = stepKey; const target = contentRef.current ?.querySelector(`[data-step="${stepKey}"]`) ?.querySelector("input, [role=radio], [aria-pressed], button"); target?.focus({ preventScroll: true }); }, 60); return () => clearTimeout(t); }, [stepKey]); const field = (key: keyof StepFlowData) => ({ id: `${id}-${key}`, "aria-invalid": shown(key) ? true : undefined, "aria-describedby": shown(key) ? `${id}-${key}-error` : undefined, onBlur: () => setTouched((t) => ({ ...t, [key]: true })), }); const slide = { enter: (d: number) => ({ x: reduce ? 0 : d * 48, opacity: 0, filter: reduce ? "none" : "blur(4px)" }), center: { x: 0, opacity: 1, filter: "blur(0px)" }, exit: (d: number) => ({ x: reduce ? 0 : d * -48, opacity: 0, filter: reduce ? "none" : "blur(4px)" }), }; return (
{done ? ( ) : (
{step === 0 && ( <> Nice to meet you set("name", e.target.value)} placeholder="Mara Okafor" value={data.name} /> set("email", e.target.value)} placeholder="mara@northwind.dev" type="email" value={data.email} /> )} {step === 1 && ( <> Create your workspace set("workspace", e.target.value)} placeholder="Northwind Labs" value={data.workspace} />
{ setSlugEdited(true); set("slug", e.target.value.toLowerCase()); }} placeholder="northwind" value={data.slug} /> .devl.app
{ set("teamSize", v); setTouched((t) => ({ ...t, teamSize: true })); }} options={teamSizes} selected={[data.teamSize]} single /> )} {step === 2 && ( <> What brings you here? { set("goals", data.goals.includes(v) ? data.goals.filter((g) => g !== v) : [...data.goals, v]); setTouched((t) => ({ ...t, goals: true })); }} options={goalOptions} selected={data.goals} /> )}
{step > 0 ? ( ) : ( Step {step + 1} of {STEPS.length} )}
)}
); } function Heading({ children, sub }: { children: ReactNode; sub: string }) { return (

{children}

{sub}

); } function ErrorText({ id, error }: { id?: string; error?: string }) { return ( {error && ( {error} )} ); } function Row({ id, label, error, children }: { id: string; label: string; error?: string; children: ReactNode }) { return (
{children}
); } function ChoiceRow({ label, options, selected, onToggle, single, error, }: { label: string; options: string[]; selected: string[]; onToggle: (value: string) => void; single?: boolean; error?: string; }) { return (
{label}
{options.map((option) => { const on = selected.includes(option); return ( ); })}
); } function StepIndicator({ steps, current }: { steps: string[]; current: number }) { const reduce = useReducedMotion(); const filterId = `goo-steps-${useId().replace(/:/g, "")}`; const last = steps.length - 1; const fill = Math.min(current, last) / last; const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 120, damping: 20 }; const at = (i: number) => `calc(14px + (100% - 28px) * ${i / last})`; return (
{steps.map((s, i) => ( ))}
{steps.map((s, i) => ( 0 ? 0.28 : 0 } } /> ))}
{steps.map((s, i) => ( {i < current ? ( ) : ( {i + 1} )} ))}
    {steps.map((s, i) => (
  1. {s}
  2. ))}
); } function Success({ data, onReset }: { data: StepFlowData; onReset: () => void }) { const reduce = useReducedMotion(); const [ready, setReady] = useState(false); useEffect(() => { const t = window.setTimeout(() => setReady(true), 50); return () => clearTimeout(t); }, []); return (

You’re in, {data.name.split(" ")[0]}

{data.slug}.devl.app is ready for {data.teamSize === "Just me" ? "you" : `your team of ${data.teamSize}`}.

    {data.goals.map((g, i) => ( {g} ))}
); }