@dubsdotapp/expo 0.5.18 → 0.5.20

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.5.18",
3
+ "version": "0.5.20",
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/types.ts CHANGED
@@ -650,4 +650,13 @@ export interface UiConfig {
650
650
  appUrl?: string;
651
651
  tagline?: string;
652
652
  environment?: 'sandbox' | 'production';
653
+ /**
654
+ * Whether the developer has uploaded push credentials in the dev portal.
655
+ * Consumers should gate their push opt-in UI on the relevant platform flag —
656
+ * e.g. only show the "Enable Notifications" screen if pushConfigured.android is true.
657
+ */
658
+ pushConfigured?: {
659
+ android: boolean;
660
+ ios: boolean;
661
+ };
653
662
  }
@@ -71,7 +71,7 @@ export function AuthGate({
71
71
  appName = 'Dubs',
72
72
  accentColor,
73
73
  }: AuthGateProps) {
74
- const { client, pushEnabled } = useDubs();
74
+ const { client, pushEnabled, uiConfig } = useDubs();
75
75
  const auth = useAuth();
76
76
  const [phase, setPhase] = useState<'init' | 'active'>('init');
77
77
  const [registrationPhase, setRegistrationPhase] = useState(false);
@@ -105,12 +105,20 @@ export function AuthGate({
105
105
  if (auth.status === 'needsRegistration') setRegistrationPhase(true);
106
106
  }, [auth.status]);
107
107
 
108
- // Show push setup after new registration completes (not restored sessions)
108
+ // Show push setup after new registration completes (not restored sessions).
109
+ // Gated on pushConfigured.android — if the developer hasn't uploaded FCM credentials
110
+ // for this app in the dev portal, push can't be delivered, so don't prompt.
109
111
  useEffect(() => {
110
- if (pushEnabled && auth.status === 'authenticated' && registrationPhase && !isRestoredSession) {
112
+ if (
113
+ pushEnabled &&
114
+ uiConfig.pushConfigured?.android &&
115
+ auth.status === 'authenticated' &&
116
+ registrationPhase &&
117
+ !isRestoredSession
118
+ ) {
111
119
  setShowPushSetup(true);
112
120
  }
113
- }, [pushEnabled, auth.status, registrationPhase, isRestoredSession]);
121
+ }, [pushEnabled, uiConfig.pushConfigured?.android, auth.status, registrationPhase, isRestoredSession]);
114
122
 
