@dubsdotapp/expo 0.1.0 → 0.1.2

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.
@@ -0,0 +1,520 @@
1
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TextInput,
6
+ TouchableOpacity,
7
+ ActivityIndicator,
8
+ StyleSheet,
9
+ Keyboard,
10
+ } from 'react-native';
11
+ import { useAuth } from '../hooks';
12
+ import { useDubs } from '../provider';
13
+ import { useDubsTheme } from './theme';
14
+ import type { AuthStatus } from '../types';
15
+ import type { DubsClient } from '../client';
16
+
17
+ // ── Public Types ──
18
+
19
+ 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 */
23
+ registering: boolean;
24
+ /** Error from the last registration attempt, or null */
25
+ error: Error | null;
26
+ /** DubsClient instance for checkUsername() availability checks */
27
+ client: DubsClient;
28
+ }
29
+
30
+ export interface AuthGateProps {
31
+ children: React.ReactNode;
32
+ /** Persist or clear the JWT token (called with null on logout) */
33
+ onSaveToken: (token: string | null) => void | Promise<void>;
34
+ /** Load a previously persisted JWT token */
35
+ onLoadToken: () => string | null | Promise<string | null>;
36
+ /** Custom loading screen (receives current auth status) */
37
+ renderLoading?: (status: AuthStatus) => React.ReactNode;
38
+ /** Custom error screen (receives the error and a retry callback) */
39
+ renderError?: (error: Error, retry: () => void) => React.ReactNode;
40
+ /** Custom registration screen */
41
+ renderRegistration?: (props: RegistrationScreenProps) => React.ReactNode;
42
+ /** App name shown in default screens. Defaults to "Dubs" */
43
+ appName?: string;
44
+ }
45
+
46
+ // ── AuthGate Component ──
47
+
48
+ export function AuthGate({
49
+ children,
50
+ onSaveToken,
51
+ onLoadToken,
52
+ renderLoading,
53
+ renderError,
54
+ renderRegistration,
55
+ appName = 'Dubs',
56
+ }: AuthGateProps) {
57
+ const { client } = useDubs();
58
+ const auth = useAuth();
59
+ const [phase, setPhase] = useState<'init' | 'active'>('init');
60
+ const [registrationPhase, setRegistrationPhase] = useState(false);
61
+
62
+ // Kick off the auth flow on mount
63
+ useEffect(() => {
64
+ let cancelled = false;
65
+
66
+ (async () => {
67
+ try {
68
+ const savedToken = await onLoadToken();
69
+ if (cancelled) return;
70
+
71
+ if (savedToken) {
72
+ const restored = await auth.restoreSession(savedToken);
73
+ if (cancelled) return;
74
+ if (restored) {
75
+ setPhase('active');
76
+ return;
77
+ }
78
+ // Token invalid — clear it
79
+ await onSaveToken(null);
80
+ }
81
+
82
+ if (cancelled) return;
83
+ setPhase('active');
84
+ // No valid session — start fresh authentication
85
+ await auth.authenticate();
86
+ } catch {
87
+ if (!cancelled) setPhase('active');
88
+ }
89
+ })();
90
+
91
+ return () => {
92
+ cancelled = true;
93
+ };
94
+ // eslint-disable-next-line react-hooks/exhaustive-deps
95
+ }, []);
96
+
97
+ // Track when we enter the registration phase
98
+ useEffect(() => {
99
+ if (auth.status === 'needsRegistration') {
100
+ setRegistrationPhase(true);
101
+ }
102
+ }, [auth.status]);
103
+
104
+ // Persist token when authentication or registration completes
105
+ useEffect(() => {
106
+ if (auth.token) {
107
+ onSaveToken(auth.token);
108
+ }
109
+ // eslint-disable-next-line react-hooks/exhaustive-deps
110
+ }, [auth.token]);
111
+
112
+ const retry = useCallback(() => {
113
+ setRegistrationPhase(false);
114
+ auth.reset();
115
+ auth.authenticate();
116
+ }, [auth]);
117
+
118
+ const handleRegister = useCallback(
119
+ (username: string, referralCode?: string) => {
120
+ auth.register(username, referralCode);
121
+ },
122
+ [auth],
123
+ );
124
+
125
+ // ── Render Logic ──
126
+
127
+ // Phase 1: Loading saved token
128
+ if (phase === 'init') {
129
+ if (renderLoading) return <>{renderLoading('authenticating')}</>;
130
+ return <DefaultLoadingScreen status="authenticating" appName={appName} />;
131
+ }
132
+
133
+ // Authenticated — render app
134
+ if (auth.status === 'authenticated') {
135
+ return <>{children}</>;
136
+ }
137
+
138
+ // Registration flow (including errors during registration)
139
+ if (registrationPhase) {
140
+ const isRegistering = auth.status === 'registering';
141
+ const regError = auth.status === 'error' ? auth.error : null;
142
+
143
+ if (renderRegistration) {
144
+ return (
145
+ <>
146
+ {renderRegistration({
147
+ onRegister: handleRegister,
148
+ registering: isRegistering,
149
+ error: regError,
150
+ client,
151
+ })}
152
+ </>
153
+ );
154
+ }
155
+ return (
156
+ <DefaultRegistrationScreen
157
+ onRegister={handleRegister}
158
+ registering={isRegistering}
159
+ error={regError}
160
+ client={client}
161
+ appName={appName}
162
+ />
163
+ );
164
+ }
165
+
166
+ // Error (non-registration)
167
+ if (auth.status === 'error' && auth.error) {
168
+ if (renderError) return <>{renderError(auth.error, retry)}</>;
169
+ return <DefaultErrorScreen error={auth.error} onRetry={retry} appName={appName} />;
170
+ }
171
+
172
+ // Loading states: idle, authenticating, signing, verifying
173
+ if (renderLoading) return <>{renderLoading(auth.status)}</>;
174
+ return <DefaultLoadingScreen status={auth.status} appName={appName} />;
175
+ }
176
+
177
+ // ── Default Loading Screen ──
178
+
179
+ function DefaultLoadingScreen({
180
+ status,
181
+ appName,
182
+ }: {
183
+ status: AuthStatus;
184
+ appName: string;
185
+ }) {
186
+ const t = useDubsTheme();
187
+
188
+ const statusText: Record<AuthStatus, string> = {
189
+ idle: 'Initializing...',
190
+ authenticating: 'Connecting...',
191
+ signing: 'Approve in your wallet...',
192
+ verifying: 'Verifying...',
193
+ registering: 'Creating account...',
194
+ needsRegistration: 'Almost there...',
195
+ authenticated: 'Ready!',
196
+ error: 'Something went wrong',
197
+ };
198
+
199
+ 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>
205
+ </View>
206
+ <Text style={[styles.appName, { color: t.text }]}>{appName}</Text>
207
+ </View>
208
+ <View style={styles.loadingSection}>
209
+ <ActivityIndicator size="large" color={t.accent} />
210
+ <Text style={[styles.statusText, { color: t.textMuted }]}>
211
+ {statusText[status] || 'Loading...'}
212
+ </Text>
213
+ </View>
214
+ </View>
215
+ </View>
216
+ );
217
+ }
218
+
219
+ // ── Default Error Screen ──
220
+
221
+ function DefaultErrorScreen({
222
+ error,
223
+ onRetry,
224
+ appName,
225
+ }: {
226
+ error: Error;
227
+ onRetry: () => void;
228
+ appName: string;
229
+ }) {
230
+ const t = useDubsTheme();
231
+
232
+ 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>
238
+ </View>
239
+ <Text style={[styles.appName, { color: t.text }]}>{appName}</Text>
240
+ </View>
241
+ <View style={styles.actionSection}>
242
+ <View
243
+ style={[
244
+ styles.errorBox,
245
+ { backgroundColor: t.errorBg, borderColor: t.errorBorder },
246
+ ]}
247
+ >
248
+ <Text style={[styles.errorText, { color: t.errorText }]}>
249
+ {error.message}
250
+ </Text>
251
+ </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>
261
+ </View>
262
+ );
263
+ }
264
+
265
+ // ── Default Registration Screen ──
266
+
267
+ function DefaultRegistrationScreen({
268
+ onRegister,
269
+ registering,
270
+ error,
271
+ client,
272
+ appName,
273
+ }: RegistrationScreenProps & { appName: string }) {
274
+ const t = useDubsTheme();
275
+ const [username, setUsername] = useState('');
276
+ const [referralCode, setReferralCode] = useState('');
277
+ const [checking, setChecking] = useState(false);
278
+ const [availability, setAvailability] = useState<{
279
+ available: boolean;
280
+ reason?: string;
281
+ } | null>(null);
282
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
283
+
284
+ // Debounced username availability check
285
+ useEffect(() => {
286
+ if (debounceRef.current) clearTimeout(debounceRef.current);
287
+
288
+ const trimmed = username.trim();
289
+ if (trimmed.length < 3) {
290
+ setAvailability(null);
291
+ setChecking(false);
292
+ return;
293
+ }
294
+
295
+ setChecking(true);
296
+ debounceRef.current = setTimeout(async () => {
297
+ try {
298
+ const result = await client.checkUsername(trimmed);
299
+ setAvailability(result);
300
+ } catch {
301
+ setAvailability(null);
302
+ } finally {
303
+ setChecking(false);
304
+ }
305
+ }, 500);
306
+
307
+ return () => {
308
+ if (debounceRef.current) clearTimeout(debounceRef.current);
309
+ };
310
+ }, [username, client]);
311
+
312
+ const canSubmit =
313
+ username.trim().length >= 3 &&
314
+ availability?.available === true &&
315
+ !registering &&
316
+ !checking;
317
+
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>
324
+ </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
+ </View>
330
+
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>
342
+ </View>
343
+ ) : null}
344
+
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}
385
+ </View>
386
+
387
+ <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)"
397
+ placeholderTextColor={t.textDim}
398
+ value={referralCode}
399
+ onChangeText={setReferralCode}
400
+ autoCapitalize="none"
401
+ autoCorrect={false}
402
+ editable={!registering}
403
+ />
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>
423
+ </View>
424
+ </View>
425
+ </View>
426
+ );
427
+ }
428
+
429
+ // ── Styles ──
430
+
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
+ },
520
+ });
package/src/ui/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export { AuthGate } from './AuthGate';
2
+ export type { AuthGateProps, RegistrationScreenProps } from './AuthGate';
1
3
  export { ConnectWalletScreen } from './ConnectWalletScreen';
2
4
  export type { ConnectWalletScreenProps } from './ConnectWalletScreen';
3
5
  export { UserProfileCard } from './UserProfileCard';