@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.
- package/dist/index.d.mts +3 -12
- package/dist/index.d.ts +3 -12
- package/dist/index.js +368 -249
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +377 -253
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/hooks/useAuth.ts +3 -2
- package/src/types.ts +1 -0
- package/src/ui/AuthGate.tsx +406 -310
package/src/ui/AuthGate.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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={[
|
|
201
|
-
<View style={
|
|
202
|
-
<View style={
|
|
203
|
-
<View style={[
|
|
204
|
-
<Text style={
|
|
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={[
|
|
177
|
+
<Text style={[s.appNameText, { color: t.text }]}>{appName}</Text>
|
|
207
178
|
</View>
|
|
208
|
-
<View style={
|
|
179
|
+
<View style={s.loadingSection}>
|
|
209
180
|
<ActivityIndicator size="large" color={t.accent} />
|
|
210
|
-
<Text style={[
|
|
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={[
|
|
234
|
-
<View style={
|
|
235
|
-
<View style={
|
|
236
|
-
<View style={[
|
|
237
|
-
<Text style={
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
249
|
-
{
|
|
250
|
-
|
|
236
|
+
{i < currentStep ? (
|
|
237
|
+
<Text style={s.stepCheck}>✓</Text>
|
|
238
|
+
) : (
|
|
239
|
+
<Text style={[s.stepNum, { color: i === currentStep ? '#FFF' : t.textMuted }]}>{i + 1}</Text>
|
|
240
|
+
)}
|
|
251
241
|
</View>
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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}>✓</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={
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 }]}>↻ 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 }]}>☺ 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 ›</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 }]}>‹</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}>✓</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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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 }]}>‹ 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 ›</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 }]}>‹</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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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 }]}>‹ 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
|
|
432
|
-
container: {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
},
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
},
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
},
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
},
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
},
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
},
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
},
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
},
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
},
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
});
|