115
123
  useEffect(() => {
116
124
  if (auth.token) onSaveToken(auth.token);
@@ -137,16 +145,15 @@ export function AuthGate({
137
145
  return <DefaultLoadingScreen status="authenticating" appName={appName} accentColor={accentColor} />;
138
146
  }
139
147
 
140
- if (auth.status === 'authenticated') {
141
- if (showPushSetup) {
142
- return (
143
- <PushSetupScreen
144
- accentColor={accentColor}
145
- appName={appName}
146
- onComplete={() => setShowPushSetup(false)}
147
- />
148
- );
149
- }
148
+ // Keep the registration screen mounted through the push step so the transition
149
+ // from step 3 (referral) → step 4 (push) animates as a continuation of the same flow.
150
+ const inRegistrationFlow =
151
+ registrationPhase &&
152
+ (auth.status === 'needsRegistration' ||
153
+ auth.status === 'registering' ||
154
+ (auth.status === 'authenticated' && showPushSetup));
155
+
156
+ if (auth.status === 'authenticated' && !inRegistrationFlow) {
150
157
  return (
151
158
  <AuthContext.Provider value={auth}>
152
159
  {pushEnabled && <PushTokenRestorer />}
@@ -155,7 +162,7 @@ export function AuthGate({
155
162
  );
156
163
  }
157
164
 
158
- if (registrationPhase) {
165
+ if (inRegistrationFlow) {
159
166
  const isRegistering = auth.status === 'registering';
160
167
  const regError = auth.status === 'error' ? auth.error : null;
161
168
  if (renderRegistration) {
@@ -169,6 +176,8 @@ export function AuthGate({
169
176
  client={client}
170
177
  appName={appName}
171
178
  accentColor={accentColor}
179
+ pushStepActive={showPushSetup}
180
+ onPushComplete={() => setShowPushSetup(false)}
172
181
  />
173
182
  );
174
183
  }
@@ -296,9 +305,17 @@ function DefaultRegistrationScreen({
296
305
  client,
297
306
  appName,
298
307
  accentColor,
299
- }: RegistrationScreenProps & { appName: string; accentColor?: string }) {
308
+ pushStepActive,
309
+ onPushComplete,
310
+ }: RegistrationScreenProps & {
311
+ appName: string;
312
+ accentColor?: string;
313
+ pushStepActive?: boolean;
314
+ onPushComplete?: () => void;
315
+ }) {
300
316
  const t = useDubsTheme();
301
317
  const accent = accentColor || t.accent;
318
+ const push = usePushNotifications();
302
319
 
303
320
  // ── Shared state ──
304
321
  const [step, setStep] = useState(0);
@@ -563,11 +580,84 @@ function DefaultRegistrationScreen({
563
580
  </View>
564
581
  );
565
582
 
583
+ // Advance to the push step when the parent signals (after auth completes).
584
+ // Uses the same animateToStep transition as steps 0→1→2 so it feels like
585
+ // a continuation, not a separate modal swap.
586
+ useEffect(() => {
587
+ if (pushStepActive && step !== 3) {
588
+ animateToStep(3);
589
+ }
590
+ // eslint-disable-next-line react-hooks/exhaustive-deps
591
+ }, [pushStepActive]);
592
+
593
+ // ── Step 4: Push Notifications ──
594
+ const handleEnablePush = async () => {
595
+ try { await push.register(); } catch {}
596
+ onPushComplete?.();
597
+ };
598
+
599
+ const renderPushStep = () => (
600
+ <View style={s.stepContainer}>
601
+ <View style={s.stepTop}>
602
+ <Text style={[s.title, { color: t.text }]}>Enable Notifications</Text>
603
+ <Text style={[s.subtitle, { color: t.textMuted }]}>
604
+ Stay in the loop with real-time updates
605
+ </Text>
606
+ <StepIndicator currentStep={3} />
607
+
608
+ <View style={pushStyles.iconContainer}>
609
+ <View style={[pushStyles.bellCircle, { backgroundColor: accent + '20' }]}>
610
+ <Text style={[pushStyles.bellIcon, { color: accent }]}>{'🔔'}</Text>
611
+ </View>
612
+ </View>
613
+
614
+ <View style={pushStyles.benefitsList}>
615
+ <Text style={[pushStyles.benefitsHeader, { color: t.text }]}>
616
+ Get real-time updates when:
617
+ </Text>
618
+ {[
619
+ 'A fight you picked on goes LIVE',
620
+ 'Your pick wins or loses',
621
+ 'Final results and rankings',
622
+ ].map((item, i) => (
623
+ <View key={i} style={pushStyles.benefitRow}>
624
+ <View style={[pushStyles.bulletDot, { backgroundColor: accent }]} />
625
+ <Text style={[pushStyles.benefitText, { color: t.textMuted }]}>{item}</Text>
626
+ </View>
627
+ ))}
628
+ </View>
629
+ </View>
630
+
631
+ <View style={s.bottomRow}>
632
+ <TouchableOpacity
633
+ style={[s.secondaryBtn, { borderColor: t.border }]}
634
+ onPress={onPushComplete}
635
+ activeOpacity={0.7}
636
+ >
637
+ <Text style={[s.secondaryBtnText, { color: t.textMuted }]}>Maybe Later</Text>
638
+ </TouchableOpacity>
639
+ <TouchableOpacity
640
+ style={[s.primaryBtn, { backgroundColor: accent, flex: 1, opacity: push.loading ? 0.7 : 1 }]}
641
+ onPress={handleEnablePush}
642
+ disabled={push.loading}
643
+ activeOpacity={0.8}
644
+ >
645
+ {push.loading ? (
646
+ <ActivityIndicator color="#FFFFFF" size="small" />
647
+ ) : (
648
+ <Text style={s.primaryBtnText}>Enable Notifications</Text>
649
+ )}
650
+ </TouchableOpacity>
651
+ </View>
652
+ </View>
653
+ );
654
+
566
655
  const renderStep = () => {
567
656
  switch (step) {
568
657
  case 0: return renderAvatarStep();
569
658
  case 1: return renderUsernameStep();
570
659
  case 2: return renderReferralStep();
660
+ case 3: return renderPushStep();
571
661
  default: return null;
572
662
  }
573
663
  };
@@ -598,7 +688,7 @@ function DefaultRegistrationScreen({
598
688
  // ── Push Token Restorer (silent, zero UI) ──
599
689
  // Runs inside DubsContext + AuthGate when user is authenticated.
600
690
  // Silently re-registers the push token for returning users who already granted permission.
601
- // Does NOT prompt — the prompt only happens in PushSetupScreen during onboarding.
691
+ // Does NOT prompt — the prompt only happens in step 4 of DefaultRegistrationScreen during onboarding.
602
692
 
603
693
  function PushTokenRestorer() {
604
694
  const push = usePushNotifications();
@@ -614,101 +704,6 @@ function PushTokenRestorer() {
614
704
  return null;
615
705
  }
616
706
 
617
- // ── Push Setup Screen (Step 3 of onboarding) ──
618
-
619
- function PushSetupScreen({
620
- accentColor,
621
- appName,
622
- onComplete,
623
- }: {
624
- accentColor?: string;
625
- appName: string;
626
- onComplete: () => void;
627
- }) {
628
- const t = useDubsTheme();
629
- const accent = accentColor || t.accent;
630
- const push = usePushNotifications();
631
- const fadeAnim = useRef(new Animated.Value(0)).current;
632
- const slideAnim = useRef(new Animated.Value(30)).current;
633
-
634
- useEffect(() => {
635
- Animated.parallel([
636
- Animated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: true }),
637
- Animated.timing(slideAnim, { toValue: 0, duration: 300, useNativeDriver: true }),
638
- ]).start();
639
- }, [fadeAnim, slideAnim]);
640
-
641
- const handleEnable = async () => {
642
- await push.register();
643
- onComplete();
644
- };
645
-
646
- const benefits = [
647
- 'A fight you picked on goes LIVE',
648
- 'Your pick wins or loses',
649
- 'Final results and rankings',
650
- ];
651
-
652
- return (
653
- <View style={[s.container, { backgroundColor: t.background }]}>
654
- <Animated.View
655
- style={[
656
- s.stepContainer,
657
- { opacity: fadeAnim, transform: [{ translateY: slideAnim }] },
658
- ]}
659
- >
660
- <View style={s.stepTop}>
661
- <Text style={[s.title, { color: t.text }]}>Enable Notifications</Text>
662
- <Text style={[s.subtitle, { color: t.textMuted }]}>
663
- Stay in the loop with real-time updates
664
- </Text>
665
- <StepIndicator currentStep={3} />
666
-
667
- <View style={pushStyles.iconContainer}>
668
- <View style={[pushStyles.bellCircle, { backgroundColor: accent + '20' }]}>
669
- <Text style={[pushStyles.bellIcon, { color: accent }]}>{'\uD83D\uDD14'}</Text>
670
- </View>
671
- </View>
672
-
673
- <View style={pushStyles.benefitsList}>
674
- <Text style={[pushStyles.benefitsHeader, { color: t.text }]}>
675
- Get real-time updates when:
676
- </Text>
677
- {benefits.map((item, i) => (
678
- <View key={i} style={pushStyles.benefitRow}>
679
- <View style={[pushStyles.bulletDot, { backgroundColor: accent }]} />
680
- <Text style={[pushStyles.benefitText, { color: t.textMuted }]}>{item}</Text>
681
- </View>
682
- ))}
683
- </View>
684
- </View>
685
-
686
- <View style={s.bottomRow}>
687
- <TouchableOpacity
688
- style={[s.secondaryBtn, { borderColor: t.border }]}
689
- onPress={onComplete}
690
- activeOpacity={0.7}
691
- >
692
- <Text style={[s.secondaryBtnText, { color: t.textMuted }]}>Maybe Later</Text>
693
- </TouchableOpacity>
694
- <TouchableOpacity
695
- style={[s.primaryBtn, { backgroundColor: accent, flex: 1, opacity: push.loading ? 0.7 : 1 }]}
696
- onPress={handleEnable}
697
- disabled={push.loading}
698
- activeOpacity={0.8}
699
- >
700
- {push.loading ? (
701
- <ActivityIndicator color="#FFFFFF" size="small" />
702
- ) : (
703
- <Text style={s.primaryBtnText}>Enable Notifications</Text>
704
- )}
705
- </TouchableOpacity>
706
- </View>
707
- </Animated.View>
708
- </View>
709
- );
710
- }
711
-
712
707
  const pushStyles = StyleSheet.create({
713
708
  iconContainer: { alignItems: 'center', marginVertical: 24 },
714
709
  bellCircle: { width: 100, height: 100, borderRadius: 50, justifyContent: 'center', alignItems: 'center' },