"use client"; import { LockIcon, LockOpenIcon, ScanLineIcon, ShieldXIcon } from "lucide-react"; import { AnimatePresence, animate as animateValue, motion, useAnimate, useInView, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; import { useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export type ShieldLockHue = "sky" | "emerald" | "amber" | "violet" | "rose" | "orange" | "indigo" | "teal"; const hues: Record = { sky: "[--hue-hi:var(--color-sky-400)] [--hue:var(--color-sky-500)] [--hue-lo:var(--color-sky-600)] [--hue-deep:var(--color-sky-800)]", emerald: "[--hue-hi:var(--color-emerald-400)] [--hue:var(--color-emerald-500)] [--hue-lo:var(--color-emerald-600)] [--hue-deep:var(--color-emerald-800)]", amber: "[--hue-hi:var(--color-amber-400)] [--hue:var(--color-amber-500)] [--hue-lo:var(--color-amber-600)] [--hue-deep:var(--color-amber-800)]", violet: "[--hue-hi:var(--color-violet-400)] [--hue:var(--color-violet-500)] [--hue-lo:var(--color-violet-600)] [--hue-deep:var(--color-violet-800)]", rose: "[--hue-hi:var(--color-rose-400)] [--hue:var(--color-rose-500)] [--hue-lo:var(--color-rose-600)] [--hue-deep:var(--color-rose-800)]", orange: "[--hue-hi:var(--color-orange-400)] [--hue:var(--color-orange-500)] [--hue-lo:var(--color-orange-600)] [--hue-deep:var(--color-orange-800)]", indigo: "[--hue-hi:var(--color-indigo-400)] [--hue:var(--color-indigo-500)] [--hue-lo:var(--color-indigo-600)] [--hue-deep:var(--color-indigo-800)]", teal: "[--hue-hi:var(--color-teal-400)] [--hue:var(--color-teal-500)] [--hue-lo:var(--color-teal-600)] [--hue-deep:var(--color-teal-800)]", }; export type ShieldLockStatus = "open" | "scanning" | "locked" | "denied"; export interface ShieldLockProps { status?: ShieldLockStatus; onRequest?: (next: "locked" | "open") => void; hue?: ShieldLockHue; labels?: Partial>; className?: string; } const defaultLabels: Record = { open: "Vault unlocked", scanning: "Checking passkey…", locked: "Vault locked", denied: "Passkey not recognised", }; const icons = { open: LockOpenIcon, scanning: ScanLineIcon, locked: LockIcon, denied: ShieldXIcon }; const script: { status: ShieldLockStatus; ms: number }[] = [ { status: "open", ms: 1400 }, { status: "scanning", ms: 1300 }, { status: "locked", ms: 2800 }, { status: "open", ms: 1400 }, { status: "scanning", ms: 1300 }, { status: "denied", ms: 1700 }, ]; const W = 400; const H = 300; const SHIELD = "M200 34 C 230 50 262 58 292 58 V 146 C 292 206 254 246 200 270 C 146 246 108 206 108 146 V 58 C 138 58 170 50 200 34 Z"; const INNER = "M200 52 C 226 65 254 72 278 72 V 146 C 278 197 246 231 200 252 C 154 231 122 197 122 146 V 72 C 146 72 174 65 200 52 Z"; const WAVE = `M0 0 ${"q12.5 -5 25 0 q12.5 5 25 0 ".repeat(9)}V 320 H 0 Z`; const levels: Record = { open: 0.2, scanning: 0.86, locked: 0.92, denied: 0.2 }; function Padlock({ shut, denied, reduce, round, tone, }: { shut: boolean; denied: boolean; reduce: boolean; round: number; tone: "dry" | "wet" }) { const wet = tone === "wet"; return ( {wet && } ); } function useCalmMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } export function ShieldLock({ status, onRequest, hue = "sky", labels, className }: ShieldLockProps) { const uid = useId().replace(/:/g, ""); const reduce = useCalmMotion(); const copy = { ...defaultLabels, ...labels }; const [demo, setDemo] = useState(status === undefined); const [step, setStep] = useState(0); const [local, setLocal] = useState("open"); const current: ShieldLockStatus = status ?? (demo ? script[step].status : local); const [scope, animate] = useAnimate(); const [round, setRound] = useState(0); const prev = useRef(current); const root = useRef(null); const inView = useInView(root); const live = inView && !reduce; const level = useMotionValue(levels[current]); const surface = useTransform(level, (l) => 262 - l * 222); useEffect(() => { const target = levels[current]; if (reduce) { level.set(target); return; } const c = current === "scanning" ? animateValue(level, target, { duration: 1.2, ease: [0.65, 0, 0.35, 1] }) : current === "denied" ? animateValue(level, target, { delay: 0.35, duration: 0.9, ease: [0.65, 0, 0.35, 1] }) : animateValue(level, target, { type: "spring", stiffness: 140, damping: 20 }); return () => c.stop(); }, [current, reduce, level]); useEffect(() => { if (!demo || status !== undefined || !inView) return; const t = setTimeout(() => setStep((s) => (s + 1) % script.length), script[step].ms); return () => clearTimeout(t); }, [demo, status, step, inView]); useEffect(() => { if (demo || status !== undefined || local !== "scanning") return; const t = setTimeout(() => setLocal("locked"), 1300); return () => clearTimeout(t); }, [demo, status, local]); useEffect(() => { if (prev.current === current) return; prev.current = current; if (current === "locked" || current === "denied") setRound((r) => r + 1); if (current === "denied" && !reduce && scope.current) { animate(scope.current, { x: [0, -9, 8, -6, 4, -2, 0] }, { duration: 0.5, ease: "easeOut" }); } }, [current, reduce, animate, scope]); const click = () => { const next = current === "locked" ? "open" : "locked"; onRequest?.(next); if (status !== undefined) return; setDemo(false); setLocal(next === "locked" ? "scanning" : "open"); }; const shut = current === "locked"; const denied = current === "denied"; const Icon = icons[current]; return (
{copy[current]}
); }