199 lines
7.6 KiB
TypeScript
199 lines
7.6 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import { useI18n } from '@/lib/hooks/use-i18n';
|
|
import { ArrowRight, ShieldCheck, LoaderCircle } from 'lucide-react';
|
|
|
|
interface AccessCodeModalProps {
|
|
open: boolean;
|
|
onSuccess: () => void;
|
|
}
|
|
|
|
export function AccessCodeModal({ open, onSuccess }: AccessCodeModalProps) {
|
|
const { t } = useI18n();
|
|
const [code, setCode] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [success, setSuccess] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (open && inputRef.current) {
|
|
inputRef.current.focus();
|
|
}
|
|
}, [open]);
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!code || loading) return;
|
|
setError('');
|
|
setLoading(true);
|
|
|
|
try {
|
|
const res = await fetch('/api/access-code/verify', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code }),
|
|
});
|
|
|
|
if (res.ok) {
|
|
setSuccess(true);
|
|
setTimeout(onSuccess, 600);
|
|
} else {
|
|
setError(t('accessCode.error'));
|
|
setCode('');
|
|
inputRef.current?.focus();
|
|
}
|
|
} catch {
|
|
setError(t('accessCode.error'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{open && (
|
|
<motion.div
|
|
className="fixed inset-0 z-[200] flex items-center justify-center overflow-hidden"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0, transition: { duration: 0.3 } }}
|
|
>
|
|
{/* Background — subtle mesh gradient */}
|
|
<div className="absolute inset-0 bg-background">
|
|
<div
|
|
className="absolute inset-0 opacity-30 dark:opacity-20"
|
|
style={{
|
|
backgroundImage: `
|
|
radial-gradient(ellipse 80% 60% at 20% 40%, var(--primary) 0%, transparent 60%),
|
|
radial-gradient(ellipse 60% 80% at 80% 20%, oklch(0.6 0.15 280) 0%, transparent 50%),
|
|
radial-gradient(ellipse 50% 50% at 60% 80%, oklch(0.5 0.12 300) 0%, transparent 50%)
|
|
`,
|
|
}}
|
|
/>
|
|
{/* Subtle noise texture */}
|
|
<div
|
|
className="absolute inset-0 opacity-[0.03] dark:opacity-[0.05]"
|
|
style={{
|
|
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
|
|
backgroundSize: '128px 128px',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Content card */}
|
|
<motion.div
|
|
className="relative z-10 w-full max-w-sm mx-4"
|
|
initial={{ opacity: 0, y: 20, scale: 0.96 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: -20, scale: 0.96 }}
|
|
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
|
>
|
|
<div className="rounded-2xl border border-border/50 bg-card/80 p-8 shadow-xl shadow-black/5 backdrop-blur-xl dark:bg-card/60 dark:shadow-black/20">
|
|
{/* Icon */}
|
|
<motion.div
|
|
className="mx-auto mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-primary/10"
|
|
initial={{ scale: 0.5, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
transition={{ delay: 0.15, duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
|
>
|
|
<ShieldCheck className="h-7 w-7 text-primary" strokeWidth={1.5} />
|
|
</motion.div>
|
|
|
|
{/* Title */}
|
|
<motion.h1
|
|
className="mb-1 text-center text-lg font-semibold tracking-tight text-foreground"
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.2, duration: 0.4 }}
|
|
>
|
|
{t('accessCode.title')}
|
|
</motion.h1>
|
|
|
|
<motion.p
|
|
className="mb-6 text-center text-sm text-muted-foreground"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.25, duration: 0.4 }}
|
|
>
|
|
OpenMAIC
|
|
</motion.p>
|
|
|
|
{/* Form */}
|
|
<motion.form
|
|
onSubmit={handleSubmit}
|
|
className="space-y-4"
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.3, duration: 0.4 }}
|
|
>
|
|
<div className="relative">
|
|
<input
|
|
ref={inputRef}
|
|
type="password"
|
|
placeholder={t('accessCode.placeholder')}
|
|
value={code}
|
|
onChange={(e) => {
|
|
setCode(e.target.value);
|
|
if (error) setError('');
|
|
}}
|
|
className={`
|
|
w-full rounded-xl border bg-background/60 px-4 py-3 pr-12 text-sm
|
|
outline-none transition-all duration-200
|
|
placeholder:text-muted-foreground/50
|
|
focus:border-primary/40 focus:ring-2 focus:ring-primary/10
|
|
${error ? 'border-destructive/50 focus:border-destructive/50 focus:ring-destructive/10' : 'border-border/60'}
|
|
`}
|
|
disabled={loading || success}
|
|
autoComplete="off"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={!code || loading || success}
|
|
className={`
|
|
absolute right-2 top-1/2 -translate-y-1/2 flex h-8 w-8 items-center
|
|
justify-center rounded-lg transition-all duration-200
|
|
${code && !loading && !success ? 'bg-primary text-primary-foreground hover:opacity-90 cursor-pointer' : 'text-muted-foreground/30 cursor-default'}
|
|
`}
|
|
>
|
|
{loading ? (
|
|
<LoaderCircle className="h-4 w-4 animate-spin" />
|
|
) : success ? (
|
|
<motion.div
|
|
initial={{ scale: 0 }}
|
|
animate={{ scale: 1 }}
|
|
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
|
>
|
|
<ShieldCheck className="h-4 w-4 text-emerald-500" />
|
|
</motion.div>
|
|
) : (
|
|
<ArrowRight className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error message */}
|
|
<AnimatePresence mode="wait">
|
|
{error && (
|
|
<motion.p
|
|
className="text-center text-sm text-destructive"
|
|
initial={{ opacity: 0, y: -4, height: 0 }}
|
|
animate={{ opacity: 1, y: 0, height: 'auto' }}
|
|
exit={{ opacity: 0, y: -4, height: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
{error}
|
|
</motion.p>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.form>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|