SkateSpots/src/components/auth/SignupDialog.tsx
SkateSpots b89136161c feat(onboarding): clearer signup steps, backup confirmation, home area
Signup and onboarding are the first impression, so tighten both.

Signup (SignupDialog):
- Add a Choose -> Profile -> Save key step indicator.
- Make backup a confirmation, not a skip: an "I've saved my recovery code"
  checkbox enables "Let's skate" (downloading the key also sets it); the old
  co-equal "Skip for now" is now a quiet link.
- Fix copy that contradicted step 3 ("nothing to remember") and warn that a
  signer may ask to approve the profile publish.
- Platform-aware signer nudge instead of implying a recommendation.

LoginArea: drop the dead SignupMethodDialog/MnemonicSignupDialog wiring (never
opened) and rename the misleading nsecSignupDialogOpen state.

Onboarding: add a "Where do you skate?" first step (Use my location caches the
fix so the map opens on the skater's area) and remove the dead disabled
"Log in first" button.
2026-10-01 09:44:47 +07:00

791 lines
34 KiB
TypeScript

// NOTE: This file is stable and usually should not be modified.
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
import React, { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { Download, Key, Shield, User, Sparkles, LogIn, CheckCircle, Upload, Smartphone } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Card, CardContent } from '@/components/ui/card';
import { toast } from '@/hooks/useToast';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { generateSecretKey, nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils';
import { SkateNip05Input } from '@/components/SkateNip05Input';
import { useSkateNip05 } from '@/hooks/useSkateNip05';
import { deriveSkateNip05Handle, validateSkateNip05Name } from '@/lib/skateNip05';
interface SignupDialogProps {
isOpen: boolean;
onClose: () => void;
onComplete?: () => void;
/** Switch the user over to the Amber (NIP-55) login flow — the recommended,
* more-secure path where the private key lives inside the Amber app. */
onUseAmber?: () => void;
}
const sanitizeFilename = (filename: string) => {
return filename.replace(/[^a-z0-9_.-]/gi, '_');
}
// The signup flow is deliberately finite and visible: a newcomer should always
// know there are three steps and how far they've got. Labels avoid jargon
// ("Save key", not "Backup nsec").
const STEP_ORDER = ['welcome', 'profile', 'done'] as const;
type SignupStep = typeof STEP_ORDER[number];
const STEP_LABELS: Record<SignupStep, string> = {
welcome: 'Choose',
profile: 'Profile',
done: 'Save key',
};
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete, onUseAmber }) => {
const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent);
const [step, setStep] = useState<SignupStep>('welcome');
const [nsec, setNsec] = useState('');
const [keySecured, setKeySecured] = useState<'none' | 'downloaded'>('none');
const [showNsec, setShowNsec] = useState(false);
// The recovery-code acknowledgement. Saving the key is the one irreversible
// step, so "Let's skate" stays locked until the user confirms it's saved (or
// downloaded it). The escape hatch stays, but it's a quiet link — never
// presented as an equal choice to saving.
const [backupAck, setBackupAck] = useState(false);
const [profileData, setProfileData] = useState({
name: '',
about: '',
picture: ''
});
// The skater's chosen `name@skate-spots.org` handle. Claimed separately from
// the profile publish (and never required — a failure just means no handle).
const [nip05Handle, setNip05Handle] = useState('');
const [nip05HandleEdited, setNip05HandleEdited] = useState(false);
const [claimedNip05, setClaimedNip05] = useState<string | null>(null);
const login = useLoginActions();
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { claim } = useSkateNip05();
const avatarFileInputRef = useRef<HTMLInputElement>(null);
// Shared source of truth: once the recovery code is saved here, mark backup
// confirmed so the gentle post-signup reminder never nags a safe user.
const [, setBackupConfirmed] = useLocalStorage('backup-confirmed', false);
// Silently generate a key and log the user in immediately.
// No forced download, no crypto vocabulary — the account "just works".
// Backup is deferred to the BackupReminder (shown a couple seconds after login).
const createAccountSilently = () => {
try {
const sk = generateSecretKey();
const newNsec = nip19.nsecEncode(sk);
setNsec(newNsec);
login.nsec(newNsec);
return true;
} catch {
toast({
title: 'Something went wrong',
description: "Couldn't create your account. Please try again.",
variant: 'destructive',
});
return false;
}
};
const downloadKey = () => {
try {
// Create a blob with the key text
const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' });
const url = globalThis.URL.createObjectURL(blob);
// Sanitize filename
const filename = sanitizeFilename('nostr-nsec-key.txt');
// Create a temporary link element and trigger download
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
// Clean up immediately
globalThis.URL.revokeObjectURL(url);
document.body.removeChild(a);
// Mark as secured — and record a confirmed backup so the reminder stops.
setKeySecured('downloaded');
setBackupAck(true);
setBackupConfirmed(true);
toast({
title: 'Secret Key Saved!',
description: 'Your key has been safely stored.',
});
} catch {
toast({
title: 'Download failed',
description: 'Could not download the key file. Please copy it manually.',
variant: 'destructive',
});
}
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Reset file input
e.target.value = '';
// Validate file type
if (!file.type.startsWith('image/')) {
toast({
title: 'Invalid file type',
description: 'Please select an image file for your avatar.',
variant: 'destructive',
});
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
toast({
title: 'File too large',
description: 'Avatar image must be smaller than 5MB.',
variant: 'destructive',
});
return;
}
try {
const tags = await uploadFile(file);
// Get the URL from the first tag
const url = tags[0]?.[1];
if (url) {
setProfileData(prev => ({ ...prev, picture: url }));
toast({
title: 'Avatar uploaded!',
description: 'Your avatar has been uploaded successfully.',
});
}
} catch {
toast({
title: 'Upload failed',
description: 'Failed to upload avatar. Please try again.',
variant: 'destructive',
});
}
};
const finishSignup = async (skipProfile = false) => {
// A display name is mandatory (changeable later). Guard here too so the step
// can't be completed without one, even via keyboard/Enter.
const trimmedName = profileData.name.trim();
if (!skipProfile && !trimmedName) {
toast({
title: 'Display name required',
description: 'Pick a display name so other skaters know who you are. You can change it anytime.',
variant: 'destructive',
});
return;
}
// Mark signup completion time for fallback welcome modal
localStorage.setItem('signup_completed', Date.now().toString());
try {
// Reserve the chosen SkateSpots handle first (best-effort). A profile only
// advertises a handle once the registry has confirmed it, so other clients
// never show a broken ✓ — and a failure here never blocks the signup.
let nip05 = claimedNip05 ?? undefined;
if (!skipProfile && !nip05) {
const candidate = validateSkateNip05Name(nip05Handle);
if (candidate.ok) {
const claimResult = await claim(candidate.name);
if (claimResult.ok) {
nip05 = claimResult.nip05;
setClaimedNip05(claimResult.nip05);
} else {
toast({
title: "Couldn't reserve that name",
description: `${claimResult.error} You can claim one later in Settings.`,
});
}
}
}
// Publish profile if user provided information
if (!skipProfile && (trimmedName || profileData.about || profileData.picture)) {
const metadata: Record<string, string> = {};
if (trimmedName) metadata.name = trimmedName;
if (profileData.about) metadata.about = profileData.about;
if (profileData.picture) metadata.picture = profileData.picture;
if (nip05) metadata.nip05 = nip05;
await publishEvent({
kind: 0,
content: JSON.stringify(metadata),
});
toast({
title: 'Profile Created!',
description: 'Your profile has been set up.',
});
}
// Show the done step with signer recommendation
setStep('done');
} catch {
toast({
title: 'Profile Setup Failed',
description: 'Your account was created but profile setup failed. You can update it later.',
variant: 'destructive',
});
// Still proceed to completion even if profile failed
setStep('done');
}
};
const handleFinishComplete = () => {
onClose();
if (onComplete) {
setTimeout(() => {
onComplete();
}, 600);
} else {
toast({
title: 'Welcome!',
description: 'Your account is ready.',
});
}
};
const getTitle = () => {
if (step === 'welcome') return (
<span className="flex items-center justify-center gap-2">
Join SkateSpots
</span>
);
if (step === 'profile') return (
<span className="flex items-center justify-center gap-2">
Set Up Your Profile
</span>
);
return (
<span className="flex items-center justify-center gap-2">
Welcome!
</span>
);
};
// Reset state when dialog opens. We now start on an explicit WELCOME step and
// only create the keypair when the skater taps "Create my account" — this
// gives signup a real, understandable flow instead of silently minting an
// account behind their back the instant the dialog opens.
useEffect(() => {
if (isOpen) {
setStep('welcome');
setNsec('');
setKeySecured('none');
setBackupAck(false);
setShowNsec(false);
setProfileData({ name: '', about: '', picture: '' });
setNip05Handle('');
setNip05HandleEdited(false);
setClaimedNip05(null);
}
}, [isOpen]);
// Offer a handle derived from the display name until the skater edits it
// themselves (or has already claimed one).
useEffect(() => {
if (!nip05HandleEdited && !claimedNip05) {
setNip05Handle(deriveSkateNip05Handle(profileData.name));
}
}, [profileData.name, nip05HandleEdited, claimedNip05]);
// Create the account (generate key + log in), then advance to profile setup.
const handleCreateAccount = () => {
if (createAccountSilently()) {
setStep('profile');
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className={cn("w-[calc(100%-1rem)] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl flex flex-col")}
>
<DialogHeader className={cn('px-4 sm:px-6 pt-4 sm:pt-6 pb-1 relative flex-shrink-0')}>
<DialogTitle className={cn('font-semibold text-center text-base sm:text-lg')}>
{getTitle()}
</DialogTitle>
</DialogHeader>
{/* Step indicator — the flow is finite and the person should always know
where they are (Choose → Profile → Save key). */}
<div className='flex items-center justify-center gap-1.5 px-4 sm:px-6 pt-2 pb-1 flex-shrink-0'>
{STEP_ORDER.map((s, i) => {
const idx = STEP_ORDER.indexOf(step);
return (
<div key={s} className='flex items-center gap-1.5'>
<span
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-black transition-colors',
i < idx
? 'bg-[#00ff00] text-black'
: i === idx
? 'bg-[#ccff00] text-black'
: 'bg-white/10 text-gray-500'
)}
>
{i < idx ? '✓' : i + 1}
</span>
<span
className={cn(
'text-[10px] font-black uppercase tracking-wide',
i === idx ? 'text-white' : 'text-gray-600'
)}
>
{STEP_LABELS[s]}
</span>
{i < STEP_ORDER.length - 1 && (
<span className={cn('h-px w-3 sm:w-5', i < idx ? 'bg-[#00ff00]/50' : 'bg-white/10')} />
)}
</div>
);
})}
</div>
<div className='px-4 sm:px-6 pt-2 pb-4 space-y-4 overflow-y-auto flex-1'>
{/* Welcome Step — recommend Amber first, then offer the quick local key */}
{step === 'welcome' && (
<div className='space-y-5 text-center'>
<div className='relative overflow-hidden rounded-2xl border-2 border-[#ccff00]/40 bg-gradient-to-br from-[#ccff00]/10 to-[#00ff00]/5 p-6'>
<div className='absolute inset-0 bg-[#ccff00]/5 blur-2xl' />
<div className='relative z-10 space-y-3'>
<div className='mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-[#ccff00]/15 text-4xl'>
🛹
</div>
<h3 className='text-xl font-black uppercase text-[#ccff00]'>
Create your free account
</h3>
<p className='text-sm text-gray-300'>
No email, no phone, no password. Your account is a Nostr key — choose
where it lives.
</p>
</div>
</div>
{/* RECOMMENDED: Amber keeps the key in a dedicated signer app.
Amber is Android-only, so only surface it there. */}
{onUseAmber && isAndroid && (
<div className='rounded-2xl border-2 border-[#ccff00]/50 bg-black/40 p-4 text-left space-y-3'>
<div className='flex items-center gap-2'>
<span className='rounded-full bg-[#ccff00] px-2 py-0.5 text-[10px] font-black uppercase text-black'>Recommended</span>
</div>
<div className='flex items-start gap-3'>
<div className='mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#ccff00]/15'>
<Smartphone className='h-5 w-5 text-[#ccff00]' />
</div>
<div>
<p className='font-bold text-white'>Sign up with Amber</p>
<p className='text-xs text-gray-400'>
Your key is created and stored inside the Amber app — SkateSpots
never touches it. Most secure. One tap to approve.
</p>
</div>
</div>
<Button
className='w-full rounded-full py-3 font-black uppercase bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
onClick={() => { onClose(); onUseAmber(); }}
>
<Smartphone className='mr-2 h-4 w-4' />
Continue with Amber
</Button>
</div>
)}
{onUseAmber && isAndroid && (
<div className='relative flex items-center'>
<div className='flex-1 border-t border-gray-800' />
<span className='px-3 text-xs text-gray-500'>or the quick way</span>
<div className='flex-1 border-t border-gray-800' />
</div>
)}
{/* QUICK: local key stored on this device */}
<div className='rounded-2xl border border-white/10 bg-black/40 p-4 text-left space-y-3'>
<div className='flex items-start gap-3'>
<div className='mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/5'>
<Key className='h-5 w-5 text-gray-300' />
</div>
<div>
<p className='font-bold text-white'>Quick account on this device</p>
<p className='text-xs text-gray-400'>
We generate a key that lives on this device. Fastest start — we'll
help you save a backup right after.
</p>
</div>
</div>
<Button
variant='outline'
className='w-full rounded-full py-3 font-bold border-white/20 text-white hover:bg-white/5'
onClick={handleCreateAccount}
>
<Sparkles className='mr-2 h-4 w-4' />
Create quick account
</Button>
{/* Reassurance: effortless + safe, no jargon. Deliberately does
NOT promise "nothing to remember" — step 3 asks them to save
the key, and the copy must not contradict that. */}
<p className='flex items-center justify-center gap-1.5 text-[11px] text-gray-500'>
<CheckCircle className='h-3 w-3 text-[#00ff00]/70' />
Takes 5 seconds · lives on this device · we'll help you back it up next
</p>
</div>
{/* Platform-aware nudge. On Android the Amber card above already
leads; elsewhere point at signer apps/extensions honestly. */}
<p className='text-xs text-gray-500'>
{onUseAmber && isAndroid ? (
<>Prefer another signer?{' '}</>
) : (
<>On iOS or desktop? A signer app or browser extension is the most secure option —{' '}</>
)}
<Link to='/signers' onClick={onClose} className='text-[#ccff00] underline'>See all options</Link>
</p>
</div>
)}
{/* Profile Step - Optional profile setup */}
{step === 'profile' && (
<div className='text-center space-y-4'>
{/* Profile setup illustration */}
<div className='relative p-6 rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-950/50 dark:to-indigo-950/50 overflow-hidden'>
{/* Sparkles */}
<div className='absolute inset-0 pointer-events-none'>
<Sparkles className='absolute top-3 left-4 w-3 h-3 text-yellow-400 animate-pulse' style={{animationDelay: '0s'}} />
<Sparkles className='absolute top-6 right-6 w-3 h-3 text-yellow-500 animate-pulse' style={{animationDelay: '0.5s'}} />
<Sparkles className='absolute bottom-4 left-6 w-3 h-3 text-yellow-400 animate-pulse' style={{animationDelay: '1s'}} />
</div>
<div className='relative z-10 flex justify-center items-center mb-3'>
<div className='relative'>
<div className='w-16 h-16 bg-gradient-to-br from-blue-200 to-indigo-300 rounded-full flex items-center justify-center shadow-lg'>
<User className='w-8 h-8 text-blue-800' />
</div>
<div className='absolute -top-1 -right-1 w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center animate-bounce'>
<Sparkles className='w-3 h-3 text-white' />
</div>
</div>
</div>
<div className='relative z-10 space-y-2'>
<p className='text-base font-semibold'>
You're in! 🛹 Pick a display name so skaters know who you are
</p>
<p className='text-sm text-muted-foreground'>
You can change it anytime later. Bio and avatar are optional.
</p>
</div>
</div>
{/* Publishing status indicator */}
{isPublishing && (
<div className='relative p-4 rounded-xl bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/30 dark:to-indigo-950/30 border border-blue-200 dark:border-blue-800'>
<div className='flex items-center justify-center gap-3'>
<div className='w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
<span className='text-sm font-medium text-blue-700 dark:text-blue-300'>
Publishing your profile...
</span>
</div>
</div>
)}
{/* Profile form */}
<div className={`space-y-4 text-left ${isPublishing ? 'opacity-50 pointer-events-none' : ''}`}>
<div className='space-y-2'>
<label htmlFor='profile-name' className='text-sm font-medium'>
Display Name <span className='text-red-500'>*</span>
</label>
<Input
id='profile-name'
value={profileData.name}
onChange={(e) => setProfileData(prev => ({ ...prev, name: e.target.value }))}
placeholder='e.g. Tony, gnarlyGrind, or your crew name'
className='rounded-lg'
maxLength={64}
required
aria-required='true'
disabled={isPublishing}
/>
<p className='text-xs text-muted-foreground'>
Required — how you'll show up across SkateSpots. Change it anytime.
</p>
</div>
<div className='space-y-2'>
<label htmlFor='skate-nip05' className='text-sm font-medium'>
Your SkateSpots name
</label>
<SkateNip05Input
id='skate-nip05'
handle={nip05Handle}
onHandleChange={(h) => {
setNip05HandleEdited(true);
setNip05Handle(h);
setClaimedNip05(null);
}}
claimed={claimedNip05}
onClaimed={(nip05) => setClaimedNip05(nip05)}
disabled={isPublishing}
/>
<p className='text-xs text-muted-foreground'>
Free and optional — a verified handle that shows a ✓ on your profile.
</p>
</div>
<div className='space-y-2'>
<label htmlFor='profile-about' className='text-sm font-medium'>
Bio
</label>
<Textarea
id='profile-about'
value={profileData.about}
onChange={(e) => setProfileData(prev => ({ ...prev, about: e.target.value }))}
placeholder='Tell others about yourself...'
className='rounded-lg resize-none'
rows={3}
disabled={isPublishing}
/>
</div>
<div className='space-y-2'>
<label htmlFor='profile-picture' className='text-sm font-medium'>
Avatar
</label>
<div className='flex gap-2'>
<Input
id='profile-picture'
value={profileData.picture}
onChange={(e) => setProfileData(prev => ({ ...prev, picture: e.target.value }))}
placeholder='https://example.com/your-avatar.jpg'
className='rounded-lg flex-1'
disabled={isPublishing}
/>
<input
type='file'
accept='image/*'
className='hidden'
ref={avatarFileInputRef}
onChange={handleAvatarUpload}
/>
<Button
type='button'
variant='outline'
size='icon'
onClick={() => avatarFileInputRef.current?.click()}
disabled={isUploading || isPublishing}
className='rounded-lg shrink-0'
title='Upload avatar image'
>
{isUploading ? (
<div className='w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin' />
) : (
<Upload className='w-4 h-4' />
)}
</Button>
</div>
</div>
</div>
{/* Action button — a display name is required to continue. */}
<div className='space-y-3'>
<Button
className='w-full rounded-full py-4 text-base font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 transform transition-all duration-200 hover:scale-105 shadow-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none disabled:hover:scale-100'
onClick={() => finishSignup(false)}
disabled={isPublishing || isUploading || !profileData.name.trim()}
>
{isPublishing ? (
<>
<div className='w-4 h-4 mr-2 border-2 border-current border-t-transparent rounded-full animate-spin' />
Saving...
</>
) : (
<>
<User className='w-4 h-4 mr-2' />
Save & Start Skating
</>
)}
</Button>
{!profileData.name.trim() && !isPublishing && (
<p className='text-center text-xs text-muted-foreground'>
Add a display name to continue.
</p>
)}
{/* Set expectations before it happens: saving publishes a kind-0
profile event, so an interactive signer (Amber / NIP-07) will
show an approval prompt. Explaining it first avoids the "why
is it asking me to sign?" moment mid-signup. */}
<p className='text-center text-[11px] text-muted-foreground/80'>
Saving publishes your profile to Nostr — your signer may ask you to approve it once.
</p>
</div>
</div>
)}
{/* Done Step — recovery FIRST. Saving your key is the hero action;
skating is one tap away but never competes with the safety step. */}
{step === 'done' && (
<div className='py-2 space-y-4'>
{/* Compact celebration — acknowledges success without stealing the
spotlight from the one thing that actually matters right now. */}
<div className='flex items-center gap-3 rounded-xl bg-[#00ff00]/10 border border-[#00ff00]/30 px-4 py-3'>
<CheckCircle className='h-6 w-6 shrink-0 text-[#00ff00]' />
<div>
<p className='font-black uppercase text-[#ccff00] leading-tight'>You're in! 🛹</p>
<p className='text-xs text-gray-400'>One quick thing before you drop in…</p>
</div>
</div>
{/* THE KEY — framed like a skate metaphor: this is your session pass.
Lose it, no bail-out. Saving it is the primary, loud action. */}
<Card className='bg-gradient-to-br from-amber-950/40 to-black border-2 border-amber-500/50 shadow-[0_0_28px_rgba(245,158,11,0.18)]'>
<CardContent className='p-5 space-y-3'>
<div className='flex items-start gap-3'>
<div className='mt-0.5 p-2 rounded-xl bg-amber-500/20 ring-1 ring-amber-500/40'>
<Key className='h-5 w-5 text-amber-400' />
</div>
<div className='flex-1'>
<h4 className='font-black uppercase text-amber-300 leading-tight'>Save your login key</h4>
<p className='text-xs text-amber-100/80 leading-snug mt-1'>
This is the <span className='font-bold text-amber-200'>only</span> way back into your account —
no email, no password reset. Save it now and you can log in on any device, anywhere you skate.
</p>
</div>
</div>
{!showNsec ? (
<Button
className='w-full rounded-full py-3 font-black uppercase bg-amber-500 text-black hover:bg-amber-400'
onClick={() => setShowNsec(true)}
>
<Shield className='h-4 w-4 mr-2' />
Reveal &amp; save my key
</Button>
) : (
<div className='space-y-2'>
<div className='p-3 bg-black rounded-lg border border-amber-500/30 font-mono text-xs break-all text-white'>
{nsec}
</div>
<div className='grid grid-cols-2 gap-2'>
<Button
variant='outline'
size='sm'
className='border-amber-500/40 text-amber-300 hover:bg-amber-500/10'
onClick={() => {
navigator.clipboard.writeText(nsec);
setBackupConfirmed(true);
toast({ title: 'Copied!', description: 'Recovery code copied. Keep it somewhere safe.' });
}}
>
<Key className='h-3.5 w-3.5 mr-1.5' />
Copy
</Button>
<Button
size='sm'
className={cn(
'font-bold',
keySecured === 'downloaded'
? 'bg-[#00ff00]/20 text-[#00ff00] border border-[#00ff00]/40 hover:bg-[#00ff00]/25'
: 'bg-amber-500 text-black hover:bg-amber-400'
)}
onClick={downloadKey}
>
{keySecured === 'downloaded' ? (
<><CheckCircle className='h-3.5 w-3.5 mr-1.5' /> Saved</>
) : (
<><Download className='h-3.5 w-3.5 mr-1.5' /> Download</>
)}
</Button>
</div>
<p className='flex items-start gap-1.5 text-[11px] text-amber-300/80 leading-snug'>
<Shield className='h-3.5 w-3.5 shrink-0 mt-px' />
Treat it like the keys to your board — anyone with this code controls your account. Never share it.
</p>
</div>
)}
</CardContent>
</Card>
{/* Backup is a confirmation, not a skip. The primary action stays
locked until the key is saved (download sets it) or explicitly
acknowledged with the checkbox. The escape hatch remains — we
never trap anyone — but it's a quiet text link, never an equal
button competing with "save". */}
<div className='space-y-2'>
<label className='flex items-start gap-2.5 rounded-xl border border-white/10 bg-black/40 p-3 cursor-pointer select-none'>
<Checkbox
checked={backupAck}
onCheckedChange={(v) => {
const on = v === true;
setBackupAck(on);
if (on) setBackupConfirmed(true);
}}
className='mt-0.5'
aria-label="I've saved my recovery code"
/>
<span className='text-xs text-gray-300 leading-snug'>
I've saved my recovery code somewhere safe — a password manager, or written down offline.
</span>
</label>
<Button
className={cn(
'w-full rounded-full py-3 font-black uppercase',
backupAck
? 'bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
: 'bg-white/10 text-gray-500 cursor-not-allowed'
)}
disabled={!backupAck}
onClick={handleFinishComplete}
>
<LogIn className='h-4 w-4 mr-2' />
Let's skate
</Button>
{!backupAck && (
<button
onClick={handleFinishComplete}
className='w-full text-xs text-gray-500 hover:text-gray-400 py-1'
>
I'll save it later from Settings
</button>
)}
<Link to='/signers' onClick={handleFinishComplete} className='block'>
<button className='w-full text-xs text-gray-500 hover:text-gray-400 py-1'>
Advanced: use a signer app instead
</button>
</Link>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default SignupDialog;