// 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 = { welcome: 'Choose', profile: 'Profile', done: 'Save key', }; const SignupDialog: React.FC = ({ isOpen, onClose, onComplete, onUseAmber }) => { const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent); const [step, setStep] = useState('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(null); const login = useLoginActions(); const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { claim } = useSkateNip05(); const avatarFileInputRef = useRef(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) => { 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 = {}; 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 ( Join SkateSpots ); if (step === 'profile') return ( Set Up Your Profile ); return ( Welcome! ); }; // 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 ( {getTitle()} {/* Step indicator — the flow is finite and the person should always know where they are (Choose → Profile → Save key). */}
{STEP_ORDER.map((s, i) => { const idx = STEP_ORDER.indexOf(step); return (
{i < idx ? '✓' : i + 1} {STEP_LABELS[s]} {i < STEP_ORDER.length - 1 && ( )}
); })}
{/* Welcome Step — recommend Amber first, then offer the quick local key */} {step === 'welcome' && (
🛹

Create your free account

No email, no phone, no password. Your account is a Nostr key — choose where it lives.

{/* RECOMMENDED: Amber keeps the key in a dedicated signer app. Amber is Android-only, so only surface it there. */} {onUseAmber && isAndroid && (
Recommended

Sign up with Amber

Your key is created and stored inside the Amber app — SkateSpots never touches it. Most secure. One tap to approve.

)} {onUseAmber && isAndroid && (
or the quick way
)} {/* QUICK: local key stored on this device */}

Quick account on this device

We generate a key that lives on this device. Fastest start — we'll help you save a backup right after.

{/* 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. */}

Takes 5 seconds · lives on this device · we'll help you back it up next

{/* Platform-aware nudge. On Android the Amber card above already leads; elsewhere point at signer apps/extensions honestly. */}

{onUseAmber && isAndroid ? ( <>Prefer another signer?{' '} ) : ( <>On iOS or desktop? A signer app or browser extension is the most secure option —{' '} )} See all options

)} {/* Profile Step - Optional profile setup */} {step === 'profile' && (
{/* Profile setup illustration */}
{/* Sparkles */}

You're in! 🛹 Pick a display name so skaters know who you are

You can change it anytime later. Bio and avatar are optional.

{/* Publishing status indicator */} {isPublishing && (
Publishing your profile...
)} {/* Profile form */}
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} />

Required — how you'll show up across SkateSpots. Change it anytime.

{ setNip05HandleEdited(true); setNip05Handle(h); setClaimedNip05(null); }} claimed={claimedNip05} onClaimed={(nip05) => setClaimedNip05(nip05)} disabled={isPublishing} />

Free and optional — a verified handle that shows a ✓ on your profile.