// 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 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; 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 { generateSecretKey, nip19 } from 'nostr-tools'; import { cn } from '@/lib/utils'; interface SignupDialogProps { isOpen: boolean; onClose: () => void; onComplete?: () => void; } const sanitizeFilename = (filename: string) => { return filename.replace(/[^a-z0-9_.-]/gi, '_'); } const SignupDialog: React.FC = ({ isOpen, onClose, onComplete }) => { const [step, setStep] = useState<'welcome' | 'profile' | 'done'>('welcome'); const [nsec, setNsec] = useState(''); const [keySecured, setKeySecured] = useState<'none' | 'downloaded'>('none'); const [showNsec, setShowNsec] = useState(false); const [profileData, setProfileData] = useState({ name: '', about: '', picture: '' }); const login = useLoginActions(); const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const avatarFileInputRef = useRef(null); // 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 setKeySecured('downloaded'); 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) => { // Mark signup completion time for fallback welcome modal localStorage.setItem('signup_completed', Date.now().toString()); try { // Publish profile if user provided information if (!skipProfile && (profileData.name || profileData.about || profileData.picture)) { const metadata: Record = {}; if (profileData.name) metadata.name = profileData.name; if (profileData.about) metadata.about = profileData.about; if (profileData.picture) metadata.picture = profileData.picture; 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'); setShowNsec(false); setProfileData({ name: '', about: '', picture: '' }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); // Create the account (generate key + log in), then advance to profile setup. const handleCreateAccount = () => { if (createAccountSilently()) { setStep('profile'); } }; return ( {getTitle()}
{/* Welcome Step — explain what's about to happen, then create on tap */} {step === 'welcome' && (
🛹

Create your free account

No email, no phone, no password. We'll generate a private key that lives only on your device — that key is your account.

{[ ['🔑', 'A secure key is created just for you'], ['📱', 'It stays on this device — nobody else can see it'], ['💾', "You'll get a chance to back it up right after"], ].map(([icon, label]) => (
{icon} {label}
))}

By continuing you're generating a Nostr keypair. Advanced users can {' '} use a signer app {' '}instead.

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

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

This is optional — you can skip it and fill it in later.

{/* Publishing status indicator */} {isPublishing && (
Publishing your profile...
)} {/* Profile form */}
setProfileData(prev => ({ ...prev, name: e.target.value }))} placeholder='Your name' className='rounded-lg' disabled={isPublishing} />