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.
791 lines
34 KiB
TypeScript
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 & 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;
|