@dubsdotapp/expo 0.2.53 → 0.2.55

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.53",
3
+ "version": "0.2.55",
4
4
  "description": "React Native SDK for the Dubs betting platform",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -33,7 +33,8 @@
33
33
  "expo-device": ">=6.0.0",
34
34
  "expo-secure-store": ">=13.0.0",
35
35
  "react": ">=18.0.0",
36
- "react-native": ">=0.72.0"
36
+ "react-native": ">=0.72.0",
37
+ "expo-notifications": ">=0.28.0"
37
38
  },
38
39
  "peerDependenciesMeta": {
39
40
  "expo-secure-store": {
@@ -41,6 +42,9 @@
41
42
  },
42
43
  "expo-device": {
43
44
  "optional": true
45
+ },
46
+ "expo-notifications": {
47
+ "optional": true
44
48
  }
45
49
  },
46
50
  "optionalDependencies": {
package/src/client.ts CHANGED
@@ -410,6 +410,24 @@ export class DubsClient {
410
410
  this._token = null;
411
411
  }
412
412
 
413
+ // ── Push Notifications ──
414
+
415
+ async registerPushToken(params: { token: string; platform: string; deviceName?: string }): Promise<void> {
416
+ await this.request<{ success: true; data: { tokenId: number; token: string } }>(
417
+ 'POST',
418
+ '/push/expo-token',
419
+ params,
420
+ );
421
+ }
422
+
423
+ async unregisterPushToken(token: string): Promise<void> {
424
+ await this.request<{ success: true; data: { message: string } }>(
425
+ 'DELETE',
426
+ '/push/expo-token',
427
+ { token },
428
+ );
429
+ }
430
+
413
431
  // ── Error Utilities ──
414
432
 
415
433
  async parseError(error: unknown): Promise<ParsedError> {
@@ -16,3 +16,5 @@ export { useAuth } from './useAuth';
16
16
  export type { UseAuthResult } from './useAuth';
17
17
  export { useUFCFightCard } from './useUFCFightCard';
18
18
  export { useUFCFighterDetail } from './useUFCFighterDetail';
19
+ export { usePushNotifications } from './usePushNotifications';
20
+ export type { PushNotificationStatus } from './usePushNotifications';
@@ -0,0 +1,169 @@
1
+ import { useState, useCallback, useRef, useMemo } from 'react';
2
+ import { Platform } from 'react-native';
3
+ import { useDubs } from '../provider';
4
+
5
+ export interface PushNotificationStatus {
6
+ /** Whether notification permission has been granted */
7
+ hasPermission: boolean;
8
+ /** The Expo push token, if registered */
9
+ expoPushToken: string | null;
10
+ /** Whether a registration operation is in progress */
11
+ loading: boolean;
12
+ /** The last error encountered */
13
+ error: Error | null;
14
+ /**
15
+ * Request notification permission and register the push token.
16
+ * Does NOT auto-prompt — must be called explicitly (e.g. from an onboarding screen).
17
+ */
18
+ register: () => Promise<boolean>;
19
+ /** Unregister the current push token */
20
+ unregister: () => Promise<void>;
21
+ /**
22
+ * Silently re-register if permission was already granted (no prompt).
23
+ * Safe to call on app startup for returning users.
24
+ */
25
+ restoreIfGranted: () => Promise<void>;
26
+ }
27
+
28
+ export function usePushNotifications(): PushNotificationStatus {
29
+ const { client, appName } = useDubs();
30
+ const channelId = useMemo(() => appName.toLowerCase().replace(/[^a-z0-9-]/g, ''), [appName]);
31
+ const [hasPermission, setHasPermission] = useState(false);
32
+ const [expoPushToken, setExpoPushToken] = useState<string | null>(null);
33
+ const [loading, setLoading] = useState(false);
34
+ const [error, setError] = useState<Error | null>(null);
35
+ const registering = useRef(false);
36
+
37
+ const getNotificationsModule = useCallback(() => {
38
+ try {
39
+ return require('expo-notifications');
40
+ } catch {
41
+ return null;
42
+ }
43
+ }, []);
44
+
45
+ const getDeviceName = useCallback((): string | null => {
46
+ try {
47
+ const Device = require('expo-device');
48
+ return Device.deviceName || null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }, []);
53
+
54
+ const setupAndroidChannels = useCallback((Notifications: any) => {
55
+ if (Platform.OS === 'android') {
56
+ Notifications.setNotificationChannelAsync(channelId || 'default', {
57
+ name: appName || 'Default',
58
+ importance: Notifications.AndroidImportance?.MAX ?? 4,
59
+ vibrationPattern: [0, 250, 250, 250],
60
+ }).catch(() => {});
61
+ }
62
+ }, [channelId, appName]);
63
+
64
+ const registerTokenWithServer = useCallback(async (token: string) => {
65
+ const deviceName = getDeviceName();
66
+ await client.registerPushToken({
67
+ token,
68
+ platform: Platform.OS,
69
+ deviceName: deviceName || undefined,
70
+ });
71
+ }, [client, getDeviceName]);
72
+
73
+ const register = useCallback(async (): Promise<boolean> => {
74
+ if (registering.current) return false;
75
+ registering.current = true;
76
+ setLoading(true);
77
+ setError(null);
78
+
79
+ try {
80
+ const Notifications = getNotificationsModule();
81
+ if (!Notifications) {
82
+ throw new Error('expo-notifications is not installed');
83
+ }
84
+
85
+ // Request permission
86
+ const { status: existingStatus } = await Notifications.getPermissionsAsync();
87
+ let finalStatus = existingStatus;
88
+
89
+ if (existingStatus !== 'granted') {
90
+ const { status } = await Notifications.requestPermissionsAsync();
91
+ finalStatus = status;
92
+ }
93
+
94
+ if (finalStatus !== 'granted') {
95
+ setHasPermission(false);
96
+ setLoading(false);
97
+ registering.current = false;
98
+ return false;
99
+ }
100
+
101
+ setHasPermission(true);
102
+
103
+ // Get Expo push token
104
+ const tokenResult = await Notifications.getExpoPushTokenAsync();
105
+ const token = tokenResult.data;
106
+ setExpoPushToken(token);
107
+
108
+ // Register with server
109
+ await registerTokenWithServer(token);
110
+
111
+ // Setup Android channels
112
+ setupAndroidChannels(Notifications);
113
+
114
+ setLoading(false);
115
+ registering.current = false;
116
+ return true;
117
+ } catch (err) {
118
+ const e = err instanceof Error ? err : new Error(String(err));
119
+ setError(e);
120
+ setLoading(false);
121
+ registering.current = false;
122
+ console.error('[usePushNotifications] Registration error:', e.message);
123
+ return false;
124
+ }
125
+ }, [getNotificationsModule, registerTokenWithServer, setupAndroidChannels]);
126
+
127
+ const unregister = useCallback(async () => {
128
+ if (!expoPushToken) return;
129
+ try {
130
+ await client.unregisterPushToken(expoPushToken);
131
+ setExpoPushToken(null);
132
+ } catch (err) {
133
+ console.error('[usePushNotifications] Unregister error:', err);
134
+ }
135
+ }, [client, expoPushToken]);
136
+
137
+ const restoreIfGranted = useCallback(async () => {
138
+ try {
139
+ const Notifications = getNotificationsModule();
140
+ if (!Notifications) return;
141
+
142
+ const { status } = await Notifications.getPermissionsAsync();
143
+ if (status !== 'granted') return;
144
+
145
+ setHasPermission(true);
146
+
147
+ const tokenResult = await Notifications.getExpoPushTokenAsync();
148
+ const token = tokenResult.data;
149
+ setExpoPushToken(token);
150
+
151
+ // Re-register silently
152
+ await registerTokenWithServer(token);
153
+ setupAndroidChannels(Notifications);
154
+ } catch (err) {
155
+ // Silent failure — this is a background restore
156
+ console.log('[usePushNotifications] Restore skipped:', err instanceof Error ? err.message : err);
157
+ }
158
+ }, [getNotificationsModule, registerTokenWithServer, setupAndroidChannels]);
159
+
160
+ return {
161
+ hasPermission,
162
+ expoPushToken,
163
+ loading,
164
+ error,
165
+ register,
166
+ unregister,
167
+ restoreIfGranted,
168
+ };
169
+ }
package/src/index.ts CHANGED
@@ -94,6 +94,7 @@ export {
94
94
  useAuth,
95
95
  useUFCFightCard,
96
96
  useUFCFighterDetail,
97
+ usePushNotifications,
97
98
  } from './hooks';
98
99
  export type {
99
100
  CreateGameMutationResult,
@@ -102,6 +103,7 @@ export type {
102
103
  ClaimMutationResult,
103
104
  ClaimStatus,
104
105
  UseAuthResult,
106
+ PushNotificationStatus,
105
107
  } from './hooks';
106
108
 
107
109
  // UI
@@ -14,6 +14,7 @@ import {
14
14
  ScrollView,
15
15
  } from 'react-native';
16
16
  import { useAuth } from '../hooks';
17
+ import { usePushNotifications } from '../hooks/usePushNotifications';
17
18
  import { useDubs } from '../provider';
18
19
  import { AuthContext } from '../auth-context';
19
20
  import { useDubsTheme } from './theme';
@@ -76,6 +77,8 @@ export function AuthGate({
76
77
  const auth = useAuth();
77
78
  const [phase, setPhase] = useState<'init' | 'active'>('init');
78
79
  const [registrationPhase, setRegistrationPhase] = useState(false);
80
+ const [showPushSetup, setShowPushSetup] = useState(false);
81
+ const [isRestoredSession, setIsRestoredSession] = useState(false);
79
82
 
80
83
  useEffect(() => {
81
84
  let cancelled = false;
@@ -86,7 +89,7 @@ export function AuthGate({
86
89
  if (savedToken) {
87
90
  const restored = await auth.restoreSession(savedToken);
88
91
  if (cancelled) return;
89
- if (restored) { setPhase('active'); return; }
92
+ if (restored) { setIsRestoredSession(true); setPhase('active'); return; }
90
93
  await onSaveToken(null);
91
94
  }
92
95
  if (cancelled) return;
@@ -104,6 +107,13 @@ export function AuthGate({
104
107
  if (auth.status === 'needsRegistration') setRegistrationPhase(true);
105
108
  }, [auth.status]);
106
109
 
110
+ // Show push setup after new registration completes (not restored sessions)
111
+ useEffect(() => {
112
+ if (auth.status === 'authenticated' && registrationPhase && !isRestoredSession) {
113
+ setShowPushSetup(true);
114
+ }
115
+ }, [auth.status, registrationPhase, isRestoredSession]);
116
+
107
117
  useEffect(() => {
108
118
  if (auth.token) onSaveToken(auth.token);
109
119
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -130,7 +140,21 @@ export function AuthGate({
130
140
  }
131
141
 
132
142
  if (auth.status === 'authenticated') {
133
- return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
143
+ if (showPushSetup) {
144
+ return (
145
+ <PushSetupScreen
146
+ accentColor={accentColor}
147
+ appName={appName}
148
+ onComplete={() => setShowPushSetup(false)}
149
+ />
150
+ );
151
+ }
152
+ return (
153
+ <AuthContext.Provider value={auth}>
154
+ <PushTokenRestorer />
155
+ {children}
156
+ </AuthContext.Provider>
157
+ );
134
158
  }
135
159
 
136
160
  if (registrationPhase) {
@@ -561,6 +585,131 @@ function DefaultRegistrationScreen({
561
585
  );
562
586
  }
563
587
 
588
+ // ── Push Token Restorer (silent, zero UI) ──
589
+ // Runs inside DubsContext + AuthGate when user is authenticated.
590
+ // Silently re-registers the push token for returning users who already granted permission.
591
+ // Does NOT prompt — the prompt only happens in PushSetupScreen during onboarding.
592
+
593
+ function PushTokenRestorer() {
594
+ const push = usePushNotifications();
595
+ const restored = useRef(false);
596
+
597
+ useEffect(() => {
598
+ if (restored.current) return;
599
+ restored.current = true;
600
+ push.restoreIfGranted();
601
+ // eslint-disable-next-line react-hooks/exhaustive-deps
602
+ }, []);
603
+
604
+ return null;
605
+ }
606
+
607
+ // ── Push Setup Screen (Step 3 of onboarding) ──
608
+
609
+ function PushSetupScreen({
610
+ accentColor,
611
+ appName,
612
+ onComplete,
613
+ }: {
614
+ accentColor?: string;
615
+ appName: string;
616
+ onComplete: () => void;
617
+ }) {
618
+ const t = useDubsTheme();
619
+ const accent = accentColor || t.accent;
620
+ const push = usePushNotifications();
621
+ const fadeAnim = useRef(new Animated.Value(0)).current;
622
+ const slideAnim = useRef(new Animated.Value(30)).current;
623
+
624
+ useEffect(() => {
625
+ Animated.parallel([
626
+ Animated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: true }),
627
+ Animated.timing(slideAnim, { toValue: 0, duration: 300, useNativeDriver: true }),
628
+ ]).start();
629
+ }, [fadeAnim, slideAnim]);
630
+
631
+ const handleEnable = async () => {
632
+ await push.register();
633
+ onComplete();
634
+ };
635
+
636
+ const benefits = [
637
+ 'A fight you picked on goes LIVE',
638
+ 'Your pick wins or loses',
639
+ 'Final results and rankings',
640
+ ];
641
+
642
+ return (
643
+ <View style={[s.container, { backgroundColor: t.background }]}>
644
+ <Animated.View
645
+ style={[
646
+ s.stepContainer,
647
+ { opacity: fadeAnim, transform: [{ translateY: slideAnim }] },
648
+ ]}
649
+ >
650
+ <View style={s.stepTop}>
651
+ <Text style={[s.title, { color: t.text }]}>Enable Notifications</Text>
652
+ <Text style={[s.subtitle, { color: t.textMuted }]}>
653
+ Stay in the loop with real-time updates
654
+ </Text>
655
+ <StepIndicator currentStep={3} />
656
+
657
+ <View style={pushStyles.iconContainer}>
658
+ <View style={[pushStyles.bellCircle, { backgroundColor: accent + '20' }]}>
659
+ <Text style={[pushStyles.bellIcon, { color: accent }]}>{'\uD83D\uDD14'}</Text>
660
+ </View>
661
+ </View>
662
+
663
+ <View style={pushStyles.benefitsList}>
664
+ <Text style={[pushStyles.benefitsHeader, { color: t.text }]}>
665
+ Get real-time updates when:
666
+ </Text>
667
+ {benefits.map((item, i) => (
668
+ <View key={i} style={pushStyles.benefitRow}>
669
+ <View style={[pushStyles.bulletDot, { backgroundColor: accent }]} />
670
+ <Text style={[pushStyles.benefitText, { color: t.textMuted }]}>{item}</Text>
671
+ </View>
672
+ ))}
673
+ </View>
674
+ </View>
675
+
676
+ <View style={s.bottomRow}>
677
+ <TouchableOpacity
678
+ style={[s.secondaryBtn, { borderColor: t.border }]}
679
+ onPress={onComplete}
680
+ activeOpacity={0.7}
681
+ >
682
+ <Text style={[s.secondaryBtnText, { color: t.textMuted }]}>Maybe Later</Text>
683
+ </TouchableOpacity>
684
+ <TouchableOpacity
685
+ style={[s.primaryBtn, { backgroundColor: accent, flex: 1, opacity: push.loading ? 0.7 : 1 }]}
686
+ onPress={handleEnable}
687
+ disabled={push.loading}
688
+ activeOpacity={0.8}
689
+ >
690
+ {push.loading ? (
691
+ <ActivityIndicator color="#FFFFFF" size="small" />
692
+ ) : (
693
+ <Text style={s.primaryBtnText}>Enable Notifications</Text>
694
+ )}
695
+ </TouchableOpacity>
696
+ </View>
697
+ </Animated.View>
698
+ </View>
699
+ );
700
+ }
701
+
702
+ const pushStyles = StyleSheet.create({
703
+ iconContainer: { alignItems: 'center', marginVertical: 24 },
704
+ bellCircle: { width: 100, height: 100, borderRadius: 50, justifyContent: 'center', alignItems: 'center' },
705
+ bellIcon: { fontSize: 48 },
706
+ benefitsList: { paddingHorizontal: 24, gap: 12, marginTop: 8 },
707
+ benefitsHeader: { fontSize: 17, fontWeight: '700', marginBottom: 4 },
708
+ benefitRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
709
+ bulletDot: { width: 8, height: 8, borderRadius: 4 },
710
+ benefitText: { fontSize: 16, flex: 1 },
711
+ });
712
+
564
713
  // ── Styles ──
565
714
 
566
715
  const s = StyleSheet.create({