"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowLeft, ArrowRight, Check, Loader2, Mail, RotateCw } from "lucide-react";
import { cn } from "@/lib/utils";
type Step = "email" | "sent" | "code" | "success";
export interface AuthMagicLinkProps {
brand?: string;
/** Called when the user asks for a link/code. Throw to show an error. */
onSendLink?: (email: string) => Promise<void>;
/** Return true when the code is valid. Demo default accepts 123456. */
onVerify?: (email: string, code: string) => Promise<boolean>;
/** Seconds before "Resend" becomes available. */
resendAfter?: number;
codeLength?: number;
/** Show the demo code hint under the OTP boxes. */
showDemoHint?: boolean;
className?: string;
}
const emailOk = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v.trim());
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
export function AuthMagicLink({
brand = "Orbit",
onSendLink,
onVerify,
resendAfter = 30,
codeLength = 6,
showDemoHint = true,
className,
}: AuthMagicLinkProps) {
const reduce = useReducedMotion();
const uid = React.useId();
const [step, setStep] = React.useState<Step>("email");
const [email, setEmail] = React.useState("");
const [emailError, setEmailError] = React.useState("");
const [sending, setSending] = React.useState(false);
const [digits, setDigits] = React.useState<string[]>(() => Array(codeLength).fill(""));
const [codeError, setCodeError] = React.useState("");
const [verifying, setVerifying] = React.useState(false);
const [shake, setShake] = React.useState(0);
const [countdown, setCountdown] = React.useState(0);
const [announce, setAnnounce] = React.useState("");
const inputs = React.useRef<Array<HTMLInputElement | null>>([]);
React.useEffect(() => {
if (countdown <= 0) return;
const t = setTimeout(() => setCountdown((c) => c - 1), 1000);
return () => clearTimeout(t);
}, [countdown]);
const send = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!emailOk(email)) {
setEmailError(email.trim() ? "That doesn't look like a valid email." : "Enter your email to continue.");
return;
}
setEmailError("");
setSending(true);
try {
if (onSendLink) await onSendLink(email.trim());
else await wait(1000);
setStep("sent");
setCountdown(resendAfter);
setAnnounce(`We sent a sign-in link to ${email.trim()}.`);
} catch (err) {
setEmailError(err instanceof Error ? err.message : "Couldn't send the link. Try again.");
} finally {
setSending(false);
}
};
const resend = async () => {
if (countdown > 0) return;
setCountdown(resendAfter);
setDigits(Array(codeLength).fill(""));
setCodeError("");
if (onSendLink) await onSendLink(email.trim());
setAnnounce("A new code is on its way.");
};
const verify = async (code: string) => {
setVerifying(true);
setCodeError("");
const ok = onVerify ? await onVerify(email.trim(), code) : (await wait(900), code === "123456");
setVerifying(false);
if (ok) {
setStep("success");
setAnnounce("Code verified. You're signed in.");
} else {
setCodeError("That code didn't match. Check the email and try again.");
setShake((s) => s + 1);
setDigits(Array(codeLength).fill(""));
setTimeout(() => inputs.current[0]?.focus(), 30);
}
};
const fill = (start: number, chars: string) => {
const clean = chars.replace(/\D/g, "").slice(0, codeLength - start);
if (!clean) return;
const next = [...digits];
clean.split("").forEach((c, i) => (next[start + i] = c));
setDigits(next);
setCodeError("");
const focusAt = Math.min(codeLength - 1, start + clean.length);
inputs.current[focusAt]?.focus();
if (next.every(Boolean)) verify(next.join(""));
};
const onKey = (i: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Backspace") {
e.preventDefault();
const next = [...digits];
if (next[i]) next[i] = "";
else if (i > 0) {
next[i - 1] = "";
inputs.current[i - 1]?.focus();
}
setDigits(next);
} else if (e.key === "ArrowLeft" && i > 0) {
e.preventDefault();
inputs.current[i - 1]?.focus();
} else if (e.key === "ArrowRight" && i < codeLength - 1) {
e.preventDefault();
inputs.current[i + 1]?.focus();
}
};
const reset = () => {
setStep("email");
setDigits(Array(codeLength).fill(""));
setCodeError("");
};
const slide = reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
: {
initial: { opacity: 0, x: 24, filter: "blur(4px)" },
animate: { opacity: 1, x: 0, filter: "blur(0px)" },
exit: { opacity: 0, x: -24, filter: "blur(4px)" },
};
const mm = String(Math.floor(countdown / 60)).padStart(1, "0");
const ss = String(countdown % 60).padStart(2, "0");
return (
<section className={cn("relative flex min-h-[720px] w-full items-center justify-center overflow-hidden bg-background px-4 py-12 text-foreground", className)}>
{/* backdrop */}
<div aria-hidden className="pointer-events-none absolute inset-0">
<div className="absolute left-1/2 top-[-20%] h-[520px] w-[820px] -translate-x-1/2 rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--primary)_22%,transparent),transparent)]" />
<div
className="absolute inset-0 opacity-60 dark:opacity-40"
style={{
backgroundImage: "radial-gradient(color-mix(in oklab, var(--foreground) 14%, transparent) 1px, transparent 1px)",
backgroundSize: "22px 22px",
maskImage: "radial-gradient(ellipse at center, black 30%, transparent 70%)",
WebkitMaskImage: "radial-gradient(ellipse at center, black 30%, transparent 70%)",
}}
/>
</div>
<p className="sr-only" aria-live="polite">
{announce}
</p>
<div className="relative w-full max-w-[420px]">
<div className="mb-6 flex items-center justify-center gap-2">
<span className="grid size-8 place-items-center rounded-lg bg-foreground text-background">
<svg viewBox="0 0 24 24" className="size-4" aria-hidden>
<circle cx="12" cy="12" r="4" fill="currentColor" />
<ellipse cx="12" cy="12" rx="10" ry="4.5" fill="none" stroke="currentColor" strokeWidth="1.8" transform="rotate(-25 12 12)" />
</svg>
</span>
<span className="font-semibold tracking-tight">{brand}</span>
</div>
<div className="overflow-hidden rounded-2xl border bg-card/90 text-card-foreground shadow-[0_1px_2px_rgba(0,0,0,.04),0_20px_50px_-20px_rgba(0,0,0,.25)] backdrop-blur-xl">
<div className="h-1 bg-muted">
<motion.div
className="h-full bg-primary"
initial={false}
animate={{ width: `${({ email: 25, sent: 50, code: 75, success: 100 } as const)[step]}%` }}
transition={{ type: "spring", stiffness: 200, damping: 30 }}
/>
</div>
<div className="p-6 sm:p-8">
<AnimatePresence mode="wait" initial={false}>
{step === "email" && (
<motion.div key="email" {...slide} transition={{ duration: 0.25 }}>
<h1 className="text-xl font-semibold tracking-tight">Sign in without a password</h1>
<p className="mt-1.5 text-sm text-muted-foreground">We'll email you a magic link and a one-time code.</p>
<form noValidate onSubmit={send} className="mt-6 space-y-3">
<label htmlFor={`${uid}-email`} className="text-sm font-medium">
Email address
</label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
id={`${uid}-email`}
type="email"
inputMode="email"
autoComplete="email"
autoFocus
value={email}
onChange={(e) => {
setEmail(e.target.value);
setEmailError("");
}}
aria-invalid={!!emailError}
aria-describedby={emailError ? `${uid}-email-err` : undefined}
placeholder="[email protected]"
className={cn(
"h-11 w-full rounded-lg border bg-background pl-10 pr-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",
emailError && "border-destructive focus-visible:border-destructive focus-visible:ring-destructive/15",
)}
/>
</div>
{emailError && (
<p id={`${uid}-email-err`} role="alert" className="text-xs font-medium text-destructive">
{emailError}
</p>
)}
<button
type="submit"
disabled={sending}
className="group inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-primary text-sm font-medium text-primary-foreground 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 disabled:opacity-80"
>
{sending ? <Loader2 className="size-4 animate-spin" /> : null}
{sending ? "Sending link…" : "Email me a sign-in link"}
{!sending && <ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />}
</button>
</form>
<p className="mt-6 text-center text-xs text-muted-foreground">
By continuing you agree to the <a href="#terms" className="underline underline-offset-4 hover:text-foreground">Terms</a> and{" "}
<a href="#privacy" className="underline underline-offset-4 hover:text-foreground">Privacy Policy</a>.
</p>
</motion.div>
)}
{step === "sent" && (
<motion.div key="sent" {...slide} transition={{ duration: 0.25 }} className="text-center">
<Envelope reduce={!!reduce} />
<h1 className="mt-6 text-xl font-semibold tracking-tight">Check your inbox</h1>
<p className="mt-1.5 text-sm text-muted-foreground">
We sent a sign-in link to <span className="font-medium text-foreground">{email.trim()}</span>. It expires in 15 minutes.
</p>
<button
type="button"
onClick={() => setStep("code")}
className="mt-6 inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-primary text-sm font-medium text-primary-foreground 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"
>
Enter the {codeLength}-digit code instead
</button>
<div className="mt-3 flex items-center justify-between text-sm">
<button type="button" onClick={reset} className="inline-flex items-center gap-1.5 rounded px-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<ArrowLeft className="size-3.5" /> Use another email
</button>
<ResendButton countdown={countdown} label={`${mm}:${ss}`} onResend={resend} />
</div>
</motion.div>
)}
{step === "code" && (
<motion.div key="code" {...slide} transition={{ duration: 0.25 }}>
<button type="button" onClick={() => setStep("sent")} className="mb-4 inline-flex items-center gap-1.5 rounded text-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<ArrowLeft className="size-3.5" /> Back
</button>
<h1 className="text-xl font-semibold tracking-tight">Enter verification code</h1>
<p className="mt-1.5 text-sm text-muted-foreground">
Sent to <span className="font-medium text-foreground">{email.trim()}</span>
</p>
<fieldset className="mt-6" disabled={verifying}>
<legend className="sr-only">{codeLength}-digit verification code</legend>
<motion.div
key={shake}
animate={shake && !reduce ? { x: [0, -10, 9, -6, 4, 0] } : undefined}
transition={{ duration: 0.4 }}
className="flex justify-between gap-2"
>
{digits.map((d, i) => (
<React.Fragment key={i}>
{i === codeLength / 2 && <span className="self-center text-muted-foreground" aria-hidden>–</span>}
<input
ref={(el) => {
inputs.current[i] = el;
}}
value={d}
inputMode="numeric"
autoComplete={i === 0 ? "one-time-code" : "off"}
maxLength={1}
autoFocus={i === 0}
aria-label={`Digit ${i + 1} of ${codeLength}`}
aria-invalid={!!codeError}
aria-describedby={codeError ? `${uid}-code-err` : undefined}
onFocus={(e) => e.target.select()}
onKeyDown={(e) => onKey(i, e)}
onChange={(e) => {
const v = e.target.value.replace(/\D/g, "");
if (!v) return;
fill(i, v.slice(-1));
}}
onPaste={(e) => {
e.preventDefault();
fill(i, e.clipboardData.getData("text"));
}}
className={cn(
"h-13 w-full min-w-0 rounded-xl border bg-background text-center font-mono text-xl font-semibold tabular-nums shadow-xs outline-none transition sm:h-14",
"focus:border-ring focus:ring-4 focus:ring-ring/15",
d && "border-foreground/30",
codeError && "border-destructive focus:border-destructive focus:ring-destructive/15",
verifying && "opacity-60",
)}
/>
</React.Fragment>
))}
</motion.div>
</fieldset>
<div className="mt-3 min-h-5 text-xs" aria-live="assertive">
{verifying ? (
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" /> Verifying…
</span>
) : codeError ? (
<span id={`${uid}-code-err`} className="font-medium text-destructive">
{codeError}
</span>
) : showDemoHint ? (
<span className="text-muted-foreground">
Demo code: <span className="font-mono text-foreground">123456</span> — try pasting it.
</span>
) : null}
</div>
<div className="mt-5 flex items-center justify-between border-t pt-4 text-sm">
<span className="text-muted-foreground">Didn't get it?</span>
<ResendButton countdown={countdown} label={`${mm}:${ss}`} onResend={resend} />
</div>
</motion.div>
)}
{step === "success" && (
<motion.div key="success" initial={{ opacity: 0, scale: 0.96 }} animate={{ opacity: 1, scale: 1 }} className="py-4 text-center">
<div className="relative mx-auto size-16">
{!reduce &&
[0, 1].map((r) => (
<motion.span
key={r}
className="absolute inset-0 rounded-full border-2 border-emerald-500"
initial={{ scale: 1, opacity: 0.6 }}
animate={{ scale: 2, opacity: 0 }}
transition={{ duration: 1.2, delay: 0.2 + r * 0.25, ease: "easeOut" }}
/>
))}
<motion.div
initial={reduce ? false : { scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 14 }}
className="grid size-16 place-items-center rounded-full bg-emerald-500 text-white shadow-lg shadow-emerald-500/30"
>
<svg viewBox="0 0 24 24" className="size-8" aria-hidden>
<motion.path
d="M5 12.5l4.5 4.5L19 7.5"
fill="none"
stroke="currentColor"
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ delay: 0.25, duration: 0.4 }}
/>
</svg>
</motion.div>
</div>
<h1 className="mt-6 text-xl font-semibold tracking-tight">You're in</h1>
<p className="mt-1.5 text-sm text-muted-foreground">Signed in as {email.trim()}. Taking you to your dashboard…</p>
<button
type="button"
onClick={() => {
reset();
setEmail("");
}}
className="mt-6 inline-flex h-10 items-center gap-2 rounded-lg border px-4 text-sm font-medium transition hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Check className="size-4" /> Start over
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<p className="mt-5 text-center text-xs text-muted-foreground">
Having trouble? <a href="#help" className="font-medium text-foreground underline-offset-4 hover:underline">Contact support</a>
</p>
</div>
</section>
);
}
function ResendButton({ countdown, label, onResend }: { countdown: number; label: string; onResend: () => void }) {
return (
<button
type="button"
onClick={onResend}
disabled={countdown > 0}
className="inline-flex items-center gap-1.5 rounded px-1 font-medium text-primary transition enabled:hover:underline disabled:cursor-not-allowed disabled:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring underline-offset-4"
>
<RotateCw className="size-3.5" />
{countdown > 0 ? (
<span>
Resend in <span className="tabular-nums">{label}</span>
</span>
) : (
"Resend code"
)}
</button>
);
}
function Envelope({ reduce }: { reduce: boolean }) {
// Letter slides into the envelope, flap closes, envelope floats with a soft shadow.
const t = (d: number, dur = 0.5) => (reduce ? { duration: 0 } : { delay: d, duration: dur, ease: [0.22, 1, 0.36, 1] as const });
return (
<div className="relative mx-auto h-36 w-44" aria-hidden>
<motion.div
className="absolute inset-0"
animate={reduce ? undefined : { y: [0, -6, 0] }}
transition={{ duration: 3.2, repeat: Infinity, ease: "easeInOut", delay: 1.4 }}
>
<svg viewBox="0 0 176 144" className="h-full w-full overflow-visible">
<defs>
<linearGradient id="aml-env" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--primary)" stopOpacity="0.95" />
<stop offset="1" stopColor="var(--primary)" stopOpacity="0.75" />
</linearGradient>
</defs>
{/* back of envelope */}
<rect x="18" y="52" width="140" height="84" rx="10" fill="var(--primary)" opacity="0.35" />
{/* open flap, behind the letter */}
<motion.path
d="M18 62 L88 18 L158 62 Z"
fill="var(--primary)"
opacity="0.55"
style={{ transformOrigin: "50% 100%", transformBox: "fill-box" }}
initial={reduce ? { scaleY: 0 } : { scaleY: 1 }}
animate={{ scaleY: 0 }}
transition={t(0.8, 0.22)}
/>
{/* letter */}
<motion.g initial={reduce ? false : { y: -52 }} animate={{ y: 0 }} transition={t(0.15, 0.7)}>
<rect x="32" y="30" width="112" height="84" rx="6" fill="var(--card)" stroke="var(--border)" />
<rect x="44" y="44" width="54" height="7" rx="3.5" fill="var(--primary)" opacity="0.8" />
<rect x="44" y="58" width="86" height="5" rx="2.5" fill="var(--muted-foreground)" opacity="0.35" />
<rect x="44" y="68" width="70" height="5" rx="2.5" fill="var(--muted-foreground)" opacity="0.35" />
</motion.g>
{/* front pocket */}
<path d="M18 62 L88 104 L158 62 V126 a10 10 0 0 1 -10 10 H28 a10 10 0 0 1 -10 -10 Z" fill="url(#aml-env)" />
<path d="M18 132 L72 94 M158 132 L104 94" stroke="white" strokeOpacity="0.25" strokeWidth="2" />
{/* flap */}
<motion.path
d="M18 62 L88 108 L158 62 Z"
fill="var(--primary)"
style={{ transformOrigin: "50% 0%", transformBox: "fill-box" }}
initial={reduce ? false : { scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={t(1.0, 0.3)}
/>
{/* notification badge */}
<motion.g initial={reduce ? false : { scale: 0, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={reduce ? { duration: 0 } : { delay: 1.2, type: "spring", stiffness: 400, damping: 14 }} style={{ transformOrigin: "50% 50%", transformBox: "fill-box" }}>
<circle cx="154" cy="56" r="13" fill="#10b981" stroke="var(--card)" strokeWidth="4" />
<path d="M148.5 56.5l3.8 3.6 7-7.4" fill="none" stroke="white" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
</motion.g>
</svg>
</motion.div>
<motion.div
className="absolute -bottom-3 left-1/2 h-3 w-28 -translate-x-1/2 rounded-full bg-foreground/10 blur-md"
animate={reduce ? undefined : { scaleX: [1, 0.85, 1], opacity: [0.8, 0.5, 0.8] }}
transition={{ duration: 3.2, repeat: Infinity, ease: "easeInOut", delay: 1.4 }}
/>
{!reduce &&
[
[10, 30, 0.2],
[150, 16, 0.5],
[164, 100, 0.9],
[4, 96, 1.1],
].map(([x, y, d], i) => (
<motion.span
key={i}
className="absolute size-1.5 rounded-full bg-primary"
style={{ left: x, top: y }}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: [0, 1, 0], opacity: [0, 1, 0] }}
transition={{ duration: 1.6, delay: 1 + d, repeat: Infinity, repeatDelay: 1.4 }}
/>
))}
</div>
);
}