@dubsdotapp/expo 0.2.55 → 0.2.56

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dubsdotapp/expo",
3
- "version": "0.2.55",
3
+ "version": "0.2.56",
4
4
  "description": "React Native SDK for the Dubs betting platform",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
package/src/client.ts CHANGED
@@ -428,6 +428,17 @@ export class DubsClient {
428
428
  );
429
429
  }
430
430
 
431
+ // ── Profile ──
432
+
433
+ async updateProfile(params: { avatar?: string }): Promise<DubsUser> {
434
+ const res = await this.request<{ success: true; data: { user: DubsUser } }>(
435
+ 'PATCH',
436
+ '/auth/profile',
437
+ params,
438
+ );
439
+ return res.data.user;
440
+ }
441
+
431
442
  // ── Error Utilities ──
432
443
 
433
444
  async parseError(error: unknown): Promise<ParsedError> {
package/src/index.ts CHANGED
@@ -107,8 +107,8 @@ export type {
107
107
  } from './hooks';
108
108
 
109
109
  // UI
110
- export { AuthGate, ConnectWalletScreen, UserProfileCard, SettingsSheet, useDubsTheme, mergeTheme } from './ui';
111
- export type { AuthGateProps, RegistrationScreenProps, ConnectWalletScreenProps, UserProfileCardProps, SettingsSheetProps, DubsTheme } from './ui';
110
+ export { AuthGate, ConnectWalletScreen, UserProfileCard, SettingsSheet, UserProfileSheet, useDubsTheme, mergeTheme } from './ui';
111
+ export type { AuthGateProps, RegistrationScreenProps, ConnectWalletScreenProps, UserProfileCardProps, SettingsSheetProps, UserProfileSheetProps, DubsTheme } from './ui';
112
112
 
113
113
  // Game widgets
114
114
  export { GamePoster, LivePoolsCard, PickWinnerCard, PlayersCard, JoinGameButton, CreateCustomGameSheet, JoinGameSheet, ClaimPrizeSheet, ClaimButton } from './ui';
@@ -0,0 +1,478 @@
1
+ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ ActivityIndicator,
7
+ Modal,
8
+ Animated,
9
+ StyleSheet,
10
+ KeyboardAvoidingView,
11
+ Platform,
12
+ Image,
13
+ ScrollView,
14
+ } from 'react-native';
15
+ import { useDubsTheme } from './theme';
16
+ import { useDubs } from '../provider';
17
+ import { usePushNotifications } from '../hooks/usePushNotifications';
18
+
19
+ // ── Avatar Helpers ──
20
+
21
+ const DICEBEAR_STYLES = [
22
+ 'adventurer',
23
+ 'avataaars',
24
+ 'fun-emoji',
25
+ 'bottts',
26
+ 'big-smile',
27
+ 'thumbs',
28
+ ];
29
+
30
+ function generateSeed(): string {
31
+ return Math.random().toString(36).slice(2, 10);
32
+ }
33
+
34
+ function getAvatarUrl(style: string, seed: string, size = 256): string {
35
+ return `https://api.dicebear.com/9.x/${style}/png?seed=${seed}&size=${size}`;
36
+ }
37
+
38
+ /** Parse style and seed from a DiceBear URL, or return defaults. */
39
+ function parseAvatarUrl(url?: string | null): { style: string; seed: string } {
40
+ if (!url) return { style: 'adventurer', seed: generateSeed() };
41
+ try {
42
+ // URL format: https://api.dicebear.com/9.x/{style}/png?seed={seed}&size=...
43
+ const match = url.match(/\/9\.x\/([^/]+)\/png\?seed=([^&]+)/);
44
+ if (match) return { style: match[1], seed: match[2] };
45
+ } catch {}
46
+ return { style: 'adventurer', seed: generateSeed() };
47
+ }
48
+
49
+ function truncateAddress(address: string, chars = 4): string {
50
+ if (address.length <= chars * 2 + 3) return address;
51
+ return `${address.slice(0, chars)}...${address.slice(-chars)}`;
52
+ }
53
+
54
+ // ── Props ──
55
+
56
+ export interface UserProfileSheetProps {
57
+ visible: boolean;
58
+ onDismiss: () => void;
59
+ user: {
60
+ walletAddress: string;
61
+ username?: string;
62
+ avatar?: string | null;
63
+ createdAt?: string;
64
+ };
65
+ onAvatarUpdated?: (newAvatarUrl: string) => void;
66
+ onDisconnect?: () => void;
67
+ }
68
+
69
+ export function UserProfileSheet({
70
+ visible,
71
+ onDismiss,
72
+ user,
73
+ onAvatarUpdated,
74
+ onDisconnect,
75
+ }: UserProfileSheetProps) {
76
+ const t = useDubsTheme();
77
+ const { client } = useDubs();
78
+ const push = usePushNotifications();
79
+
80
+ const overlayOpacity = useRef(new Animated.Value(0)).current;
81
+
82
+ const parsed = useMemo(() => parseAvatarUrl(user.avatar), [user.avatar]);
83
+ const [avatarStyle, setAvatarStyle] = useState(parsed.style);
84
+ const [avatarSeed, setAvatarSeed] = useState(parsed.seed);
85
+ const [saving, setSaving] = useState(false);
86
+ const [error, setError] = useState<string | null>(null);
87
+
88
+ // Re-sync when user prop changes
89
+ useEffect(() => {
90
+ const p = parseAvatarUrl(user.avatar);
91
+ setAvatarStyle(p.style);
92
+ setAvatarSeed(p.seed);
93
+ }, [user.avatar]);
94
+
95
+ // Animate overlay
96
+ useEffect(() => {
97
+ Animated.timing(overlayOpacity, {
98
+ toValue: visible ? 1 : 0,
99
+ duration: 250,
100
+ useNativeDriver: true,
101
+ }).start();
102
+ }, [visible, overlayOpacity]);
103
+
104
+ // Reset error when sheet opens
105
+ useEffect(() => {
106
+ if (visible) setError(null);
107
+ }, [visible]);
108
+
109
+ const currentAvatarUrl = getAvatarUrl(avatarStyle, avatarSeed);
110
+
111
+ const handleSelectStyle = useCallback(
112
+ async (style: string) => {
113
+ const newUrl = getAvatarUrl(style, avatarSeed);
114
+ setAvatarStyle(style);
115
+ setSaving(true);
116
+ setError(null);
117
+ try {
118
+ await client.updateProfile({ avatar: newUrl });
119
+ onAvatarUpdated?.(newUrl);
120
+ } catch (err) {
121
+ setError(err instanceof Error ? err.message : 'Failed to update avatar');
122
+ } finally {
123
+ setSaving(false);
124
+ }
125
+ },
126
+ [avatarSeed, client, onAvatarUpdated],
127
+ );
128
+
129
+ const handleShuffle = useCallback(async () => {
130
+ const newSeed = generateSeed();
131
+ const newUrl = getAvatarUrl(avatarStyle, newSeed);
132
+ setAvatarSeed(newSeed);
133
+ setSaving(true);
134
+ setError(null);
135
+ try {
136
+ await client.updateProfile({ avatar: newUrl });
137
+ onAvatarUpdated?.(newUrl);
138
+ } catch (err) {
139
+ setError(err instanceof Error ? err.message : 'Failed to update avatar');
140
+ } finally {
141
+ setSaving(false);
142
+ }
143
+ }, [avatarStyle, client, onAvatarUpdated]);
144
+
145
+ return (
146
+ <Modal
147
+ visible={visible}
148
+ animationType="slide"
149
+ transparent
150
+ onRequestClose={onDismiss}
151
+ >
152
+ <Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
153
+ <TouchableOpacity style={styles.overlayTap} activeOpacity={1} onPress={onDismiss} />
154
+ </Animated.View>
155
+
156
+ <KeyboardAvoidingView
157
+ style={styles.keyboardView}
158
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
159
+ >
160
+ <View style={styles.sheetPositioner}>
161
+ <View style={[styles.sheet, { backgroundColor: t.background }]}>
162
+ {/* Drag handle */}
163
+ <View style={styles.handleRow}>
164
+ <View style={[styles.handle, { backgroundColor: t.textMuted }]} />
165
+ </View>
166
+
167
+ {/* Header */}
168
+ <View style={styles.header}>
169
+ <Text style={[styles.headerTitle, { color: t.text }]}>Profile</Text>
170
+ <TouchableOpacity onPress={onDismiss} activeOpacity={0.8}>
171
+ <Text style={[styles.closeButton, { color: t.textMuted }]}>{'\u2715'}</Text>
172
+ </TouchableOpacity>
173
+ </View>
174
+
175
+ <ScrollView
176
+ style={styles.scrollContent}
177
+ contentContainerStyle={styles.scrollContentInner}
178
+ showsVerticalScrollIndicator={false}
179
+ bounces={false}
180
+ >
181
+ {/* Avatar section */}
182
+ <View style={styles.avatarSection}>
183
+ <View style={[styles.avatarContainer, { borderColor: t.border }]}>
184
+ <Image
185
+ source={{ uri: currentAvatarUrl }}
186
+ style={styles.avatar}
187
+ />
188
+ {saving && (
189
+ <View style={styles.avatarLoading}>
190
+ <ActivityIndicator size="small" color="#FFFFFF" />
191
+ </View>
192
+ )}
193
+ </View>
194
+ {user.username ? (
195
+ <Text style={[styles.username, { color: t.text }]}>{user.username}</Text>
196
+ ) : null}
197
+ <Text style={[styles.walletAddress, { color: t.textMuted }]}>
198
+ {truncateAddress(user.walletAddress, 6)}
199
+ </Text>
200
+ </View>
201
+
202
+ {/* Change Avatar */}
203
+ <View style={styles.section}>
204
+ <View style={styles.sectionHeaderRow}>
205
+ <Text style={[styles.sectionLabel, { color: t.textSecondary }]}>
206
+ Change Avatar
207
+ </Text>
208
+ <TouchableOpacity
209
+ style={[styles.shuffleButton, { borderColor: t.border }]}
210
+ onPress={handleShuffle}
211
+ activeOpacity={0.7}
212
+ disabled={saving}
213
+ >
214
+ <Text style={[styles.shuffleText, { color: t.accent }]}>Shuffle</Text>
215
+ </TouchableOpacity>
216
+ </View>
217
+ <ScrollView
218
+ horizontal
219
+ showsHorizontalScrollIndicator={false}
220
+ contentContainerStyle={styles.stylePickerContent}
221
+ >
222
+ {DICEBEAR_STYLES.map((style) => {
223
+ const isSelected = style === avatarStyle;
224
+ return (
225
+ <TouchableOpacity
226
+ key={style}
227
+ onPress={() => handleSelectStyle(style)}
228
+ activeOpacity={0.7}
229
+ disabled={saving}
230
+ style={[
231
+ styles.styleTile,
232
+ {
233
+ borderColor: isSelected ? t.accent : t.border,
234
+ borderWidth: isSelected ? 2 : 1,
235
+ },
236
+ ]}
237
+ >
238
+ <Image
239
+ source={{ uri: getAvatarUrl(style, avatarSeed, 80) }}
240
+ style={styles.styleTileImage}
241
+ />
242
+ </TouchableOpacity>
243
+ );
244
+ })}
245
+ </ScrollView>
246
+ </View>
247
+
248
+ {/* Error */}
249
+ {error ? (
250
+ <View style={[styles.errorBox, { backgroundColor: t.errorBg, borderColor: t.errorBorder }]}>
251
+ <Text style={[styles.errorText, { color: t.errorText }]}>{error}</Text>
252
+ </View>
253
+ ) : null}
254
+
255
+ {/* Push Notifications */}
256
+ <View style={[styles.notifRow, { backgroundColor: t.surface, borderColor: t.border }]}>
257
+ <View style={styles.notifLeft}>
258
+ <Text style={[styles.notifLabel, { color: t.text }]}>Push Notifications</Text>
259
+ <Text
260
+ style={[
261
+ styles.notifStatus,
262
+ { color: push.hasPermission ? t.success : t.textMuted },
263
+ ]}
264
+ >
265
+ {push.hasPermission ? 'Enabled' : 'Disabled'}
266
+ </Text>
267
+ </View>
268
+ {!push.hasPermission && (
269
+ <TouchableOpacity
270
+ style={[styles.enableButton, { backgroundColor: t.accent }]}
271
+ onPress={push.register}
272
+ disabled={push.loading}
273
+ activeOpacity={0.8}
274
+ >
275
+ {push.loading ? (
276
+ <ActivityIndicator size="small" color="#FFFFFF" />
277
+ ) : (
278
+ <Text style={styles.enableText}>Enable</Text>
279
+ )}
280
+ </TouchableOpacity>
281
+ )}
282
+ </View>
283
+
284
+ {/* Disconnect */}
285
+ {onDisconnect ? (
286
+ <TouchableOpacity
287
+ style={[styles.disconnectButton, { borderColor: t.live }]}
288
+ onPress={onDisconnect}
289
+ activeOpacity={0.7}
290
+ >
291
+ <Text style={[styles.disconnectText, { color: t.live }]}>Disconnect Wallet</Text>
292
+ </TouchableOpacity>
293
+ ) : null}
294
+ </ScrollView>
295
+ </View>
296
+ </View>
297
+ </KeyboardAvoidingView>
298
+ </Modal>
299
+ );
300
+ }
301
+
302
+ const styles = StyleSheet.create({
303
+ overlay: {
304
+ ...StyleSheet.absoluteFillObject,
305
+ backgroundColor: 'rgba(0,0,0,0.5)',
306
+ },
307
+ overlayTap: {
308
+ flex: 1,
309
+ },
310
+ keyboardView: {
311
+ flex: 1,
312
+ justifyContent: 'flex-end',
313
+ },
314
+ sheetPositioner: {
315
+ justifyContent: 'flex-end',
316
+ },
317
+ sheet: {
318
+ borderTopLeftRadius: 24,
319
+ borderTopRightRadius: 24,
320
+ paddingHorizontal: 20,
321
+ paddingBottom: 40,
322
+ maxHeight: '85%',
323
+ },
324
+ handleRow: {
325
+ alignItems: 'center',
326
+ paddingTop: 10,
327
+ paddingBottom: 8,
328
+ },
329
+ handle: {
330
+ width: 36,
331
+ height: 4,
332
+ borderRadius: 2,
333
+ opacity: 0.4,
334
+ },
335
+ header: {
336
+ flexDirection: 'row',
337
+ alignItems: 'center',
338
+ justifyContent: 'space-between',
339
+ paddingVertical: 12,
340
+ },
341
+ headerTitle: {
342
+ fontSize: 20,
343
+ fontWeight: '700',
344
+ },
345
+ closeButton: {
346
+ fontSize: 20,
347
+ padding: 4,
348
+ },
349
+ scrollContent: {
350
+ flexGrow: 0,
351
+ },
352
+ scrollContentInner: {
353
+ paddingBottom: 8,
354
+ },
355
+ // Avatar
356
+ avatarSection: {
357
+ alignItems: 'center',
358
+ paddingTop: 8,
359
+ paddingBottom: 20,
360
+ gap: 8,
361
+ },
362
+ avatarContainer: {
363
+ width: 120,
364
+ height: 120,
365
+ borderRadius: 60,
366
+ borderWidth: 2,
367
+ overflow: 'hidden',
368
+ },
369
+ avatar: {
370
+ width: '100%',
371
+ height: '100%',
372
+ },
373
+ avatarLoading: {
374
+ ...StyleSheet.absoluteFillObject,
375
+ backgroundColor: 'rgba(0,0,0,0.35)',
376
+ justifyContent: 'center',
377
+ alignItems: 'center',
378
+ },
379
+ username: {
380
+ fontSize: 18,
381
+ fontWeight: '700',
382
+ },
383
+ walletAddress: {
384
+ fontSize: 13,
385
+ fontFamily: 'monospace',
386
+ },
387
+ // Change Avatar
388
+ section: {
389
+ marginBottom: 20,
390
+ gap: 12,
391
+ },
392
+ sectionHeaderRow: {
393
+ flexDirection: 'row',
394
+ alignItems: 'center',
395
+ justifyContent: 'space-between',
396
+ },
397
+ sectionLabel: {
398
+ fontSize: 14,
399
+ fontWeight: '600',
400
+ },
401
+ shuffleButton: {
402
+ paddingHorizontal: 14,
403
+ paddingVertical: 6,
404
+ borderRadius: 12,
405
+ borderWidth: 1,
406
+ },
407
+ shuffleText: {
408
+ fontSize: 13,
409
+ fontWeight: '600',
410
+ },
411
+ stylePickerContent: {
412
+ gap: 10,
413
+ },
414
+ styleTile: {
415
+ width: 72,
416
+ height: 72,
417
+ borderRadius: 16,
418
+ overflow: 'hidden',
419
+ },
420
+ styleTileImage: {
421
+ width: '100%',
422
+ height: '100%',
423
+ },
424
+ // Error
425
+ errorBox: {
426
+ marginBottom: 16,
427
+ borderRadius: 12,
428
+ borderWidth: 1,
429
+ padding: 12,
430
+ },
431
+ errorText: {
432
+ fontSize: 13,
433
+ fontWeight: '500',
434
+ },
435
+ // Push Notifications
436
+ notifRow: {
437
+ flexDirection: 'row',
438
+ alignItems: 'center',
439
+ justifyContent: 'space-between',
440
+ borderRadius: 16,
441
+ borderWidth: 1,
442
+ paddingHorizontal: 16,
443
+ paddingVertical: 14,
444
+ marginBottom: 16,
445
+ },
446
+ notifLeft: {
447
+ gap: 2,
448
+ },
449
+ notifLabel: {
450
+ fontSize: 15,
451
+ fontWeight: '600',
452
+ },
453
+ notifStatus: {
454
+ fontSize: 13,
455
+ },
456
+ enableButton: {
457
+ paddingHorizontal: 18,
458
+ paddingVertical: 10,
459
+ borderRadius: 12,
460
+ },
461
+ enableText: {
462
+ color: '#FFFFFF',
463
+ fontSize: 14,
464
+ fontWeight: '700',
465
+ },
466
+ // Disconnect
467
+ disconnectButton: {
468
+ height: 52,
469
+ borderRadius: 16,
470
+ borderWidth: 1.5,
471
+ justifyContent: 'center',
472
+ alignItems: 'center',
473
+ },
474
+ disconnectText: {
475
+ fontSize: 16,
476
+ fontWeight: '700',
477
+ },
478
+ });
package/src/ui/index.ts CHANGED
@@ -6,6 +6,8 @@ export { UserProfileCard } from './UserProfileCard';
6
6
  export type { UserProfileCardProps } from './UserProfileCard';
7
7
  export { SettingsSheet } from './SettingsSheet';
8
8
  export type { SettingsSheetProps } from './SettingsSheet';
9
+ export { UserProfileSheet } from './UserProfileSheet';
10
+ export type { UserProfileSheetProps } from './UserProfileSheet';
9
11
  export { useDubsTheme, mergeTheme } from './theme';
10
12
  export type { DubsTheme } from './theme';
11
13