@dubsdotapp/expo 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,11 @@ import {
7
7
  ActivityIndicator,
8
8
  StyleSheet,
9
9
  Keyboard,
10
+ KeyboardAvoidingView,
11
+ Platform,
12
+ Image,
13
+ Animated,
14
+ ScrollView,
10
15
  } from 'react-native';
11
16
  import { useAuth } from '../hooks';
12
17
  import { useDubs } from '../provider';
@@ -14,32 +19,41 @@ import { useDubsTheme } from './theme';
14
19
  import type { AuthStatus } from '../types';
15
20
  import type { DubsClient } from '../client';
16
21
 
22
+ // ── Avatar Helpers ──
23
+
24
+ const DICEBEAR_STYLES = [
25
+ 'adventurer',
26
+ 'avataaars',
27
+ 'fun-emoji',
28
+ 'bottts',
29
+ 'big-smile',
30
+ 'thumbs',
31
+ ];
32
+
33
+ function generateSeed(): string {
34
+ return Math.random().toString(36).slice(2, 10);
35
+ }
36
+
37
+ function getAvatarUrl(style: string, seed: string, size = 256): string {
38
+ return `https://api.dicebear.com/9.x/${style}/png?seed=${seed}&size=${size}`;
39
+ }
40
+
17
41
  // ── Public Types ──
18
42
 
19
43
  export interface RegistrationScreenProps {
20
- /** Called when the user submits registration */
21
- onRegister: (username: string, referralCode?: string) => void;
22
- /** Whether a registration request is in flight */
44
+ onRegister: (username: string, referralCode?: string, avatarUrl?: string) => void;
23
45
  registering: boolean;
24
- /** Error from the last registration attempt, or null */
25
46
  error: Error | null;
26
- /** DubsClient instance for checkUsername() availability checks */
27
47
  client: DubsClient;
28
48
  }
29
49
 
30
50
  export interface AuthGateProps {
31
51
  children: React.ReactNode;
32
- /** Persist or clear the JWT token (called with null on logout) */
33
52
  onSaveToken: (token: string | null) => void | Promise<void>;
34
- /** Load a previously persisted JWT token */
35
53
  onLoadToken: () => string | null | Promise<string | null>;
36
- /** Custom loading screen (receives current auth status) */
37
54
  renderLoading?: (status: AuthStatus) => React.ReactNode;
38
- /** Custom error screen (receives the error and a retry callback) */
39
55
  renderError?: (error: Error, retry: () => void) => React.ReactNode;
40
- /** Custom registration screen */
41
56
  renderRegistration?: (props: RegistrationScreenProps) => React.ReactNode;
42
- /** App name shown in default screens. Defaults to "Dubs" */
43
57
  appName?: string;
44
58
  }
45
59
 
