import React, { useState, useEffect, useRef, useCallback } from 'react'; import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, StyleSheet, Keyboard, KeyboardAvoidingView, Platform, Image, Animated, ScrollView, } from 'react-native'; import { useAuth } from '../hooks'; import { usePushNotifications } from '../hooks/usePushNotifications'; import { useDubs } from '../provider'; import { AuthContext } from '../auth-context'; import { useDubsTheme } from './theme'; import { AvatarEditor, generateSeed, getAvatarUrl } from './AvatarEditor'; import type { AuthStatus } from '../types'; import type { DubsClient } from '../client'; // ── Public Types ── export interface RegistrationScreenProps { onRegister: (username: string, referralCode?: string, avatarUrl?: string) => void; registering: boolean; error: Error | null; client: DubsClient; } export interface ConnectWalletScreenProps { /** Call this to trigger the wallet connection flow */ onConnect: () => void; /** True while the wallet is connecting/signing */ connecting: boolean; /** Error from a failed connection attempt */ error: Error | null; } export interface AuthGateProps { children: React.ReactNode; onSaveToken: (token: string | null) => void | Promise; onLoadToken: () => string | null | Promise; renderLoading?: (status: AuthStatus) => React.ReactNode; renderError?: (error: Error, retry: () => void) => React.ReactNode; renderRegistration?: (props: RegistrationScreenProps) => React.ReactNode; /** * Render your own connect-wallet screen. * Receives { onConnect, connecting, error }. * If omitted, the default ConnectWalletScreen is shown. */ renderConnectWallet?: (props: ConnectWalletScreenProps) => React.ReactNode; appName?: string; /** Override accent color for registration screens (from developer UI config) */ accentColor?: string; } // ── AuthGate Component ── export function AuthGate({ children, onSaveToken, onLoadToken, renderLoading, renderError, renderRegistration, renderConnectWallet, appName = 'Dubs', accentColor, }: AuthGateProps) { const { client, pushEnabled, uiConfig } = useDubs(); const auth = useAuth(); const [phase, setPhase] = useState<'init' | 'active'>('init'); const [registrationPhase, setRegistrationPhase] = useState(false); const [showPushSetup, setShowPushSetup] = useState(false); const [isRestoredSession, setIsRestoredSession] = useState(false); useEffect(() => { let cancelled = false; (async () => { try { const savedToken = await onLoadToken(); if (cancelled) return; if (savedToken) { const restored = await auth.restoreSession(savedToken); if (cancelled) return; if (restored) { setIsRestoredSession(true); setPhase('active'); return; } await onSaveToken(null); } if (cancelled) return; setPhase('active'); await auth.authenticate(); } catch { if (!cancelled) setPhase('active'); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (auth.status === 'needsRegistration') setRegistrationPhase(true); }, [auth.status]); // Show push setup after new registration completes (not restored sessions). // Gated on pushConfigured.android — if the developer hasn't uploaded FCM credentials // for this app in the dev portal, push can't be delivered, so don't prompt. useEffect(() => { if ( pushEnabled && uiConfig.pushConfigured?.android && auth.status === 'authenticated' && registrationPhase && !isRestoredSession ) { setShowPushSetup(true); } }, [pushEnabled, uiConfig.pushConfigured?.android, auth.status, registrationPhase, isRestoredSession]); useEffect(() => { if (auth.token) onSaveToken(auth.token); // eslint-disable-next-line react-hooks/exhaustive-deps }, [auth.token]); const retry = useCallback(() => { setRegistrationPhase(false); auth.reset(); auth.authenticate(); }, [auth]); const handleRegister = useCallback( (username: string, referralCode?: string, avatarUrl?: string) => { auth.register(username, referralCode, avatarUrl); }, [auth], ); // ── Render ── if (phase === 'init') { if (renderLoading) return <>{renderLoading('authenticating')}; return ; } // Keep the registration screen mounted through the push step so the transition // from step 3 (referral) → step 4 (push) animates as a continuation of the same flow. const inRegistrationFlow = registrationPhase && (auth.status === 'needsRegistration' || auth.status === 'registering' || (auth.status === 'authenticated' && showPushSetup)); if (auth.status === 'authenticated' && !inRegistrationFlow) { return ( {pushEnabled && } {children} ); } if (inRegistrationFlow) { const isRegistering = auth.status === 'registering'; const regError = auth.status === 'error' ? auth.error : null; if (renderRegistration) { return <>{renderRegistration({ onRegister: handleRegister, registering: isRegistering, error: regError, client })}; } return ( setShowPushSetup(false)} /> ); } if (auth.status === 'error' && auth.error) { if (renderError) return <>{renderError(auth.error, retry)}; return ; } // idle = waiting for user to connect — show connect wallet screen if (auth.status === 'idle') { const connectProps: ConnectWalletScreenProps = { onConnect: auth.authenticate, connecting: false, error: null, }; if (renderConnectWallet) return <>{renderConnectWallet(connectProps)}; // Fall through to default loading (existing behaviour if no renderConnectWallet) } if (renderLoading) return <>{renderLoading(auth.status)}; return ; } // ── Default Loading Screen ── function DefaultLoadingScreen({ status, appName, accentColor }: { status: AuthStatus; appName: string; accentColor?: string }) { const t = useDubsTheme(); const accent = accentColor || t.accent; const statusText: Record = { idle: 'Initializing...', authenticating: 'Connecting...', signing: 'Approve in your wallet...', verifying: 'Verifying...', registering: 'Creating account...', needsRegistration: 'Almost there...', authenticated: 'Ready!', error: 'Something went wrong', }; return ( D {appName} {statusText[status] || 'Loading...'} ); } // ── Default Error Screen ── function DefaultErrorScreen({ error, onRetry, appName, accentColor }: { error: Error; onRetry: () => void; appName: string; accentColor?: string }) { const t = useDubsTheme(); const accent = accentColor || t.accent; return ( D {appName} {error.message} Try Again ); } // ── Step Indicator ── function StepIndicator({ currentStep }: { currentStep: number }) { const t = useDubsTheme(); const steps = [0, 1, 2, 3]; return ( {steps.map((i) => ( {i > 0 && ( )} {i < currentStep ? ( ) : ( {i + 1} )} ))} ); } // ── Default Registration Screen (3-Step Onboarding) ── function DefaultRegistrationScreen({ onRegister, registering, error, client, appName, accentColor, pushStepActive, onPushComplete, }: RegistrationScreenProps & { appName: string; accentColor?: string; pushStepActive?: boolean; onPushComplete?: () => void; }) { const t = useDubsTheme(); const accent = accentColor || t.accent; const push = usePushNotifications(); // ── Shared state ── const [step, setStep] = useState(0); const [avatarSeed, setAvatarSeed] = useState(generateSeed); const [avatarStyle, setAvatarStyle] = useState('adventurer'); const [avatarBg, setAvatarBg] = useState('1a1a2e'); const [showStyles, setShowStyles] = useState(false); const [username, setUsername] = useState(''); const [referralCode, setReferralCode] = useState(''); const [checking, setChecking] = useState(false); const [availability, setAvailability] = useState<{ available: boolean; reason?: string } | null>(null); const debounceRef = useRef | null>(null); // ── Animation ── const fadeAnim = useRef(new Animated.Value(1)).current; const slideAnim = useRef(new Animated.Value(0)).current; const avatarUrl = getAvatarUrl(avatarStyle, avatarSeed, avatarBg); // Debounced username check useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); const trimmed = username.trim(); if (trimmed.length < 3) { setAvailability(null); setChecking(false); return; } setChecking(true); debounceRef.current = setTimeout(async () => { try { const result = await client.checkUsername(trimmed); setAvailability(result); } catch { setAvailability(null); } finally { setChecking(false); } }, 500); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [username, client]); const animateToStep = useCallback((newStep: number) => { const dir = newStep > step ? 1 : -1; Keyboard.dismiss(); Animated.parallel([ Animated.timing(fadeAnim, { toValue: 0, duration: 120, useNativeDriver: true }), Animated.timing(slideAnim, { toValue: -dir * 40, duration: 120, useNativeDriver: true }), ]).start(() => { setStep(newStep); slideAnim.setValue(dir * 40); Animated.parallel([ Animated.timing(fadeAnim, { toValue: 1, duration: 200, useNativeDriver: true }), Animated.timing(slideAnim, { toValue: 0, duration: 200, useNativeDriver: true }), ]).start(); }); }, [step, fadeAnim, slideAnim]); const canContinueUsername = username.trim().length >= 3 && availability?.available === true && !checking; const handleSubmit = () => { Keyboard.dismiss(); onRegister(username.trim(), referralCode.trim() || undefined, avatarUrl); }; // ── Step 1: Avatar ── const renderAvatarStep = () => ( Choose Your Avatar Pick a look that represents you setAvatarSeed(generateSeed())} activeOpacity={0.7} > ↻ Shuffle setShowStyles(!showStyles)} activeOpacity={0.7} > ☺ Customize {showStyles && ( )} animateToStep(1)} activeOpacity={0.8} > Continue › ); // ── Step 2: Username ── const renderUsernameStep = () => ( animateToStep(0)} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}> Pick a Username This is how others will see you Username * {checking ? ( Checking... ) : availability ? ( {availability.available ? '\u2713 Available!' : (availability.reason || 'Username taken')} ) : username.trim().length > 0 && username.trim().length < 3 ? ( At least 3 characters ) : null} animateToStep(0)} activeOpacity={0.7} > ‹ Back animateToStep(2)} disabled={!canContinueUsername} activeOpacity={0.8} > Continue › ); // ── Step 3: Referral + Create ── const renderReferralStep = () => ( animateToStep(1)} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}> Almost There! Got a referral code? (optional) {/* Profile preview card */} Your Profile @{username} {'\u2713'} Ready to go! {error ? ( {error.message} ) : null} Referral Code (optional) {'\uD83C\uDF81'} If a friend invited you, enter their code to give them credit! animateToStep(1)} disabled={registering} activeOpacity={0.7} > ‹ Back {registering ? ( ) : ( Create Account )} ); // Advance to the push step when the parent signals (after auth completes). // Uses the same animateToStep transition as steps 0→1→2 so it feels like // a continuation, not a separate modal swap. useEffect(() => { if (pushStepActive && step !== 3) { animateToStep(3); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [pushStepActive]); // ── Step 4: Push Notifications ── const handleEnablePush = async () => { try { await push.register(); } catch {} onPushComplete?.(); }; const renderPushStep = () => ( Enable Notifications Stay in the loop with real-time updates {'🔔'} Get real-time updates when: {[ 'A fight you picked on goes LIVE', 'Your pick wins or loses', 'Final results and rankings', ].map((item, i) => ( {item} ))} Maybe Later {push.loading ? ( ) : ( Enable Notifications )} ); const renderStep = () => { switch (step) { case 0: return renderAvatarStep(); case 1: return renderUsernameStep(); case 2: return renderReferralStep(); case 3: return renderPushStep(); default: return null; } }; return ( {renderStep()} ); } // ── Push Token Restorer (silent, zero UI) ── // Runs inside DubsContext + AuthGate when user is authenticated. // Silently re-registers the push token for returning users who already granted permission. // Does NOT prompt — the prompt only happens in step 4 of DefaultRegistrationScreen during onboarding. function PushTokenRestorer() { const push = usePushNotifications(); const restored = useRef(false); useEffect(() => { if (restored.current) return; restored.current = true; push.restoreIfGranted(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return null; } const pushStyles = StyleSheet.create({ iconContainer: { alignItems: 'center', marginVertical: 24 }, bellCircle: { width: 100, height: 100, borderRadius: 50, justifyContent: 'center', alignItems: 'center' }, bellIcon: { fontSize: 48 }, benefitsList: { paddingHorizontal: 24, gap: 12, marginTop: 8 }, benefitsHeader: { fontSize: 17, fontWeight: '700', marginBottom: 4 }, benefitRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, bulletDot: { width: 8, height: 8, borderRadius: 4 }, benefitText: { fontSize: 16, flex: 1 }, }); // ── Styles ── const s = StyleSheet.create({ container: { flex: 1 }, // Loading / Error centerContent: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 32, gap: 48 }, spreadContent: { flex: 1, justifyContent: 'space-between', paddingHorizontal: 32, paddingTop: 120, paddingBottom: 80 }, brandingSection: { alignItems: 'center', gap: 12 }, logoCircle: { width: 80, height: 80, borderRadius: 40, justifyContent: 'center', alignItems: 'center', marginBottom: 8 }, logoText: { fontSize: 36, fontWeight: '800', color: '#FFFFFF' }, appNameText: { fontSize: 32, fontWeight: '800' }, loadingSection: { alignItems: 'center', gap: 16 }, statusText: { fontSize: 16, textAlign: 'center' }, errorBox: { borderWidth: 1, borderRadius: 12, paddingHorizontal: 16, paddingVertical: 12 }, errorText: { fontSize: 14, textAlign: 'center' }, // Step indicator stepRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 24, marginVertical: 16 }, stepLine: { flex: 1, height: 2 }, stepCircle: { width: 36, height: 36, borderRadius: 18, justifyContent: 'center', alignItems: 'center' }, stepCheck: { color: '#FFF', fontSize: 16, fontWeight: '700' }, stepNum: { fontSize: 14, fontWeight: '700' }, // Onboarding layout stepContainer: { flex: 1, justifyContent: 'space-between', paddingBottom: 40 }, stepTop: { gap: 8, paddingTop: 64 }, headerRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 24 }, backChevron: { fontSize: 32, fontWeight: '300', lineHeight: 36, marginTop: -2 }, title: { fontSize: 28, fontWeight: '800', paddingHorizontal: 24 }, titleInline: { fontSize: 28, fontWeight: '800' }, subtitle: { fontSize: 15, paddingHorizontal: 24, lineHeight: 20 }, // Avatar avatarCenter: { alignItems: 'center', marginVertical: 12 }, avatarFrame: { borderWidth: 3, borderRadius: 20, padding: 4 }, avatarLarge: { width: 160, height: 160, borderRadius: 16, backgroundColor: '#E5E5EA' }, checkBadge: { position: 'absolute', bottom: -6, right: -6, width: 28, height: 28, borderRadius: 14, justifyContent: 'center', alignItems: 'center' }, checkBadgeText: { color: '#FFF', fontSize: 15, fontWeight: '700' }, avatarFrameSmall: { borderWidth: 3, borderRadius: 14, padding: 3 }, avatarSmall: { width: 100, height: 100, borderRadius: 12, backgroundColor: '#E5E5EA' }, checkBadgeSm: { position: 'absolute', bottom: -4, right: -4, width: 22, height: 22, borderRadius: 11, justifyContent: 'center', alignItems: 'center' }, checkBadgeTextSm: { color: '#FFF', fontSize: 12, fontWeight: '700' }, avatarActions: { flexDirection: 'row', justifyContent: 'center', gap: 12, paddingHorizontal: 24 }, outlineBtn: { borderWidth: 1.5, borderRadius: 12, paddingHorizontal: 20, paddingVertical: 12 }, outlineBtnText: { fontSize: 15, fontWeight: '600' }, styleScroll: { paddingHorizontal: 24, marginTop: 4 }, styleThumbWrap: { borderWidth: 2, borderRadius: 12, padding: 3, marginRight: 10 }, styleThumb: { width: 52, height: 52, borderRadius: 10, backgroundColor: '#E5E5EA' }, // Input inputGroup: { paddingHorizontal: 24, gap: 6 }, inputLabel: { fontSize: 15, fontWeight: '600' }, input: { height: 56, borderRadius: 16, borderWidth: 1.5, paddingHorizontal: 16, fontSize: 16 }, hint: { fontSize: 13, paddingLeft: 4 }, // Profile card profileCard: { marginHorizontal: 24, borderWidth: 1, borderRadius: 16, padding: 16, gap: 12 }, profileLabel: { fontSize: 13 }, profileRow: { flexDirection: 'row', alignItems: 'center', gap: 14 }, profileAvatar: { width: 56, height: 56, borderRadius: 12, backgroundColor: '#E5E5EA' }, profileUsername: { fontSize: 20, fontWeight: '800' }, profileReady: { fontSize: 14, fontWeight: '600' }, // Buttons bottomRow: { flexDirection: 'row', gap: 12, paddingHorizontal: 24 }, primaryBtn: { height: 56, borderRadius: 16, justifyContent: 'center', alignItems: 'center' }, primaryBtnText: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' }, secondaryBtn: { height: 56, borderRadius: 16, borderWidth: 1.5, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 }, secondaryBtnText: { fontSize: 16, fontWeight: '600' }, });