Pull signup flow from Treasures

This commit is contained in:
Chad Curtis 2025-07-08 13:54:02 +00:00
parent ad1c8b123c
commit baaf7e6ab4
2 changed files with 990 additions and 202 deletions

View File

@ -2,18 +2,14 @@
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested. // It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { Shield, Upload } from 'lucide-react'; import { Shield, Upload, AlertTriangle, Sparkles, Crown, Gem, Star, KeyRound, Lock } from 'lucide-react';
import { Button } from '@/components/ui/button.tsx'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input.tsx'; import { Input } from '@/components/ui/input';
import { import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
Dialog, import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
DialogContent, import { Alert, AlertDescription } from '@/components/ui/alert';
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog.tsx';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs.tsx';
import { useLoginActions } from '@/hooks/useLoginActions'; import { useLoginActions } from '@/hooks/useLoginActions';
import { cn } from '@/lib/utils';
interface LoginDialogProps { interface LoginDialogProps {
isOpen: boolean; isOpen: boolean;
@ -22,54 +18,108 @@ interface LoginDialogProps {
onSignup?: () => void; onSignup?: () => void;
} }
const validateNsec = (nsec: string) => {
return /^nsec1[a-zA-Z0-9]{58}$/.test(nsec);
};
const validateBunkerUri = (uri: string) => {
return uri.startsWith('bunker://');
};
const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onSignup }) => { const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onSignup }) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isFileLoading, setIsFileLoading] = useState(false);
const [nsec, setNsec] = useState(''); const [nsec, setNsec] = useState('');
const [bunkerUri, setBunkerUri] = useState(''); const [bunkerUri, setBunkerUri] = useState('');
const [errors, setErrors] = useState<{
nsec?: string;
bunker?: string;
file?: string;
extension?: string;
}>({});
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const login = useLoginActions(); const login = useLoginActions();
const handleExtensionLogin = () => { const handleExtensionLogin = async () => {
setIsLoading(true); setIsLoading(true);
setErrors(prev => ({ ...prev, extension: undefined }));
try { try {
if (!('nostr' in window)) { if (!('nostr' in window)) {
throw new Error('Nostr extension not found. Please install a NIP-07 extension.'); throw new Error('Nostr extension not found. Please install a NIP-07 extension.');
} }
login.extension(); await login.extension();
onLogin(); onLogin();
onClose(); onClose();
} catch (error) { } catch (e: unknown) {
const error = e as Error;
console.error('Bunker login failed:', error);
console.error('Nsec login failed:', error);
console.error('Extension login failed:', error); console.error('Extension login failed:', error);
setErrors(prev => ({
...prev,
extension: error instanceof Error ? error.message : 'Extension login failed'
}));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const executeLogin = (key: string) => {
setIsLoading(true);
setErrors({});
// Use a timeout to allow the UI to update before the synchronous login call
setTimeout(() => {
try {
login.nsec(key);
onLogin();
onClose();
} catch {
setErrors({ nsec: "Failed to login with this key. Please check that it's correct." });
setIsLoading(false);
}
}, 50);
};
const handleKeyLogin = () => { const handleKeyLogin = () => {
if (!nsec.trim()) return; if (!nsec.trim()) {
setIsLoading(true); setErrors(prev => ({ ...prev, nsec: 'Please enter your secret key' }));
return;
try {
login.nsec(nsec);
onLogin();
onClose();
} catch (error) {
console.error('Nsec login failed:', error);
} finally {
setIsLoading(false);
} }
if (!validateNsec(nsec)) {
setErrors(prev => ({ ...prev, nsec: 'Invalid secret key format. Must be a valid nsec starting with nsec1.' }));
return;
}
executeLogin(nsec);
}; };
const handleBunkerLogin = () => { const handleBunkerLogin = async () => {
if (!bunkerUri.trim() || !bunkerUri.startsWith('bunker://')) return; if (!bunkerUri.trim()) {
setErrors(prev => ({ ...prev, bunker: 'Please enter a bunker URI' }));
return;
}
if (!validateBunkerUri(bunkerUri)) {
setErrors(prev => ({ ...prev, bunker: 'Invalid bunker URI format. Must start with bunker://' }));
return;
}
setIsLoading(true); setIsLoading(true);
setErrors(prev => ({ ...prev, bunker: undefined }));
try { try {
login.bunker(bunkerUri); await login.bunker(bunkerUri);
onLogin(); onLogin();
onClose(); onClose();
} catch (error) { // Clear the URI from memory
console.error('Bunker login failed:', error); setBunkerUri('');
} catch {
setErrors(prev => ({
...prev,
bunker: 'Failed to connect to bunker. Please check the URI.'
}));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -79,10 +129,27 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setIsFileLoading(true);
setErrors({});
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
setIsFileLoading(false);
const content = event.target?.result as string; const content = event.target?.result as string;
setNsec(content.trim()); if (content) {
const trimmedContent = content.trim();
if (validateNsec(trimmedContent)) {
executeLogin(trimmedContent);
} else {
setErrors({ file: 'File does not contain a valid secret key.' });
}
} else {
setErrors({ file: 'Could not read file content.' });
}
};
reader.onerror = () => {
setIsFileLoading(false);
setErrors({ file: 'Failed to read file.' });
}; };
reader.readAsText(file); reader.readAsText(file);
}; };
@ -94,57 +161,157 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
} }
}; };
const defaultTab = 'nostr' in window ? 'extension' : 'key';
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className='sm:max-w-md p-0 overflow-hidden rounded-2xl'> <DialogContent
<DialogHeader className='px-6 pt-6 pb-0 relative'> className={cn("max-w-[95vw] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl")}
<DialogTitle className='text-xl font-semibold text-center'>Log in</DialogTitle> >
<DialogDescription className='text-center text-muted-foreground mt-2'> <DialogHeader className={cn('px-6 pt-6 pb-1 relative')}>
Access your account securely with your preferred method <DialogTitle className={cn('font-semibold text-center')}>
</DialogDescription> Welcome, Traveler!
</DialogTitle>
<DialogDescription className="text-center">
Start your quest, or login to return to your adventure
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className='px-6 pt-2 pb-4 space-y-4 overflow-y-auto flex-1'>
{/* Prominent Sign Up Section */}
<div className='relative p-4 rounded-2xl bg-gradient-to-br from-green-50 to-emerald-100 dark:from-green-950/50 dark:to-emerald-950/50 adventure:from-amber-50 adventure:to-orange-100 adventure:dark:from-amber-950/50 adventure:dark:to-orange-950/50 border border-green-200 dark:border-green-800 adventure:border-amber-200 adventure:dark:border-amber-800 overflow-hidden'>
{/* Magical sparkles */}
<div className='absolute inset-0 pointer-events-none'>
<Sparkles className='absolute top-2 right-3 w-3 h-3 text-yellow-400 animate-pulse' style={{animationDelay: '0s'}} />
<Star className='absolute top-4 left-4 w-2 h-2 text-yellow-500 animate-pulse' style={{animationDelay: '0.5s'}} />
<Gem className='absolute bottom-3 right-4 w-2 h-2 text-yellow-400 animate-pulse' style={{animationDelay: '1s'}} />
</div>
<div className='px-6 py-8 space-y-6'> <div className='relative z-10 text-center space-y-3'>
<Tabs defaultValue={'nostr' in window ? 'extension' : 'key'} className='w-full'> <div className='flex justify-center items-center gap-2 mb-2'>
<TabsList className='grid grid-cols-3 mb-6'> <Crown className='w-5 h-5 text-green-600 adventure:text-amber-700' />
<TabsTrigger value='extension'>Extension</TabsTrigger> <span className='font-semibold text-green-800 dark:text-green-200 adventure:text-amber-800 adventure:dark:text-amber-200'>
<TabsTrigger value='key'>Nsec</TabsTrigger> <span className='adventure:hidden'>New to Geocaching?</span>
<TabsTrigger value='bunker'>Bunker</TabsTrigger> <span className='hidden adventure:inline'>New to the Quest?</span>
</span>
</div>
<p className='text-sm text-green-700 dark:text-green-300 adventure:text-amber-700 adventure:dark:text-amber-300 mb-3'>
<span className='adventure:hidden'>
Join the guild of adventurers discovering hidden geocaches worldwide!
</span>
<span className='hidden adventure:inline'>
Join the ancient guild of geocache seekers on legendary quests!
</span>
</p>
<Button
onClick={handleSignupClick}
className='w-full rounded-full py-3 text-base font-semibold bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 adventure:from-amber-700 adventure:to-orange-700 adventure:hover:from-amber-800 adventure:hover:to-orange-800 transform transition-all duration-200 hover:scale-105 shadow-lg border-0'
>
<Sparkles className='w-4 h-4 mr-2' />
<span className='adventure:hidden'>Start Your Adventure!</span>
<span className='hidden adventure:inline'>Begin Your Quest!</span>
</Button>
</div>
</div>
{/* Divider */}
<div className='relative'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-t border-gray-300 dark:border-gray-600'></div>
</div>
<div className='relative flex justify-center text-sm'>
<span className='px-3 bg-background text-muted-foreground'>
<span>Or return to your adventure</span>
</span>
</div>
</div>
{/* Login Methods */}
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-muted/80 rounded-lg mb-4">
<TabsTrigger value="extension" className="flex items-center gap-2">
<Shield className="w-4 h-4" />
<span>Extension</span>
</TabsTrigger>
<TabsTrigger value="key" className="flex items-center gap-2">
<KeyRound className="w-4 h-4" />
<span>Key</span>
</TabsTrigger>
<TabsTrigger value="bunker" className="flex items-center gap-2">
<Lock className="w-4 h-4" />
<span>Bunker</span>
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='extension' className='space-y-3 bg-muted'>
<TabsContent value='extension' className='space-y-4'> {errors.extension && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{errors.extension}</AlertDescription>
</Alert>
)}
<div className='text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800'> <div className='text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800'>
<Shield className='w-12 h-12 mx-auto mb-3 text-primary' /> <Shield className='w-12 h-12 mx-auto mb-3 text-primary' />
<p className='text-sm text-gray-600 dark:text-gray-300 mb-4'> <p className='text-sm text-gray-600 dark:text-gray-300 mb-4'>
Login with one click using the browser extension Login with one click using the browser extension
</p> </p>
<Button <div className="flex justify-center">
className='w-full rounded-full py-6' <Button
onClick={handleExtensionLogin} className='w-full rounded-full py-4'
disabled={isLoading} onClick={handleExtensionLogin}
> disabled={isLoading}
{isLoading ? 'Logging in...' : 'Login with Extension'} >
</Button> {isLoading ? 'Logging in...' : 'Login with Extension'}
</Button>
</div>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value='key' className='space-y-4'> <TabsContent value='key' className='space-y-4'>
<div className='space-y-4'> <div className='space-y-4'>
<div className='space-y-2'> <div className='space-y-2'>
<label htmlFor='nsec' className='text-sm font-medium text-gray-700 dark:text-gray-400'> <label htmlFor='nsec' className='text-sm font-medium'>
Enter your nsec Secret Key (nsec)
</label> </label>
<Input <Input
id='nsec' id='nsec'
type="password"
value={nsec} value={nsec}
onChange={(e) => setNsec(e.target.value)} onChange={(e) => {
className='rounded-lg border-gray-300 dark:border-gray-700 focus-visible:ring-primary' setNsec(e.target.value);
if (errors.nsec) setErrors(prev => ({ ...prev, nsec: undefined }));
}}
className={`rounded-lg ${
errors.nsec ? 'border-red-500 focus-visible:ring-red-500' : ''
}`}
placeholder='nsec1...' placeholder='nsec1...'
autoComplete="off"
/> />
{errors.nsec && (
<p className="text-sm text-red-500">{errors.nsec}</p>
)}
</div>
<Button
className='w-full rounded-full py-3'
onClick={handleKeyLogin}
disabled={isLoading || !nsec.trim()}
>
{isLoading ? 'Verifying...' : 'Log In'}
</Button>
<div className='relative'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-t border-muted'></div>
</div>
<div className='relative flex justify-center text-xs'>
<span className='px-2 bg-background text-muted-foreground'>
or
</span>
</div>
</div> </div>
<div className='text-center'> <div className='text-center'>
<p className='text-sm mb-2 text-gray-600 dark:text-gray-400'>Or upload a key file</p>
<input <input
type='file' type='file'
accept='.txt' accept='.txt'
@ -154,25 +321,21 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
/> />
<Button <Button
variant='outline' variant='outline'
className='w-full dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700' className='w-full'
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={isLoading || isFileLoading}
> >
<Upload className='w-4 h-4 mr-2' /> <Upload className='w-4 h-4 mr-2' />
Upload Nsec File {isFileLoading ? 'Reading File...' : 'Upload Your Key File'}
</Button> </Button>
{errors.file && (
<p className="text-sm text-red-500 mt-2">{errors.file}</p>
)}
</div> </div>
<Button
className='w-full rounded-full py-6 mt-4'
onClick={handleKeyLogin}
disabled={isLoading || !nsec.trim()}
>
{isLoading ? 'Verifying...' : 'Login with Nsec'}
</Button>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value='bunker' className='space-y-4'> <TabsContent value='bunker' className='space-y-3 bg-muted'>
<div className='space-y-2'> <div className='space-y-2'>
<label htmlFor='bunkerUri' className='text-sm font-medium text-gray-700 dark:text-gray-400'> <label htmlFor='bunkerUri' className='text-sm font-medium text-gray-700 dark:text-gray-400'>
Bunker URI Bunker URI
@ -180,40 +343,36 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
<Input <Input
id='bunkerUri' id='bunkerUri'
value={bunkerUri} value={bunkerUri}
onChange={(e) => setBunkerUri(e.target.value)} onChange={(e) => {
className='rounded-lg border-gray-300 dark:border-gray-700 focus-visible:ring-primary' setBunkerUri(e.target.value);
if (errors.bunker) setErrors(prev => ({ ...prev, bunker: undefined }));
}}
className={`rounded-lg border-gray-300 dark:border-gray-700 focus-visible:ring-primary ${
errors.bunker ? 'border-red-500' : ''
}`}
placeholder='bunker://' placeholder='bunker://'
autoComplete="off"
/> />
{bunkerUri && !bunkerUri.startsWith('bunker://') && ( {errors.bunker && (
<p className='text-red-500 text-xs'>URI must start with bunker://</p> <p className="text-sm text-red-500">{errors.bunker}</p>
)} )}
</div> </div>
<Button <div className="flex justify-center">
className='w-full rounded-full py-6' <Button
onClick={handleBunkerLogin} className='w-full rounded-full py-4'
disabled={isLoading || !bunkerUri.trim() || !bunkerUri.startsWith('bunker://')} onClick={handleBunkerLogin}
> disabled={isLoading || !bunkerUri.trim()}
{isLoading ? 'Connecting...' : 'Login with Bunker'} >
</Button> {isLoading ? 'Connecting...' : 'Login with Bunker'}
</Button>
</div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<div className='text-center text-sm'>
<p className='text-gray-600 dark:text-gray-400'>
Don't have an account?{' '}
<button
onClick={handleSignupClick}
className='text-primary hover:underline font-medium'
>
Sign up
</button>
</p>
</div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };
export default LoginDialog; export default LoginDialog;

