@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/dist/index.d.mts +31 -1
- package/dist/index.d.ts +31 -1
- package/dist/index.js +648 -399
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +322 -74
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -2
- package/src/client.ts +18 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/usePushNotifications.ts +169 -0
- package/src/index.ts +2 -0
- package/src/ui/AuthGate.tsx +151 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dubsdotapp/expo",
|
|
3
|
-
"version": "0.2.
|
|
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> {
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
package/src/ui/AuthGate.tsx
CHANGED
|
@@ -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
|
-
|
|
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({
|