@@ -59,53 +73,35 @@ export function AuthGate({
59
73
  const [phase, setPhase] = useState<'init' | 'active'>('init');
60
74
  const [registrationPhase, setRegistrationPhase] = useState(false);
61
75
 
62
- // Kick off the auth flow on mount
63
76
  useEffect(() => {
64
77
  let cancelled = false;
65
-
66
78
  (async () => {
67
79
  try {
68
80
  const savedToken = await onLoadToken();
69
81
  if (cancelled) return;
70
-
71
82
  if (savedToken) {
72
83
  const restored = await auth.restoreSession(savedToken);
73
84
  if (cancelled) return;
74
- if (restored) {
75
- setPhase('active');
76
- return;
77
- }
78
- // Token invalid — clear it
85
+ if (restored) { setPhase('active'); return; }
79
86
  await onSaveToken(null);
80
87
  }
81
-
82
88
  if (cancelled) return;
83
89
  setPhase('active');
84
- // No valid session — start fresh authentication
85
90
  await auth.authenticate();
86
91
  } catch {
87
92
  if (!cancelled) setPhase('active');
88
93
  }
89
94
  })();
90
-
91
- return () => {
92
- cancelled = true;
93
- };
95
+ return () => { cancelled = true; };
94
96
  // eslint-disable-next-line react-hooks/exhaustive-deps
95
97
  }, []);
96
98
 
97
- // Track when we enter the registration phase
98
99
  useEffect(() => {
99
- if (auth.status === 'needsRegistration') {
100
- setRegistrationPhase(true);
101
- }
100
+ if (auth.status === 'needsRegistration') setRegistrationPhase(true);
102
101
  }, [auth.status]);
103
102
 
104
- // Persist token when authentication or registration completes
105
103
  useEffect(() => {
106
- if (auth.token) {
107
- onSaveToken(auth.token);
108
- }
104
+ if (auth.token) onSaveToken(auth.token);
109
105
  // eslint-disable-next-line react-hooks/exhaustive-deps
110
106
  }, [auth.token]);
111
107
 
@@ -116,41 +112,26 @@ export function AuthGate({
116
112
  }, [auth]);
117
113
 
118
114
  const handleRegister = useCallback(
119
- (username: string, referralCode?: string) => {
120
- auth.register(username, referralCode);
115
+ (username: string, referralCode?: string, avatarUrl?: string) => {
116
+ auth.register(username, referralCode, avatarUrl);
121
117
  },
122
118
  [auth],
123
119
  );
124
120
 
125
- // ── Render Logic ──
121
+ // ── Render ──
126
122
 
127
- // Phase 1: Loading saved token
128
123
  if (phase === 'init') {
129
124
  if (renderLoading) return <>{renderLoading('authenticating')}</>;
130
125
  return <DefaultLoadingScreen status="authenticating" appName={appName} />;
131
126
  }
132
127
 
133
- // Authenticated render app
134
- if (auth.status === 'authenticated') {
135
- return <>{children}</>;
136
- }
128
+ if (auth.status === 'authenticated') return <>{children}</>;
137
129
 
138
- // Registration flow (including errors during registration)
139
130
  if (registrationPhase) {
140
131
  const isRegistering = auth.status === 'registering';
141
132
  const regError = auth.status === 'error' ? auth.error : null;
142
-
143
133
  if (renderRegistration) {
144
- return (
145
- <>
146
- {renderRegistration({
147
- onRegister: handleRegister,
148
- registering: isRegistering,
149
- error: regError,
150
- client,
151
- })}
152
- </>
153
- );
134
+ return <>{renderRegistration({ onRegister: handleRegister, registering: isRegistering, error: regError, client })}</>;
154
135
  }
155
136
  return (
156
137
  <DefaultRegistrationScreen
@@ -163,28 +144,19 @@ export function AuthGate({
163
144
  );
164
145
  }
165
146
 
166
- // Error (non-registration)
167
147
  if (auth.status === 'error' && auth.error) {
168
148
  if (renderError) return <>{renderError(auth.error, retry)}</>;
169
149
  return <DefaultErrorScreen error={auth.error} onRetry={retry} appName={appName} />;
170
150
  }
171
151
 
172
- // Loading states: idle, authenticating, signing, verifying
173
152
  if (renderLoading) return <>{renderLoading(auth.status)}</>;
174
153
  return <DefaultLoadingScreen status={auth.status} appName={appName} />;
175
154
  }
176
155
 
177
156
  // ── Default Loading Screen ──
178
157
 
179
- function DefaultLoadingScreen({
180
- status,
181
- appName,
182
- }: {
183
- status: AuthStatus;
184
- appName: string;
185
- }) {
158
+ function DefaultLoadingScreen({ status, appName }: { status: AuthStatus; appName: string }) {
186
159
  const t = useDubsTheme();
187
-
188
160
  const statusText: Record<AuthStatus, string> = {
189
161
  idle: 'Initializing...',
190
162
  authenticating: 'Connecting...',
@@ -195,21 +167,18 @@ function DefaultLoadingScreen({
195
167
  authenticated: 'Ready!',
196
168
  error: 'Something went wrong',
197
169
  };
198
-
199
170
  return (
200
- <View style={[styles.container, { backgroundColor: t.background }]}>
201
- <View style={styles.centerContent}>
202
- <View style={styles.brandingSection}>
203
- <View style={[styles.logoCircle, { backgroundColor: t.accent }]}>
204
- <Text style={styles.logoText}>D</Text>
171
+ <View style={[s.container, { backgroundColor: t.background }]}>
172
+ <View style={s.centerContent}>
173
+ <View style={s.brandingSection}>
174
+ <View style={[s.logoCircle, { backgroundColor: t.accent }]}>
175
+ <Text style={s.logoText}>D</Text>
205
176
  </View>
206
- <Text style={[styles.appName, { color: t.text }]}>{appName}</Text>
177
+ <Text style={[s.appNameText, { color: t.text }]}>{appName}</Text>
207
178
  </View>
208
- <View style={styles.loadingSection}>
179
+ <View style={s.loadingSection}>
209
180
  <ActivityIndicator size="large" color={t.accent} />
210
- <Text style={[styles.statusText, { color: t.textMuted }]}>
211
- {statusText[status] || 'Loading...'}
212
- </Text>
181
+ <Text style={[s.statusText, { color: t.textMuted }]}>{statusText[status] || 'Loading...'}</Text>
213
182
  </View>
214
183
  </View>
215
184
  </View>
@@ -218,51 +187,65 @@ function DefaultLoadingScreen({
218
187
 
219
188
  // ── Default Error Screen ──
220
189
 
221
- function DefaultErrorScreen({
222
- error,
223
- onRetry,
224
- appName,
225
- }: {
226
- error: Error;
227
- onRetry: () => void;
228
- appName: string;
229
- }) {
190
+ function DefaultErrorScreen({ error, onRetry, appName }: { error: Error; onRetry: () => void; appName: string }) {
230
191
  const t = useDubsTheme();
231
-
232
192
  return (
233
- <View style={[styles.container, { backgroundColor: t.background }]}>
234
- <View style={styles.spreadContent}>
235
- <View style={styles.brandingSection}>
236
- <View style={[styles.logoCircle, { backgroundColor: t.accent }]}>
237
- <Text style={styles.logoText}>D</Text>
193
+ <View style={[s.container, { backgroundColor: t.background }]}>
194
+ <View style={s.spreadContent}>
195
+ <View style={s.brandingSection}>
196
+ <View style={[s.logoCircle, { backgroundColor: t.accent }]}>
197
+ <Text style={s.logoText}>D</Text>
198
+ </View>
199
+ <Text style={[s.appNameText, { color: t.text }]}>{appName}</Text>
200
+ </View>
201
+ <View style={{ gap: 16 }}>
202
+ <View style={[s.errorBox, { backgroundColor: t.errorBg, borderColor: t.errorBorder }]}>
203
+ <Text style={[s.errorText, { color: t.errorText }]}>{error.message}</Text>
238
204
  </View>
239
- <Text style={[styles.appName, { color: t.text }]}>{appName}</Text>
205
+ <TouchableOpacity style={[s.primaryBtn, { backgroundColor: t.accent }]} onPress={onRetry} activeOpacity={0.8}>
206
+ <Text style={s.primaryBtnText}>Try Again</Text>
207
+ </TouchableOpacity>
240
208
  </View>
241
- <View style={styles.actionSection}>
209
+ </View>
210
+ </View>
211
+ );
212
+ }
213
+
214
+ // ── Step Indicator ──
215
+
216
+ function StepIndicator({ currentStep }: { currentStep: number }) {
217
+ const t = useDubsTheme();
218
+ const steps = [0, 1, 2, 3];
219
+ return (
220
+ <View style={s.stepRow}>
221
+ {steps.map((i) => (
222
+ <React.Fragment key={i}>
223
+ {i > 0 && (
224
+ <View style={[s.stepLine, { backgroundColor: i <= currentStep ? t.success : t.border }]} />
225
+ )}
242
226
  <View
243
227
  style={[
244
- styles.errorBox,
245
- { backgroundColor: t.errorBg, borderColor: t.errorBorder },
228
+ s.stepCircle,
229
+ i < currentStep
230
+ ? { backgroundColor: t.success }
231
+ : i === currentStep
232
+ ? { backgroundColor: t.accent }
233
+ : { backgroundColor: 'transparent', borderWidth: 2, borderColor: t.border },
246
234
  ]}
247
235
  >
248
- <Text style={[styles.errorText, { color: t.errorText }]}>
249
- {error.message}
250
- </Text>
236
+ {i < currentStep ? (
237
+ <Text style={s.stepCheck}>&#10003;</Text>
238
+ ) : (
239
+ <Text style={[s.stepNum, { color: i === currentStep ? '#FFF' : t.textMuted }]}>{i + 1}</Text>
240
+ )}
251
241
  </View>
252
- <TouchableOpacity
253
- style={[styles.button, { backgroundColor: t.accent }]}
254
- onPress={onRetry}
255
- activeOpacity={0.8}
256
- >
257
- <Text style={styles.buttonText}>Try Again</Text>
258
- </TouchableOpacity>
259
- </View>
260
- </View>
242
+ </React.Fragment>
243
+ ))}
261
244
  </View>
262
245
  );
263
246
  }
264
247
 
265
- // ── Default Registration Screen ──
248
+ // ── Default Registration Screen (3-Step Onboarding) ──
266
249
 
267
250
  function DefaultRegistrationScreen({
268
251
  onRegister,
@@ -272,128 +255,231 @@ function DefaultRegistrationScreen({
272
255
  appName,
273
256
  }: RegistrationScreenProps & { appName: string }) {
274
257
  const t = useDubsTheme();
258
+
259
+ // ── Shared state ──
260
+ const [step, setStep] = useState(0);
261
+ const [avatarSeed, setAvatarSeed] = useState(generateSeed);
262
+ const [avatarStyle, setAvatarStyle] = useState('adventurer');
263
+ const [showStyles, setShowStyles] = useState(false);
275
264
  const [username, setUsername] = useState('');
276
265
  const [referralCode, setReferralCode] = useState('');
277
266
  const [checking, setChecking] = useState(false);
278
- const [availability, setAvailability] = useState<{
279
- available: boolean;
280
- reason?: string;
281
- } | null>(null);
267
+ const [availability, setAvailability] = useState<{ available: boolean; reason?: string } | null>(null);
282
268
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
283
269
 
284
- // Debounced username availability check
270
+ // ── Animation ──
271
+ const fadeAnim = useRef(new Animated.Value(1)).current;
272
+ const slideAnim = useRef(new Animated.Value(0)).current;
273
+
274
+ const avatarUrl = getAvatarUrl(avatarStyle, avatarSeed);
275
+
276
+ // Debounced username check
285
277
  useEffect(() => {
286
278
  if (debounceRef.current) clearTimeout(debounceRef.current);
287
-
288
279
  const trimmed = username.trim();
289
- if (trimmed.length < 3) {
290
- setAvailability(null);
291
- setChecking(false);
292
- return;
293
- }
294
-
280
+ if (trimmed.length < 3) { setAvailability(null); setChecking(false); return; }
295
281
  setChecking(true);
296
282
  debounceRef.current = setTimeout(async () => {
297
283
  try {
298
284
  const result = await client.checkUsername(trimmed);
299
285
  setAvailability(result);
300
- } catch {
301
- setAvailability(null);
302
- } finally {
303
- setChecking(false);
304
- }
286
+ } catch { setAvailability(null); }
287
+ finally { setChecking(false); }
305
288
  }, 500);
306
-
307
- return () => {
308
- if (debounceRef.current) clearTimeout(debounceRef.current);
309
- };
289
+ return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
310
290
  }, [username, client]);
311
291
 
312
- const canSubmit =
313
- username.trim().length >= 3 &&
314
- availability?.available === true &&
315
- !registering &&
316
- !checking;
292
+ const animateToStep = useCallback((newStep: number) => {
293
+ const dir = newStep > step ? 1 : -1;
294
+ Keyboard.dismiss();
295
+ Animated.parallel([
296
+ Animated.timing(fadeAnim, { toValue: 0, duration: 120, useNativeDriver: true }),
297
+ Animated.timing(slideAnim, { toValue: -dir * 40, duration: 120, useNativeDriver: true }),
298
+ ]).start(() => {
299
+ setStep(newStep);
300
+ slideAnim.setValue(dir * 40);
301
+ Animated.parallel([
302
+ Animated.timing(fadeAnim, { toValue: 1, duration: 200, useNativeDriver: true }),
303
+ Animated.timing(slideAnim, { toValue: 0, duration: 200, useNativeDriver: true }),
304
+ ]).start();
305
+ });
306
+ }, [step, fadeAnim, slideAnim]);
307
+
308
+ const canContinueUsername =
309
+ username.trim().length >= 3 && availability?.available === true && !checking;
310
+
311
+ const handleSubmit = () => {
312
+ Keyboard.dismiss();
313
+ onRegister(username.trim(), referralCode.trim() || undefined, avatarUrl);
314
+ };
317
315
 
318
- return (
319
- <View style={[styles.container, { backgroundColor: t.background }]}>
320
- <View style={styles.spreadContent}>
321
- <View style={styles.brandingSection}>
322
- <View style={[styles.logoCircle, { backgroundColor: t.accent }]}>
323
- <Text style={styles.logoText}>D</Text>
316
+ // ── Step 1: Avatar ──
317
+ const renderAvatarStep = () => (
318
+ <View style={s.stepContainer}>
319
+ <View style={s.stepTop}>
320
+ <Text style={[s.title, { color: t.text }]}>Choose Your Avatar</Text>
321
+ <Text style={[s.subtitle, { color: t.textMuted }]}>Pick a look that represents you</Text>
322
+ <StepIndicator currentStep={0} />
323
+
324
+ <View style={s.avatarCenter}>
325
+ <View style={[s.avatarFrame, { borderColor: t.accent }]}>
326
+ <Image source={{ uri: avatarUrl }} style={s.avatarLarge} />
327
+ <View style={[s.checkBadge, { backgroundColor: t.success }]}>
328
+ <Text style={s.checkBadgeText}>&#10003;</Text>
329
+ </View>
324
330
  </View>
325
- <Text style={[styles.appName, { color: t.text }]}>{appName}</Text>
326
- <Text style={[styles.subtitle, { color: t.textMuted }]}>
327
- Choose a username to get started
328
- </Text>
329
331
  </View>
330
332
 
331
- <View style={styles.actionSection}>
332
- {error ? (
333
- <View
334
- style={[
335
- styles.errorBox,
336
- { backgroundColor: t.errorBg, borderColor: t.errorBorder },
337
- ]}
338
- >
339
- <Text style={[styles.errorText, { color: t.errorText }]}>
340
- {error.message}
341
- </Text>
333
+ <View style={s.avatarActions}>
334
+ <TouchableOpacity
335
+ style={[s.outlineBtn, { borderColor: t.border }]}
336
+ onPress={() => setAvatarSeed(generateSeed())}
337
+ activeOpacity={0.7}
338
+ >
339
+ <Text style={[s.outlineBtnText, { color: t.text }]}>&#8635; Shuffle</Text>
340
+ </TouchableOpacity>
341
+ <TouchableOpacity
342
+ style={[s.outlineBtn, { borderColor: t.accent, backgroundColor: t.accent + '15' }]}
343
+ onPress={() => setShowStyles(!showStyles)}
344
+ activeOpacity={0.7}
345
+ >
346
+ <Text style={[s.outlineBtnText, { color: t.accent }]}>&#9786; Customize</Text>
347
+ </TouchableOpacity>
348
+ </View>
349
+
350
+ {showStyles && (
351
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} style={s.styleScroll}>
352
+ {DICEBEAR_STYLES.map((st) => (
353
+ <TouchableOpacity
354
+ key={st}
355
+ onPress={() => setAvatarStyle(st)}
356
+ style={[s.styleThumbWrap, { borderColor: st === avatarStyle ? t.accent : t.border }]}
357
+ >
358
+ <Image source={{ uri: getAvatarUrl(st, avatarSeed, 80) }} style={s.styleThumb} />
359
+ </TouchableOpacity>
360
+ ))}
361
+ </ScrollView>
362
+ )}
363
+ </View>
364
+
365
+ <View style={s.bottomRow}>
366
+ <TouchableOpacity
367
+ style={[s.primaryBtn, { backgroundColor: t.accent, flex: 1 }]}
368
+ onPress={() => animateToStep(1)}
369
+ activeOpacity={0.8}
370
+ >
371
+ <Text style={s.primaryBtnText}>Continue &#8250;</Text>
372
+ </TouchableOpacity>
373
+ </View>
374
+ </View>
375
+ );
376
+
377
+ // ── Step 2: Username ──
378
+ const renderUsernameStep = () => (
379
+ <View style={s.stepContainer}>
380
+ <View style={s.stepTop}>
381
+ <View style={s.headerRow}>
382
+ <TouchableOpacity onPress={() => animateToStep(0)} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}>
383
+ <Text style={[s.backChevron, { color: t.text }]}>&#8249;</Text>
384
+ </TouchableOpacity>
385
+ <Text style={[s.titleInline, { color: t.text }]}>Pick a Username</Text>
386
+ </View>
387
+ <Text style={[s.subtitle, { color: t.textMuted }]}>This is how others will see you</Text>
388
+ <StepIndicator currentStep={1} />
389
+
390
+ <View style={s.avatarCenter}>
391
+ <View style={[s.avatarFrameSmall, { borderColor: t.accent }]}>
392
+ <Image source={{ uri: avatarUrl }} style={s.avatarSmall} />
393
+ <View style={[s.checkBadgeSm, { backgroundColor: t.success }]}>
394
+ <Text style={s.checkBadgeTextSm}>&#10003;</Text>
342
395
  </View>
396
+ </View>
397
+ </View>
398
+
399
+ <View style={s.inputGroup}>
400
+ <Text style={[s.inputLabel, { color: t.text }]}>
401
+ Username <Text style={{ color: t.errorText }}>*</Text>
402
+ </Text>
403
+ <TextInput
404
+ style={[s.input, { backgroundColor: t.surface, color: t.text, borderColor: t.accent }]}
405
+ placeholder="Enter username"
406
+ placeholderTextColor={t.textDim}
407
+ value={username}
408
+ onChangeText={setUsername}
409
+ autoCapitalize="none"
410
+ autoCorrect={false}
411
+ autoFocus
412
+ />
413
+ {checking ? (
414
+ <Text style={[s.hint, { color: t.textDim }]}>Checking...</Text>
415
+ ) : availability ? (
416
+ <Text style={[s.hint, { color: availability.available ? t.success : t.errorText }]}>
417
+ {availability.available ? '\u2713 Available!' : (availability.reason || 'Username taken')}
418
+ </Text>
419
+ ) : username.trim().length > 0 && username.trim().length < 3 ? (
420
+ <Text style={[s.hint, { color: t.textDim }]}>At least 3 characters</Text>
343
421
  ) : null}
422
+ </View>
423
+ </View>
344
424
 
345
- <View>
346
- <TextInput
347
- style={[
348
- styles.input,
349
- {
350
- backgroundColor: t.surface,
351
- color: t.text,
352
- borderColor: t.border,
353
- },
354
- ]}
355
- placeholder="Username"
356
- placeholderTextColor={t.textDim}
357
- value={username}
358
- onChangeText={setUsername}
359
- autoCapitalize="none"
360
- autoCorrect={false}
361
- editable={!registering}
362
- />
363
- {checking ? (
364
- <Text style={[styles.availabilityHint, { color: t.textDim }]}>
365
- Checking...
366
- </Text>
367
- ) : availability ? (
368
- <Text
369
- style={[
370
- styles.availabilityHint,
371
- {
372
- color: availability.available ? t.success : t.errorText,
373
- },
374
- ]}
375
- >
376
- {availability.available
377
- ? 'Available!'
378
- : availability.reason || 'Username taken'}
379
- </Text>
380
- ) : username.trim().length > 0 && username.trim().length < 3 ? (
381
- <Text style={[styles.availabilityHint, { color: t.textDim }]}>
382
- At least 3 characters
383
- </Text>
384
- ) : null}
425
+ <View style={s.bottomRow}>
426
+ <TouchableOpacity
427
+ style={[s.secondaryBtn, { borderColor: t.border }]}
428
+ onPress={() => animateToStep(0)}
429
+ activeOpacity={0.7}
430
+ >
431
+ <Text style={[s.secondaryBtnText, { color: t.text }]}>&#8249; Back</Text>
432
+ </TouchableOpacity>
433
+ <TouchableOpacity
434
+ style={[s.primaryBtn, { backgroundColor: t.accent, flex: 1, opacity: canContinueUsername ? 1 : 0.4 }]}
435
+ onPress={() => animateToStep(2)}
436
+ disabled={!canContinueUsername}
437
+ activeOpacity={0.8}
438
+ >
439
+ <Text style={s.primaryBtnText}>Continue &#8250;</Text>
440
+ </TouchableOpacity>
441
+ </View>
442
+ </View>
443
+ );
444
+
445
+ // ── Step 3: Referral + Create ──
446
+ const renderReferralStep = () => (
447
+ <View style={s.stepContainer}>
448
+ <View style={s.stepTop}>
449
+ <View style={s.headerRow}>
450
+ <TouchableOpacity onPress={() => animateToStep(1)} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}>
451
+ <Text style={[s.backChevron, { color: t.text }]}>&#8249;</Text>
452
+ </TouchableOpacity>
453
+ <Text style={[s.titleInline, { color: t.text }]}>Almost There!</Text>
454
+ </View>
455
+ <Text style={[s.subtitle, { color: t.textMuted }]}>Got a referral code? (optional)</Text>
456
+ <StepIndicator currentStep={2} />
457
+
458
+ {/* Profile preview card */}
459
+ <View style={[s.profileCard, { borderColor: t.border }]}>
460
+ <Text style={[s.profileLabel, { color: t.textMuted }]}>Your Profile</Text>
461
+ <View style={s.profileRow}>
462
+ <Image source={{ uri: avatarUrl }} style={s.profileAvatar} />
463
+ <View style={{ gap: 4 }}>
464
+ <Text style={[s.profileUsername, { color: t.text }]}>@{username}</Text>
465
+ <Text style={[s.profileReady, { color: t.success }]}>{'\u2713'} Ready to go!</Text>
466
+ </View>
467
+ </View>
468
+ </View>
469
+
470
+ {error ? (
471
+ <View style={[s.errorBox, { backgroundColor: t.errorBg, borderColor: t.errorBorder }]}>
472
+ <Text style={[s.errorText, { color: t.errorText }]}>{error.message}</Text>
385
473
  </View>
474
+ ) : null}
386
475
 
476
+ <View style={s.inputGroup}>
477
+ <Text style={[s.inputLabel, { color: t.text }]}>
478
+ Referral Code <Text style={{ color: t.textMuted }}>(optional)</Text>
479
+ </Text>
387
480
  <TextInput
388
- style={[
389
- styles.input,
390
- {
391
- backgroundColor: t.surface,
392
- color: t.text,
393
- borderColor: t.border,
394
- },
395
- ]}
396
- placeholder="Referral code (optional)"
481
+ style={[s.input, { backgroundColor: t.surface, color: t.text, borderColor: t.border }]}
482
+ placeholder="Enter referral code"
397
483
  placeholderTextColor={t.textDim}
398
484
  value={referralCode}
399
485
  onChangeText={setReferralCode}
@@ -401,120 +487,130 @@ function DefaultRegistrationScreen({
401
487
  autoCorrect={false}
402
488
  editable={!registering}
403
489
  />
404
-
405
- <TouchableOpacity
406
- style={[
407
- styles.button,
408
- { backgroundColor: t.accent, opacity: canSubmit ? 1 : 0.5 },
409
- ]}
410
- onPress={() => {
411
- Keyboard.dismiss();
412
- onRegister(username.trim(), referralCode.trim() || undefined);
413
- }}
414
- disabled={!canSubmit}
415
- activeOpacity={0.8}
416
- >
417
- {registering ? (
418
- <ActivityIndicator color="#FFFFFF" size="small" />
419
- ) : (
420
- <Text style={styles.buttonText}>Create Account</Text>
421
- )}
422
- </TouchableOpacity>
490
+ <Text style={[s.hint, { color: t.textMuted }]}>
491
+ {'\uD83C\uDF81'} If a friend invited you, enter their code to give them credit!
492
+ </Text>
423
493
  </View>
424
494
  </View>
495
+
496
+ <View style={s.bottomRow}>
497
+ <TouchableOpacity
498
+ style={[s.secondaryBtn, { borderColor: t.border }]}
499
+ onPress={() => animateToStep(1)}
500
+ disabled={registering}
501
+ activeOpacity={0.7}
502
+ >
503
+ <Text style={[s.secondaryBtnText, { color: t.text }]}>&#8249; Back</Text>
504
+ </TouchableOpacity>
505
+ <TouchableOpacity
506
+ style={[s.primaryBtn, { backgroundColor: t.accent, flex: 1, opacity: registering ? 0.7 : 1 }]}
507
+ onPress={handleSubmit}
508
+ disabled={registering}
509
+ activeOpacity={0.8}
510
+ >
511
+ {registering ? (
512
+ <ActivityIndicator color="#FFFFFF" size="small" />
513
+ ) : (
514
+ <Text style={s.primaryBtnText}>Create Account</Text>
515
+ )}
516
+ </TouchableOpacity>
517
+ </View>
425
518
  </View>
426
519
  );
520
+
521
+ const renderStep = () => {
522
+ switch (step) {
523
+ case 0: return renderAvatarStep();
524
+ case 1: return renderUsernameStep();
525
+ case 2: return renderReferralStep();
526
+ default: return null;
527
+ }
528
+ };
529
+
530
+ return (
531
+ <KeyboardAvoidingView
532
+ style={[s.container, { backgroundColor: t.background }]}
533
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
534
+ >
535
+ <ScrollView
536
+ contentContainerStyle={{ flexGrow: 1 }}
537
+ keyboardShouldPersistTaps="handled"
538
+ bounces={false}
539
+ >
540
+ <Animated.View
541
+ style={[
542
+ { flex: 1 },
543
+ { opacity: fadeAnim, transform: [{ translateX: slideAnim }] },
544
+ ]}
545
+ >
546
+ {renderStep()}
547
+ </Animated.View>
548
+ </ScrollView>
549
+ </KeyboardAvoidingView>
550
+ );
427
551
  }
428
552
 
429
553
  // ── Styles ──
430
554
 
431
- const styles = StyleSheet.create({
432
- container: {
433
- flex: 1,
434
- justifyContent: 'center',
435
- },
436
- centerContent: {
437
- flex: 1,
438
- justifyContent: 'center',
439
- alignItems: 'center',
440
- paddingHorizontal: 32,
441
- gap: 48,
442
- },
443
- spreadContent: {
444
- flex: 1,
445
- justifyContent: 'space-between',
446
- paddingHorizontal: 32,
447
- paddingTop: 120,
448
- paddingBottom: 80,
449
- },
450
- brandingSection: {
451
- alignItems: 'center',
452
- gap: 12,
453
- },
454
- logoCircle: {
455
- width: 80,
456
- height: 80,
457
- borderRadius: 40,
458
- justifyContent: 'center',
459
- alignItems: 'center',
460
- marginBottom: 8,
461
- },
462
- logoText: {
463
- fontSize: 36,
464
- fontWeight: '800',
465
- color: '#FFFFFF',
466
- },
467
- appName: {
468
- fontSize: 32,
469
- fontWeight: '800',
470
- },
471
- subtitle: {
472
- fontSize: 16,
473
- textAlign: 'center',
474
- lineHeight: 22,
475
- },
476
- loadingSection: {
477
- alignItems: 'center',
478
- gap: 16,
479
- },
480
- statusText: {
481
- fontSize: 16,
482
- textAlign: 'center',
483
- },
484
- actionSection: {
485
- gap: 16,
486
- },
487
- errorBox: {
488
- borderWidth: 1,
489
- borderRadius: 12,
490
- paddingHorizontal: 16,
491
- paddingVertical: 12,
492
- },
493
- errorText: {
494
- fontSize: 14,
495
- textAlign: 'center',
496
- },
497
- input: {
498
- height: 56,
499
- borderRadius: 16,
500
- borderWidth: 1,
501
- paddingHorizontal: 16,
502
- fontSize: 16,
503
- },
504
- availabilityHint: {
505
- fontSize: 13,
506
- marginTop: 6,
507
- paddingLeft: 4,
508
- },
509
- button: {
510
- height: 56,
511
- borderRadius: 16,
512
- justifyContent: 'center',
513
- alignItems: 'center',
514
- },
515
- buttonText: {
516
- color: '#FFFFFF',
517
- fontSize: 18,
518
- fontWeight: '700',
519
- },
555
+ const s = StyleSheet.create({
556
+ container: { flex: 1 },
557
+ // Loading / Error
558
+ centerContent: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 32, gap: 48 },
559
+ spreadContent: { flex: 1, justifyContent: 'space-between', paddingHorizontal: 32, paddingTop: 120, paddingBottom: 80 },
560
+ brandingSection: { alignItems: 'center', gap: 12 },
561
+ logoCircle: { width: 80, height: 80, borderRadius: 40, justifyContent: 'center', alignItems: 'center', marginBottom: 8 },
562
+ logoText: { fontSize: 36, fontWeight: '800', color: '#FFFFFF' },
563
+ appNameText: { fontSize: 32, fontWeight: '800' },
564
+ loadingSection: { alignItems: 'center', gap: 16 },
565
+ statusText: { fontSize: 16, textAlign: 'center' },
566
+ errorBox: { borderWidth: 1, borderRadius: 12, paddingHorizontal: 16, paddingVertical: 12 },
567
+ errorText: { fontSize: 14, textAlign: 'center' },
568
+ // Step indicator
569
+ stepRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 24, marginVertical: 16 },
570
+ stepLine: { flex: 1, height: 2 },
571
+ stepCircle: { width: 36, height: 36, borderRadius: 18, justifyContent: 'center', alignItems: 'center' },
572
+ stepCheck: { color: '#FFF', fontSize: 16, fontWeight: '700' },
573
+ stepNum: { fontSize: 14, fontWeight: '700' },
574
+ // Onboarding layout
575
+ stepContainer: { flex: 1, justifyContent: 'space-between', paddingBottom: 40 },
576
+ stepTop: { gap: 8, paddingTop: 64 },
577
+ headerRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 24 },
578
+ backChevron: { fontSize: 32, fontWeight: '300', lineHeight: 36, marginTop: -2 },
579
+ title: { fontSize: 28, fontWeight: '800', paddingHorizontal: 24 },
580
+ titleInline: { fontSize: 28, fontWeight: '800' },
581
+ subtitle: { fontSize: 15, paddingHorizontal: 24, lineHeight: 20 },
582
+ // Avatar
583
+ avatarCenter: { alignItems: 'center', marginVertical: 12 },
584
+ avatarFrame: { borderWidth: 3, borderRadius: 20, padding: 4 },
585
+ avatarLarge: { width: 160, height: 160, borderRadius: 16, backgroundColor: '#E5E5EA' },
586
+ checkBadge: { position: 'absolute', bottom: -6, right: -6, width: 28, height: 28, borderRadius: 14, justifyContent: 'center', alignItems: 'center' },
587
+ checkBadgeText: { color: '#FFF', fontSize: 15, fontWeight: '700' },
588
+ avatarFrameSmall: { borderWidth: 3, borderRadius: 14, padding: 3 },
589
+ avatarSmall: { width: 100, height: 100, borderRadius: 12, backgroundColor: '#E5E5EA' },
590
+ checkBadgeSm: { position: 'absolute', bottom: -4, right: -4, width: 22, height: 22, borderRadius: 11, justifyContent: 'center', alignItems: 'center' },
591
+ checkBadgeTextSm: { color: '#FFF', fontSize: 12, fontWeight: '700' },
592
+ avatarActions: { flexDirection: 'row', justifyContent: 'center', gap: 12, paddingHorizontal: 24 },
593
+ outlineBtn: { borderWidth: 1.5, borderRadius: 12, paddingHorizontal: 20, paddingVertical: 12 },
594
+ outlineBtnText: { fontSize: 15, fontWeight: '600' },
595
+ styleScroll: { paddingHorizontal: 24, marginTop: 4 },
596
+ styleThumbWrap: { borderWidth: 2, borderRadius: 12, padding: 3, marginRight: 10 },
597
+ styleThumb: { width: 52, height: 52, borderRadius: 10, backgroundColor: '#E5E5EA' },
598
+ // Input
599
+ inputGroup: { paddingHorizontal: 24, gap: 6 },
600
+ inputLabel: { fontSize: 15, fontWeight: '600' },
601
+ input: { height: 56, borderRadius: 16, borderWidth: 1.5, paddingHorizontal: 16, fontSize: 16 },
602
+ hint: { fontSize: 13, paddingLeft: 4 },
603
+ // Profile card
604
+ profileCard: { marginHorizontal: 24, borderWidth: 1, borderRadius: 16, padding: 16, gap: 12 },
605
+ profileLabel: { fontSize: 13 },
606
+ profileRow: { flexDirection: 'row', alignItems: 'center', gap: 14 },
607
+ profileAvatar: { width: 56, height: 56, borderRadius: 12, backgroundColor: '#E5E5EA' },
608
+ profileUsername: { fontSize: 20, fontWeight: '800' },
609
+ profileReady: { fontSize: 14, fontWeight: '600' },
610
+ // Buttons
611
+ bottomRow: { flexDirection: 'row', gap: 12, paddingHorizontal: 24 },
612
+ primaryBtn: { height: 56, borderRadius: 16, justifyContent: 'center', alignItems: 'center' },
613
+ primaryBtnText: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
614
+ secondaryBtn: { height: 56, borderRadius: 16, borderWidth: 1.5, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
615
+ secondaryBtnText: { fontSize: 16, fontWeight: '600' },
520
616
  });