| |
|
| |
|
| | "use client"; |
| |
|
| | import React, { useState } from 'react'; |
| | import { doc, setDoc, getDoc, writeBatch } from "firebase/firestore"; |
| | import { useAuth } from '@/contexts/auth-context'; |
| | import { useFirebase } from '@/contexts/firebase-context'; |
| | import { useSettings } from '@/contexts/settings-context'; |
| | import { Button } from '@/components/ui/button'; |
| | import { Input } from '@/components/ui/input'; |
| | import { Label } from '@/components/ui/label'; |
| | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; |
| | import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; |
| | import { Logo } from './logo'; |
| | import { Sparkles, Languages, HelpCircle, ArrowRight, ArrowLeft } from 'lucide-react'; |
| | import words from 'mnemonic-words'; |
| | import { cn } from '@/lib/utils'; |
| | import type { Language } from '@/lib/types'; |
| | import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from '@/components/ui/dialog'; |
| | import { Separator } from './ui/separator'; |
| | import { Progress } from './ui/progress'; |
| |
|
| |
|
| | const InfoBubble = ({ text, emoji }: { text: string, emoji: string }) => ( |
| | <div className="info-bubble pointer-events-none"> |
| | <span className="mr-1">{emoji}</span> |
| | {text} |
| | </div> |
| | ); |
| |
|
| | const TOTAL_STEPS = 3; |
| |
|
| | const StepIndicator = ({ current, total, t }: { current: number, total: number, t: (key: string) => string }) => ( |
| | <div className="w-full px-2 pb-2"> |
| | <Progress value={(current / total) * 100} className="h-2" /> |
| | <p className="text-xs text-muted-foreground text-center mt-2"> |
| | {t('step')} {current} / {total} |
| | </p> |
| | </div> |
| | ); |
| |
|
| |
|
| | export const Onboarding = () => { |
| | const { setAuthStatus, auth, handleAuthenticatedUser } = useAuth(); |
| | const { db } = useFirebase(); |
| | const { addToast, t, language, setLanguage, playSound } = useSettings(); |
| | const [displayName, setDisplayName] = useState(''); |
| | const [publicId, setPublicId] = useState(''); |
| | const [recoveryId, setRecoveryId] = useState(''); |
| | const [step, setStep] = useState(1); |
| | const [isLoading, setIsLoading] = useState(false); |
| | |
| |
|
| | const handleNextStep = () => { |
| | playSound('touch'); |
| | if (step === 1 && !displayName.trim()) { |
| | addToast(t('nameCannotBeEmpty')); |
| | return; |
| | } |
| | if (step === 2 && publicId.trim().length < 4) { |
| | addToast(t('publicIdMinLength')); |
| | return; |
| | } |
| | setStep(prev => prev + 1); |
| | } |
| | |
| | const handlePrevStep = () => { |
| | playSound('touch'); |
| | setStep(prev => prev - 1); |
| | } |
| |
|
| | const handleCreateProfile = async () => { |
| | setIsLoading(true); |
| | playSound('touch'); |
| | const trimmedDisplayName = displayName.trim(); |
| | const trimmedPublicId = publicId.trim().toLowerCase(); |
| | const trimmedRecoveryId = recoveryId.trim(); |
| |
|
| | if (!trimmedRecoveryId || trimmedRecoveryId.length < 8) { |
| | addToast("Recovery ID must be at least 8 characters."); |
| | setIsLoading(false); |
| | return; |
| | } |
| |
|
| | try { |
| | const publicIdRef = doc(db, 'publicIds', trimmedPublicId); |
| | const recoveryIdRef = doc(db, 'recovery', trimmedRecoveryId); |
| |
|
| | const [publicIdSnap, recoveryIdSnap] = await Promise.all([ |
| | getDoc(publicIdRef), |
| | getDoc(recoveryIdRef) |
| | ]); |
| |
|
| | if (publicIdSnap.exists()) { |
| | addToast("This Public ID is already taken. Please choose another."); |
| | setStep(2); |
| | setIsLoading(false); |
| | return; |
| | } |
| | if (recoveryIdSnap.exists()) { |
| | addToast("This Recovery ID is already in use. Please choose a more unique one."); |
| | setIsLoading(false); |
| | return; |
| | } |
| |
|
| | const user = auth.currentUser; |
| | if (!user) throw new Error("User not signed in."); |
| |
|
| | const androidId = localStorage.getItem('android_id'); |
| |
|
| | const profile = { |
| | uid: user.uid, |
| | displayName: trimmedDisplayName, |
| | publicId: trimmedPublicId, |
| | photoURL: `https://api.dicebear.com/8.x/initials/svg?seed=${trimmedDisplayName}`, |
| | ...(androidId && { androidId }), |
| | }; |
| | |
| | const batch = writeBatch(db); |
| | batch.set(doc(db, 'users', user.uid), profile); |
| | batch.set(doc(db, 'publicIds', trimmedPublicId), { uid: user.uid }); |
| | batch.set(recoveryIdRef, { uid: user.uid }); |
| |
|
| | await batch.commit(); |
| |
|
| | setStep(TOTAL_STEPS + 1); |
| | } catch (error: any) { |
| | console.error("Profile creation error:", error); |
| | addToast(`Error: ${error.message}`); |
| | } finally { |
| | setIsLoading(false); |
| | } |
| | }; |
| | |
| | const handleFinishOnboarding = () => { |
| | playSound('touch'); |
| | if (auth.currentUser) { |
| | handleAuthenticatedUser(auth.currentUser); |
| | } else { |
| | setAuthStatus('loading'); |
| | } |
| | } |
| |
|
| | const generateRandomRecoveryId = () => { |
| | playSound('touch'); |
| | const randomWords = []; |
| | for (let i = 0; i < 4; i++) { |
| | const randomIndex = Math.floor(Math.random() * words.length); |
| | randomWords.push(words[randomIndex]); |
| | } |
| | setRecoveryId(randomWords.join('-')); |
| | }; |
| | |
| | const handleLanguageChange = (lang: Language) => { |
| | playSound('touch'); |
| | setLanguage(lang); |
| | }; |
| |
|
| | if (step > TOTAL_STEPS) { |
| | return ( |
| | <div className="min-h-svh flex items-center justify-center bg-background p-4"> |
| | <Card className="w-full max-w-md"> |
| | <CardHeader> |
| | <Logo /> |
| | <CardTitle className="text-center">{t('recoveryTitle')}</CardTitle> |
| | <CardDescription className="text-center">{t('recoveryDescription')}</CardDescription> |
| | </CardHeader> |
| | <CardContent> |
| | <Alert variant="default"> |
| | <AlertTitle>{t('yourRecoveryId')}</AlertTitle> |
| | <AlertDescription> |
| | {t('youChose')}: <strong className="font-mono">{recoveryId}</strong>. {t('keepItSafe')}! |
| | </AlertDescription> |
| | </Alert> |
| | </CardContent> |
| | <CardFooter> |
| | <Button onClick={handleFinishOnboarding} className="w-full btn-gradient">{t('continueToApp')}</Button> |
| | </CardFooter> |
| | </Card> |
| | </div> |
| | ); |
| | } |
| | |
| | const renderStepContent = () => { |
| | switch (step) { |
| | case 1: |
| | return ( |
| | <div className="space-y-1"> |
| | <Label htmlFor="displayName">{t('displayNameLabel')}</Label> |
| | <Input id="displayName" type="text" value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder={t('displayNameLabel')} autoFocus/> |
| | <p className="text-xs text-muted-foreground px-2">{t('displayNameHint')}</p> |
| | </div> |
| | ); |
| | case 2: |
| | return ( |
| | <div className="space-y-1"> |
| | <Label htmlFor="publicId">{t('publicIdLabel')}</Label> |
| | <Input id="publicId" type="text" value={publicId} onChange={e => setPublicId(e.target.value.replace(/[^a-z0-9-]/gi, '').toLowerCase())} placeholder={t('publicIdLabel')} autoFocus/> |
| | <p className="text-xs text-muted-foreground px-2">{t('publicIdHint')}</p> |
| | </div> |
| | ); |
| | case 3: |
| | return ( |
| | <div className="space-y-1"> |
| | <Label htmlFor="recoveryId">{t('recoveryIdPlaceholder')}</Label> |
| | <div className="flex items-center gap-2"> |
| | <Input id="recoveryId" type="text" value={recoveryId} onChange={e => setRecoveryId(e.target.value)} placeholder={t('recoveryIdPlaceholder')} className="flex-1" autoFocus/> |
| | <Button variant="ghost" size="icon" onClick={generateRandomRecoveryId} title="Generate random ID"> |
| | <Sparkles className="h-5 w-5" /> |
| | </Button> |
| | </div> |
| | <p className="text-xs text-muted-foreground px-2">{t('recoveryIdHint')}</p> |
| | </div> |
| | ); |
| | default: |
| | return null; |
| | } |
| | } |
| |
|
| | return ( |
| | <div className="min-h-svh flex justify-center bg-background p-4 sm:p-6 md:p-8" dir={language === 'ar' ? 'rtl' : 'ltr'}> |
| | <div className="absolute top-0 left-0 right-0 h-screen w-screen overflow-hidden pointer-events-none"> |
| | <InfoBubble emoji="💡" text={t('bubble1')} /> |
| | <InfoBubble emoji="🔒" text={t('bubble2')} /> |
| | <InfoBubble emoji="👤" text={t('bubble3')} /> |
| | <InfoBubble emoji="✨" text={t('bubble4')} /> |
| | </div> |
| | <Card className="w-full max-w-sm z-10 bg-background/80 backdrop-blur-sm self-center"> |
| | <CardHeader> |
| | <div className="relative w-full flex justify-center mb-4"> |
| | <Logo /> |
| | </div> |
| | <div className="flex items-center justify-center gap-2 pt-2"> |
| | <CardTitle className="text-center">{t('createProfileTitle')}</CardTitle> |
| | <Dialog> |
| | <DialogTrigger asChild> |
| | <Button variant="ghost" size="icon" className="h-6 w-6"> |
| | <HelpCircle className="h-4 w-4" /> |
| | </Button> |
| | </DialogTrigger> |
| | <DialogContent> |
| | <DialogHeader> |
| | <DialogTitle>{t('howLoginWorksTitle')}</DialogTitle> |
| | </DialogHeader> |
| | <div className="space-y-4 text-sm text-muted-foreground"> |
| | <p>{t('howLoginWorksP1')}</p> |
| | <ul className="space-y-2 list-disc pl-5"> |
| | <li><strong className="text-foreground">{t('howLoginWorksL1Title')}</strong> {t('howLoginWorksL1Text')}</li> |
| | <li><strong className="text-foreground">{t('howLoginWorksL2Title')}</strong> {t('howLoginWorksL2Text')}</li> |
| | </ul> |
| | <p>{t('howLoginWorksP2')}</p> |
| | </div> |
| | </DialogContent> |
| | </Dialog> |
| | </div> |
| | <CardDescription className="text-center pt-2"> |
| | {t('onboardingDescription')} |
| | </CardDescription> |
| | </CardHeader> |
| | <CardContent className="space-y-4"> |
| | <StepIndicator current={step} total={TOTAL_STEPS} t={t} /> |
| | {renderStepContent()} |
| | </CardContent> |
| | <CardFooter className="flex-col gap-4"> |
| | <div className="w-full flex justify-between items-center"> |
| | <Button variant="outline" onClick={handlePrevStep} disabled={step === 1}> |
| | <ArrowLeft className="h-4 w-4" /> |
| | </Button> |
| | {step < TOTAL_STEPS ? ( |
| | <Button onClick={handleNextStep}> |
| | {t('nextStep')} <ArrowRight className="h-4 w-4" /> |
| | </Button> |
| | ) : ( |
| | <Button onClick={handleCreateProfile} disabled={isLoading} className="btn-gradient flex-1"> |
| | {isLoading ? t('creating') : t('createProfileButton')} |
| | </Button> |
| | )} |
| | </div> |
| | <Separator /> |
| | <div className="w-full flex justify-center items-center gap-2"> |
| | <Button variant={language === 'en' ? 'secondary' : 'ghost'} size="sm" onClick={() => handleLanguageChange('en')}>English</Button> |
| | <Languages className="w-4 h-4 text-muted-foreground"/> |
| | <Button variant={language === 'ar' ? 'secondary' : 'ghost'} size="sm" onClick={() => handleLanguageChange('ar')}>العربية</Button> |
| | </div> |
| | <p className="text-center text-sm text-muted-foreground"> |
| | {t('haveAnAccount')}{' '} |
| | <Button variant="link" className="p-0" onClick={() => { playSound('touch'); setAuthStatus('recovery'); }}> |
| | {t('recoverAccount')} |
| | </Button> |
| | </p> |
| | </CardFooter> |
| | </Card> |
| | </div> |
| | ); |
| | }; |
| |
|