View File

@ -1,163 +1,792 @@
// NOTE: This file is stable and usually should not be modified. // 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. // It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
import React, { useState } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Download, Key } from 'lucide-react'; import { Download, Key, Compass, Scroll, Shield, Crown, Sparkles, MapPin, Gem, Map, Star, Zap, Lock, CheckCircle, Copy, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button.tsx'; import { Button } from '@/components/ui/button';
import { import { Input } from '@/components/ui/input';
Dialog, import { Textarea } from '@/components/ui/textarea';
DialogContent, import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
DialogDescription, import { Card, CardContent } from '@/components/ui/card';
DialogHeader, import { toast } from '@/hooks/useToast';
DialogTitle,
} from '@/components/ui/dialog.tsx';
import { toast } from '@/hooks/useToast.ts';
import { useLoginActions } from '@/hooks/useLoginActions'; import { useLoginActions } from '@/hooks/useLoginActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { generateSecretKey, nip19 } from 'nostr-tools'; import { generateSecretKey, nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils';
interface SignupDialogProps { interface SignupDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onComplete?: () => void;
} }
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => { const sanitizeFilename = (filename: string) => {
const [step, setStep] = useState<'generate' | 'download' | 'done'>('generate'); return filename.replace(/[^a-z0-9_.-]/gi, '_');
}
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete }) => {
const [step, setStep] = useState<'welcome' | 'generate' | 'download' | 'profile' | 'done'>('welcome');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [nsec, setNsec] = useState(''); const [nsec, setNsec] = useState('');
const [showSparkles, setShowSparkles] = useState(false);
const [keySecured, setKeySecured] = useState<'none' | 'copied' | 'downloaded'>('none');
const [profileData, setProfileData] = useState({
name: '',
about: '',
picture: ''
});
const login = useLoginActions(); const login = useLoginActions();
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const avatarFileInputRef = useRef<HTMLInputElement>(null);
// Generate a proper nsec key using nostr-tools // Generate a proper nsec key using nostr-tools
const generateKey = () => { const generateKey = () => {
setIsLoading(true); setIsLoading(true);
setShowSparkles(true);
try {
// Generate a new secret key // Add a dramatic pause for the treasure generation effect
const sk = generateSecretKey(); setTimeout(() => {
try {
// Convert to nsec format // Generate a new secret key
setNsec(nip19.nsecEncode(sk)); const sk = generateSecretKey();
setStep('download');
} catch (error) { // Convert to nsec format
console.error('Failed to generate key:', error); setNsec(nip19.nsecEncode(sk));
toast({ setStep('download');
title: 'Error',
description: 'Failed to generate key. Please try again.', toast({
variant: 'destructive', title: 'Your Treasure Key is Ready!',
}); description: 'A magical key has been forged just for you.',
} finally { });
setIsLoading(false); } catch {
} toast({
title: 'Error',
description: 'Failed to generate key. Please try again.',
variant: 'destructive',
});
} finally {
setIsLoading(false);
setShowSparkles(false);
}
}, 2000);
}; };
const downloadKey = () => { const downloadKey = () => {
// Create a blob with the key text try {
const blob = new Blob([nsec], { type: 'text/plain' }); // Create a blob with the key text
const url = globalThis.URL.createObjectURL(blob); const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' });
const url = globalThis.URL.createObjectURL(blob);
// Create a temporary link element and trigger download // Sanitize filename
const a = document.createElement('a'); const filename = sanitizeFilename('treasure-key.txt');
a.href = url;
a.download = 'nsec.txt';
document.body.appendChild(a);
a.click();
// Clean up // Create a temporary link element and trigger download
globalThis.URL.revokeObjectURL(url); const a = document.createElement('a');
document.body.removeChild(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: 'Treasure Key Secured!',
description: 'Your key has been safely stored in your vault. Guard it well!',
});
} catch {
toast({
title: 'Download failed',
description: 'Could not download the key file. Please copy it manually.',
variant: 'destructive',
});
}
};
const copyKey = () => {
navigator.clipboard.writeText(nsec);
setKeySecured('copied');
toast({ toast({
title: 'Key downloaded', title: 'Copied to your spellbook!',
description: 'Your key has been downloaded. Keep it safe!', description: 'Key safely transcribed to clipboard',
}); });
}; };
const finishSignup = () => { const finishKeySetup = () => {
login.nsec(nsec); try {
login.nsec(nsec);
setStep('done'); setStep('profile');
onClose(); } catch {
toast({
toast({ title: 'Login Failed',
title: 'Account created', description: 'Failed to login with the generated key. Please try again.',
description: 'You are now logged in.', 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('treasures_last_signup', 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 adventurer profile has been set up.',
});
}
// Close signup and show welcome modal
onClose();
if (onComplete) {
// Add a longer delay to ensure login state has fully propagated
setTimeout(() => {
onComplete();
}, 600);
} else {
// Fallback for when used without onComplete
setStep('done');
setTimeout(() => {
onClose();
toast({
title: 'Welcome to the Adventure!',
description: 'Your quest begins now!',
});
}, 3000);
}
} 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
onClose();
if (onComplete) {
// Add a longer delay to ensure login state has fully propagated
setTimeout(() => {
onComplete();
}, 600);
} else {
// Fallback for when used without onComplete
setStep('done');
setTimeout(() => {
onClose();
toast({
title: 'Welcome to the Adventure!',
description: 'Your quest begins now!',
});
}, 3000);
}
}
};
const getTitle = () => {
if (step === 'welcome') return (
<span className="flex items-center justify-center gap-2">
<Map className="w-5 h-5 text-green-600 adventure:text-amber-700" />
Begin Your Quest
</span>
);
if (step === 'generate') return (
<span className="flex items-center justify-center gap-2">
<Sparkles className="w-5 h-5 text-purple-600 adventure:text-amber-700" />
Forging Your Key
</span>
);
if (step === 'download') return (
<span className="flex items-center justify-center gap-2">
<Lock className="w-5 h-5 text-green-600 adventure:text-amber-700" />
Secure Your Treasure Key
</span>
);
if (step === 'profile') return (
<span className="flex items-center justify-center gap-2">
<Crown className="w-5 h-5 text-green-600 adventure:text-amber-700" />
Create Your Profile
</span>
);
return (
<span className="flex items-center justify-center gap-2">
<Crown className="w-5 h-5 text-green-600 adventure:text-amber-700" />
Welcome, Adventurer!
</span>
);
};
const getDescription = () => {
if (step === 'welcome') return 'Ready to discover hidden geocaches around the world?';
if (step === 'generate') return 'Creating your magical key to unlock Treasures';
if (step === 'download') return 'This key is your passport to adventure - keep it safe!';
if (step === 'profile') return 'Tell other adventurers about yourself';
return 'Your adventure begins now!';
};
// Reset state when dialog opens
useEffect(() => {
if (isOpen) {
setStep('welcome');
setIsLoading(false);
setNsec('');
setShowSparkles(false);
setKeySecured('none');
setProfileData({ name: '', about: '', picture: '' });
}
}, [isOpen]);
// Add sparkle animation effect
useEffect(() => {
if (showSparkles) {
const interval = setInterval(() => {
// This will trigger re-renders for sparkle animation
}, 100);
return () => clearInterval(interval);
}
}, [showSparkles]);
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className='sm:max-w-md p-0 overflow-hidden rounded-2xl'> <DialogContent
<DialogHeader className='px-6 pt-6 pb-0 relative'> className={cn("max-w-[95vw] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl flex flex-col")}
<DialogTitle className='text-xl font-semibold text-center'> >
{step === 'generate' && 'Create Your Account'} <DialogHeader className={cn('px-6 pt-6 pb-1 relative flex-shrink-0')}>
{step === 'download' && 'Download Your Key'} <DialogTitle className={cn('font-semibold text-center text-lg')}>
{step === 'done' && 'Setting Up Your Account'} {getTitle()}
</DialogTitle> </DialogTitle>
<DialogDescription className='text-center text-muted-foreground mt-2'> <DialogDescription className={cn('text-muted-foreground text-center')}>
{step === 'generate' && 'Generate a secure key for your account'} {getDescription()}
{step === 'download' && "Keep your key safe - you'll need it to log in"}
{step === 'done' && 'Finalizing your account setup'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className='px-6 pt-2 pb-4 space-y-4 overflow-y-auto flex-1'>
{/* Welcome Step - New engaging introduction */}
{step === 'welcome' && (
<div className='text-center space-y-4'>
{/* Hero illustration */}
<div className='relative p-6 rounded-2xl bg-gradient-to-br from-green-50 to-emerald-100 dark:from-green-950/50 dark:to-emerald-950/50 adventure:from-amber-50 adventure:to-orange-100 adventure:dark:from-amber-950/50 adventure:dark:to-orange-950/50'>
<div className='flex justify-center items-center space-x-4 mb-3'>
<div className='relative'>
<MapPin className='w-12 h-12 text-green-600 adventure:text-amber-700 animate-bounce' />
<Sparkles className='w-4 h-4 text-yellow-500 absolute -top-1 -right-1 animate-pulse' />
</div>
<Compass className='w-16 h-16 text-green-700 adventure:text-amber-800 animate-spin-slow' />
<div className='relative'>
<Gem className='w-12 h-12 text-green-600 adventure:text-amber-700 animate-bounce' style={{animationDelay: '0.5s'}} />
<Star className='w-4 h-4 text-yellow-500 absolute -top-1 -left-1 animate-pulse' style={{animationDelay: '0.3s'}} />
</div>
</div>
<div className='px-6 py-8 space-y-6'> {/* Adventure benefits */}
{step === 'generate' && ( <div className='grid grid-cols-1 gap-2 text-sm'>
<div className='text-center space-y-6'> <div className='flex items-center justify-center gap-2 text-green-700 dark:text-green-300 adventure:text-amber-800 adventure:dark:text-amber-200'>
<div className='p-4 rounded-lg bg-gray-50 dark:bg-gray-800 flex items-center justify-center'> <Shield className='w-4 h-4' />
<Key className='w-16 h-16 text-primary' /> Embark on legendary quests worldwide
</div>
<div className='flex items-center justify-center gap-2 text-green-700 dark:text-green-300 adventure:text-amber-800 adventure:dark:text-amber-200'>
<Crown className='w-4 h-4' />
Hide your own geocaches
</div>
<div className='flex items-center justify-center gap-2 text-green-700 dark:text-green-300 adventure:text-amber-800 adventure:dark:text-amber-200'>
<Map className='w-4 h-4' />
Unite with fellow adventurers
</div>
</div>
</div>
<div className='space-y-3'>
<p className='text-muted-foreground px-5'>
Join adventurers exploring the world through geocaching.
Your quest begins with forging your very own treasure key.
</p>
<Button
className='w-full rounded-full py-6 text-lg font-semibold bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 adventure:from-amber-700 adventure:to-orange-700 adventure:hover:from-amber-800 adventure:hover:to-orange-800 transform transition-all duration-200 hover:scale-105 shadow-lg'
onClick={() => setStep('generate')}
>
<Zap className='w-5 h-5 mr-2' />
Begin My Quest!
</Button>
<p className='text-xs text-muted-foreground'>
Free forever Decentralized Your data, your control
</p>
</div> </div>
<p className='text-sm text-gray-600 dark:text-gray-300'>
We'll generate a secure key for your account. You'll need this key to log in later.
</p>
<Button
className='w-full rounded-full py-6'
onClick={generateKey}
disabled={isLoading}
>
{isLoading ? 'Generating key...' : 'Generate my key'}
</Button>
</div> </div>
)} )}
{/* Generate Step - Enhanced with animations */}
{step === 'generate' && (
<div className='text-center space-y-4'>
<div className='relative p-6 rounded-2xl bg-gradient-to-br from-blue-50 to-purple-100 dark:from-blue-950/50 dark:to-purple-950/50 adventure:from-amber-50 adventure:to-yellow-100 adventure:dark:from-amber-950/50 adventure:dark:to-yellow-950/50 overflow-hidden'>
{/* Animated background elements */}
{showSparkles && (
<div className='absolute inset-0'>
{[...Array(12)].map((_, i) => (
<Sparkles
key={i}
className={`absolute w-4 h-4 text-yellow-400 animate-ping`}
style={{
left: `${Math.random() * 80 + 10}%`,
top: `${Math.random() * 80 + 10}%`,
animationDelay: `${Math.random() * 2}s`
}}
/>
))}
</div>
)}
<div className='relative z-10'>
{isLoading ? (
<div className='space-y-3'>
<div className='relative'>
<Key className='w-20 h-20 text-primary mx-auto animate-pulse' />
<div className='absolute inset-0 flex items-center justify-center'>
<div className='w-24 h-24 border-4 border-yellow-400 border-t-transparent rounded-full animate-spin'></div>
</div>
</div>
<div className='space-y-2'>
<p className='text-lg font-semibold text-primary flex items-center justify-center gap-2'>
<Sparkles className='w-5 h-5' />
Forging your magical key...
</p>
<p className='text-sm text-muted-foreground'>
Weaving cryptographic spells
</p>
</div>
</div>
) : (
<div className='space-y-3'>
<Key className='w-20 h-20 text-primary mx-auto' />
<div className='space-y-2'>
<p className='text-lg font-semibold'>
Ready to forge your treasure key?
</p>
<p className='text-sm text-muted-foreground px-5'>
This magical key will be your passport to the world of Treasures.
</p>
<p className='text-sm text-muted-foreground px-5'>
It's completely unique and secure - keep it secret, keep it safe!
</p>
</div>
</div>
)}
</div>
</div>
{!isLoading && (
<Button
className='w-full rounded-full py-6 text-lg font-semibold bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 adventure:from-amber-700 adventure:to-yellow-700 adventure:hover:from-amber-800 adventure:hover:to-yellow-800 transform transition-all duration-200 hover:scale-105 shadow-lg'
onClick={generateKey}
disabled={isLoading}
>
<Sparkles className='w-5 h-5 mr-2' />
Forge My Treasure Key!
</Button>
)}
</div>
)}
{/* Download Step - Whimsical and magical */}
{step === 'download' && ( {step === 'download' && (
<div className='space-y-6'> <div className='text-center space-y-4'>
<div className='p-4 rounded-lg border bg-gray-50 dark:bg-gray-800 overflow-auto'> {/* Magical treasure chest reveal */}
<code className='text-xs break-all'>{nsec}</code> <div className='relative p-6 rounded-2xl bg-gradient-to-br from-green-50 to-emerald-100 dark:from-green-950/50 dark:to-emerald-950/50 adventure:from-amber-50 adventure:to-orange-100 adventure:dark:from-amber-950/50 adventure:dark:to-orange-950/50 overflow-hidden'>
{/* Magical sparkles floating around */}
<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'}} />
<Star 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'}} />
<Star className='absolute bottom-3 right-4 w-3 h-3 text-yellow-500 animate-pulse' style={{animationDelay: '1.5s'}} />
</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-yellow-200 to-amber-300 adventure:from-amber-200 adventure:to-orange-300 rounded-full flex items-center justify-center shadow-lg animate-pulse'>
<Key className='w-8 h-8 text-amber-800 adventure:text-orange-900' />
</div>
<div className='absolute -top-1 -right-1 w-5 h-5 bg-green-500 adventure:bg-emerald-600 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'>
Behold! Your magical treasure key!
</p>
{/* Whimsical warning with scroll design */}
<div className='relative mx-auto max-w-sm'>
<div className='p-3 bg-gradient-to-r from-amber-100 via-yellow-50 to-amber-100 dark:from-amber-950/40 dark:via-yellow-950/20 dark:to-amber-950/40 adventure:from-orange-100 adventure:via-amber-50 adventure:to-orange-100 adventure:dark:from-orange-950/40 adventure:dark:via-amber-950/20 adventure:dark:to-orange-950/40 rounded-lg border-2 border-amber-300 dark:border-amber-700 adventure:border-orange-400 adventure:dark:border-orange-600 shadow-md'>
<div className='flex items-center gap-2 mb-1'>
<Scroll className='w-3 h-3 text-amber-700 adventure:text-orange-800' />
<span className='text-xs font-bold text-amber-800 dark:text-amber-200 adventure:text-orange-900 adventure:dark:text-orange-200'>
Ancient Warning
</span>
</div>
<p className='text-xs text-amber-700 dark:text-amber-300 adventure:text-orange-800 adventure:dark:text-orange-300 italic'>
"Guard this key with your life, for once lost to the digital winds, it shall never return..."
</p>
</div>
</div>
</div>
</div> </div>
<div className='text-sm text-gray-600 dark:text-gray-300 space-y-2'> {/* Enchanted key vault */}
<p className='font-medium text-red-500'>Important:</p> <div className='relative p-3 bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-800 dark:to-slate-900 adventure:from-amber-100 adventure:to-yellow-200 adventure:dark:from-amber-900 adventure:to-yellow-900 rounded-xl border-2 border-dashed border-amber-400 dark:border-amber-600 adventure:border-orange-500 adventure:dark:border-orange-400 shadow-inner'>
<ul className='list-disc pl-5 space-y-1'> <div className='flex items-center gap-2 mb-2'>
<li>This is your only way to access your account</li> <Lock className='w-4 h-4 text-amber-600 adventure:text-orange-700' />
<li>Store it somewhere safe</li> <span className='text-sm font-medium text-amber-800 dark:text-amber-200 adventure:text-orange-800 adventure:dark:text-orange-200'>
<li>Never share this key with anyone</li> Your Treasure Key
</ul> </span>
</div>
<div className='p-2 bg-background/90 rounded-lg border border-amber-300 dark:border-amber-700 adventure:border-orange-400 adventure:dark:border-orange-600'>
<code className='text-xs break-all font-mono text-amber-900 dark:text-amber-100 adventure:text-orange-900 adventure:dark:text-orange-100'>{nsec}</code>
</div>
</div> </div>
<div className='flex flex-col space-y-3'> {/* Security options - clearly presented as choices */}
<div className='space-y-3'>
<div className='text-center'>
<p className='text-sm font-medium text-muted-foreground mb-2'>
Choose how to secure your key:
</p>
</div>
<div className='grid grid-cols-1 gap-2'>
{/* Copy Option */}
<Card className={`cursor-pointer transition-all duration-200 ${
keySecured === 'copied'
? 'ring-2 ring-green-500 adventure:ring-amber-500 bg-green-50 dark:bg-green-950/20 adventure:bg-amber-50 adventure:dark:bg-amber-950/20'
: 'hover:bg-muted/50 dark:bg-muted'
}`}>
<CardContent className='p-3'>
<Button
variant="ghost"
className='w-full h-auto p-0 justify-start'
onClick={copyKey}
>
<div className='flex items-center gap-3 w-full'>
<div className={`p-1.5 rounded-lg ${
keySecured === 'copied'
? 'bg-green-100 dark:bg-green-900 adventure:bg-amber-100 adventure:dark:bg-amber-900'
: 'bg-muted'
}`}>
{keySecured === 'copied' ? (
<CheckCircle className='w-4 h-4 text-green-600 adventure:text-amber-600' />
) : (
<Copy className='w-4 h-4 text-muted-foreground' />
)}
</div>
<div className='flex-1 text-left'>
<div className='font-medium text-sm'>
Copy to Clipboard
</div>
<div className='text-xs text-muted-foreground'>
Save to password manager
</div>
</div>
{keySecured === 'copied' && (
<div className='text-xs font-medium text-green-600 adventure:text-amber-600'>
Copied
</div>
)}
</div>
</Button>
</CardContent>
</Card>
{/* Download Option */}
<Card className={`cursor-pointer transition-all duration-200 ${
keySecured === 'downloaded'
? 'ring-2 ring-green-500 adventure:ring-amber-500 bg-green-50 dark:bg-green-950/20 adventure:bg-amber-50 adventure:dark:bg-amber-950/20'
: 'hover:bg-muted/50 dark:bg-muted'
}`}>
<CardContent className='p-3'>
<Button
variant="ghost"
className='w-full h-auto p-0 justify-start'
onClick={downloadKey}
>
<div className='flex items-center gap-3 w-full'>
<div className={`p-1.5 rounded-lg ${
keySecured === 'downloaded'
? 'bg-green-100 dark:bg-green-900 adventure:bg-amber-100 adventure:dark:bg-amber-900'
: 'bg-muted'
}`}>
{keySecured === 'downloaded' ? (
<CheckCircle className='w-4 h-4 text-green-600 adventure:text-amber-600' />
) : (
<Download className='w-4 h-4 text-muted-foreground' />
)}
</div>
<div className='flex-1 text-left'>
<div className='font-medium text-sm'>
Download as File
</div>
<div className='text-xs text-muted-foreground'>
Save as treasure-key.txt file
</div>
</div>
{keySecured === 'downloaded' && (
<div className='text-xs font-medium text-green-600 adventure:text-amber-600'>
Downloaded
</div>
)}
</div>
</Button>
</CardContent>
</Card>
</div>
{/* Continue button - blocked until key is secured */}
<Button
className={`w-full rounded-full py-4 text-base font-semibold transform transition-all duration-200 shadow-lg ${
keySecured !== 'none'
? 'bg-gradient-to-r from-emerald-600 to-green-600 hover:from-emerald-700 hover:to-green-700 adventure:from-amber-700 adventure:to-orange-700 adventure:hover:from-amber-800 adventure:hover:to-orange-800 hover:scale-105'
: 'bg-muted text-muted-foreground cursor-not-allowed'
}`}
onClick={finishKeySetup}
disabled={keySecured === 'none'}
>
<Compass className='w-4 h-4 mr-2 flex-shrink-0' />
<span className="text-center leading-tight">
{keySecured === 'none' ? (
<>
Please secure your key first
</>
) : (
<>
<span className="hidden sm:inline">My Key is Safe - Let the Quest Begin!</span>
<span className="sm:hidden">Key Secured - Begin Quest!</span>
</>
)}
</span>
</Button>
</div>
</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 adventure:from-amber-50 adventure:to-yellow-100 adventure:dark:from-amber-950/50 adventure:dark:to-yellow-950/50 overflow-hidden'>
{/* Magical 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'}} />
<Star className='absolute top-6 right-6 w-3 h-3 text-yellow-500 animate-pulse' style={{animationDelay: '0.5s'}} />
<Crown 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 adventure:from-amber-200 adventure:to-yellow-300 rounded-full flex items-center justify-center shadow-lg'>
<Crown className='w-8 h-8 text-blue-800 adventure:text-amber-800' />
</div>
<div className='absolute -top-1 -right-1 w-5 h-5 bg-blue-500 adventure:bg-amber-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'>
Almost there! Let's set up your profile
</p>
<p className='text-sm text-muted-foreground'>
Your legend starts here
</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 adventure:from-amber-50 adventure:to-yellow-50 adventure:dark:from-amber-950/30 adventure:dark:to-yellow-950/30 border border-blue-200 dark:border-blue-800 adventure:border-amber-200 adventure:dark:border-amber-800'>
<div className='flex items-center justify-center gap-3'>
<div className='w-5 h-5 border-2 border-blue-600 adventure:border-amber-600 border-t-transparent rounded-full animate-spin' />
<span className='text-sm font-medium text-blue-700 dark:text-blue-300 adventure:text-amber-700 adventure:dark:text-amber-300'>
Publishing your profile to the realm...
</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 adventure:from-amber-700 adventure:to-yellow-700 adventure:hover:from-amber-800 adventure:hover:to-yellow-800 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' />
Creating Profile...
</>
) : (
<>
<Crown className='w-4 h-4 mr-2' />
Create Profile & Begin Quest!
</>
)}
</Button>
<Button <Button
variant='outline' variant='outline'
className='w-full' className='w-full rounded-full py-3 disabled:opacity-50 disabled:cursor-not-allowed'
onClick={downloadKey} onClick={() => finishSignup(true)}
disabled={isPublishing || isUploading}
> >
<Download className='w-4 h-4 mr-2' /> {isPublishing ? (
Download Key <>
</Button> <div className='w-4 h-4 mr-2 border-2 border-current border-t-transparent rounded-full animate-spin' />
Setting up account...
<Button </>
className='w-full rounded-full py-6' ) : (
onClick={finishSignup} 'Skip for now - Begin Quest!'
> )}
I've saved my key, continue
</Button> </Button>
</div> </div>
</div> </div>
)} )}
{step === 'done' && (
<div className='flex justify-center items-center py-8'>
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-primary'></div>
</div>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>