- CrewCard: rebuilt to fit any screen width — clip overflow, truncate name/location, single wrapping badge row, consistent avatar sizing; responsive header - Amber web login: after opening Amber, drop user on manual paste step after 2.5s so the spinner never hangs when the callback opens in a new tab or clipboard is blocked; keep polling on the paste step so a late callback still auto-completes - Stash: hasNip44/hasNip04 now fall back to the active login when pubkey matching fails, so logged-in nsec/amber users are no longer wrongly told "Encryption Required" - Signup: added an explicit welcome step that explains account creation and only generates the keypair when the user taps "Create my account" (no more silent auto-creation on dialog open) Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
550 lines
22 KiB
TypeScript
550 lines
22 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 } 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<SignupDialogProps> = ({ 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<HTMLInputElement>(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<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) => {
|
||
// 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<string, string> = {};
|
||
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 (
|
||
<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');
|
||
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 (
|
||
<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>
|
||
<div className='px-4 sm:px-6 pt-2 pb-4 space-y-4 overflow-y-auto flex-1'>
|
||
{/* Welcome Step — explain what's about to happen, then create on tap */}
|
||
{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. We'll generate a private key that lives
|
||
<strong className='text-white'> only on your device</strong> — that key <em>is</em> your account.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className='space-y-2 text-left'>
|
||
{[
|
||
['🔑', '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]) => (
|
||
<div key={label} className='flex items-center gap-3 rounded-lg border border-white/10 bg-black/40 px-3 py-2.5'>
|
||
<span className='text-lg'>{icon}</span>
|
||
<span className='text-sm text-gray-300'>{label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<Button
|
||
className='w-full rounded-full py-4 text-base font-black uppercase bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
|
||
onClick={handleCreateAccount}
|
||
>
|
||
<Sparkles className='mr-2 h-4 w-4' />
|
||
Create my account
|
||
</Button>
|
||
<p className='text-xs text-gray-500'>
|
||
By continuing you're generating a Nostr keypair. Advanced users can
|
||
{' '}
|
||
<Link to='/signers' onClick={onClose} className='text-[#ccff00] underline'>use a signer app</Link>
|
||
{' '}instead.
|
||
</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! 🛹 Add a name so skaters know who you are
|
||
</p>
|
||
|
||
<p className='text-sm text-muted-foreground'>
|
||
This is optional — you can skip it and fill it in later.
|
||
</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
|
||
</label>
|
||
<Input
|
||
id='profile-name'
|
||
value={profileData.name}
|
||
onChange={(e) => setProfileData(prev => ({ ...prev, name: e.target.value }))}
|
||
placeholder='Your name'
|
||
className='rounded-lg'
|
||
disabled={isPublishing}
|
||
/>
|
||
</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 buttons */}
|
||
<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'
|
||
onClick={() => finishSignup(false)}
|
||
disabled={isPublishing || isUploading}
|
||
>
|
||
{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>
|
||
|
||
<Button
|
||
variant='outline'
|
||
className='w-full rounded-full py-3 disabled:opacity-50 disabled:cursor-not-allowed'
|
||
onClick={() => finishSignup(true)}
|
||
disabled={isPublishing || isUploading}
|
||
>
|
||
{isPublishing ? (
|
||
<>
|
||
<div className='w-4 h-4 mr-2 border-2 border-current border-t-transparent rounded-full animate-spin' />
|
||
Setting up account...
|
||
</>
|
||
) : (
|
||
'Skip for now'
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Done Step - Account created, gentle optional backup */}
|
||
{step === 'done' && (
|
||
<div className='py-4 space-y-4'>
|
||
<Card className='bg-gradient-to-br from-[#ccff00]/10 to-[#00ff00]/10 border-2 border-[#ccff00]/50'>
|
||
<CardContent className='p-6 text-center space-y-3'>
|
||
<div className='text-5xl mb-2'>🛹</div>
|
||
<h3 className='text-xl font-black uppercase text-[#ccff00]'>
|
||
You're all set!
|
||
</h3>
|
||
<p className='text-gray-300 text-sm'>
|
||
Your account is ready. Go find some spots.
|
||
</p>
|
||
<CheckCircle className='h-10 w-10 mx-auto text-[#00ff00]' />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Optional backup — no pressure, no jargon */}
|
||
<Card className='bg-amber-950/20 border-2 border-amber-500/30'>
|
||
<CardContent className='p-5 space-y-3'>
|
||
<div className='flex items-center gap-3'>
|
||
<div className='p-2 rounded-lg bg-amber-500/20'>
|
||
<Shield className='h-5 w-5 text-amber-400' />
|
||
</div>
|
||
<div>
|
||
<h4 className='font-bold text-amber-400'>Save a backup (optional)</h4>
|
||
<p className='text-xs text-gray-400'>So you never lose access to your account</p>
|
||
</div>
|
||
</div>
|
||
<p className='text-sm text-gray-300'>
|
||
Your account lives on your device. Save a recovery code somewhere safe and
|
||
you'll be able to log back in on any device. You can also do this later from Settings.
|
||
</p>
|
||
|
||
{!showNsec ? (
|
||
<Button
|
||
variant='outline'
|
||
className='w-full border-amber-500/50 text-amber-400 hover:bg-amber-500/10'
|
||
onClick={() => setShowNsec(true)}
|
||
>
|
||
<Key className='h-4 w-4 mr-2' />
|
||
Show my recovery code
|
||
</Button>
|
||
) : (
|
||
<div className='space-y-2'>
|
||
<div className='p-2 bg-black rounded border border-amber-500/20 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);
|
||
toast({ title: 'Copied!', description: 'Recovery code copied. Keep it somewhere safe.' });
|
||
}}
|
||
>
|
||
Copy
|
||
</Button>
|
||
<Button
|
||
variant='outline'
|
||
size='sm'
|
||
className='border-amber-500/40 text-amber-300 hover:bg-amber-500/10'
|
||
onClick={downloadKey}
|
||
>
|
||
<Download className='h-3.5 w-3.5 mr-1.5' />
|
||
{keySecured === 'downloaded' ? 'Saved ✓' : 'Download'}
|
||
</Button>
|
||
</div>
|
||
<p className='text-xs text-amber-300/80'>⚠️ Anyone with this code can access your account. Never share it.</p>
|
||
</div>
|
||
)}
|
||
|
||
<Button
|
||
className='w-full rounded-full py-3 font-bold uppercase bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
|
||
onClick={handleFinishComplete}
|
||
>
|
||
<LogIn className='h-4 w-4 mr-2' />
|
||
Start Skating
|
||
</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>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
};
|
||
|
||
export default SignupDialog;
|