@squad-sports/react-native 1.3.1

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.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/package.json +46 -0
  4. package/src/SquadExperience.tsx +200 -0
  5. package/src/SquadProvider.tsx +232 -0
  6. package/src/SquadSportsSDK.ts +286 -0
  7. package/src/__tests__/DeepLinkHandler.test.ts +101 -0
  8. package/src/__tests__/ErrorBoundary.test.tsx +161 -0
  9. package/src/__tests__/EventProcessor.test.ts +241 -0
  10. package/src/__tests__/PushNotificationHandler.test.ts +91 -0
  11. package/src/__tests__/SecureStorage.test.ts +62 -0
  12. package/src/__tests__/SquadSportsSDK.test.ts +278 -0
  13. package/src/__tests__/VerificationCooldown.test.ts +153 -0
  14. package/src/components/ErrorBoundary.tsx +129 -0
  15. package/src/components/SOTD/SOTDComponents.tsx +101 -0
  16. package/src/components/audio/AudioPlayerRow.tsx +189 -0
  17. package/src/components/audio/recording/AudioRecording.tsx +232 -0
  18. package/src/components/communities/CommunityComponents.tsx +78 -0
  19. package/src/components/dialogs/AllDialogs.tsx +123 -0
  20. package/src/components/dialogs/ConfirmDialog.tsx +77 -0
  21. package/src/components/dialogs/PermissionDialog.tsx +132 -0
  22. package/src/components/dragAndDrop/DragAndDrop.tsx +56 -0
  23. package/src/components/events/EventComponents.tsx +93 -0
  24. package/src/components/feed/ChatBannerCard.tsx +94 -0
  25. package/src/components/feed/FeedLoadingSkeleton.tsx +43 -0
  26. package/src/components/feed/FreestyleCard.tsx +119 -0
  27. package/src/components/feed/InterstitialOverlay.tsx +190 -0
  28. package/src/components/feed/PollCard.tsx +158 -0
  29. package/src/components/feed/SponsoredContentCard.tsx +118 -0
  30. package/src/components/freestyle/FreestyleComponents.tsx +148 -0
  31. package/src/components/index.ts +42 -0
  32. package/src/components/message/MessageCard.tsx +166 -0
  33. package/src/components/message/MessageComponents.tsx +143 -0
  34. package/src/components/poll/PollComponents.tsx +226 -0
  35. package/src/components/sentinels/Sentinels.tsx +175 -0
  36. package/src/components/squad/FeedSquad.tsx +54 -0
  37. package/src/components/toasts/Toasts.tsx +88 -0
  38. package/src/components/ux/RootUXComponents.tsx +157 -0
  39. package/src/components/ux/action-sheet/ActionSheet.tsx +67 -0
  40. package/src/components/ux/bottom-sheet/BottomSheetComponents.tsx +93 -0
  41. package/src/components/ux/bottom-sheet/SquadBottomSheet.tsx +119 -0
  42. package/src/components/ux/buttons/Button.tsx +37 -0
  43. package/src/components/ux/buttons/InfoButton.tsx +24 -0
  44. package/src/components/ux/buttons/XButton.tsx +27 -0
  45. package/src/components/ux/carousel/Carousel.tsx +134 -0
  46. package/src/components/ux/emoji-picker/EmojiReactionPicker.tsx +139 -0
  47. package/src/components/ux/errors/ErrorHint.tsx +46 -0
  48. package/src/components/ux/inputs/CodeInput.tsx +121 -0
  49. package/src/components/ux/inputs/DatePicker.tsx +76 -0
  50. package/src/components/ux/inputs/MaskedTextInput.tsx +95 -0
  51. package/src/components/ux/inputs/PhoneNumberInput.tsx +90 -0
  52. package/src/components/ux/inputs/SearchTextInput.tsx +70 -0
  53. package/src/components/ux/inputs/TextInput.tsx +58 -0
  54. package/src/components/ux/layout/AvoidKeyboardScreen.tsx +58 -0
  55. package/src/components/ux/layout/BlurOverlay.tsx +26 -0
  56. package/src/components/ux/layout/CrossFade.tsx +30 -0
  57. package/src/components/ux/layout/DismissKeyboardOnBlur.tsx +14 -0
  58. package/src/components/ux/layout/LoadingOverlay.tsx +60 -0
  59. package/src/components/ux/layout/NetworkBanner.tsx +64 -0
  60. package/src/components/ux/layout/PermissionsCTAContent.tsx +54 -0
  61. package/src/components/ux/layout/RefreshControl.tsx +7 -0
  62. package/src/components/ux/layout/Screen.tsx +31 -0
  63. package/src/components/ux/layout/ScreenHeader.tsx +89 -0
  64. package/src/components/ux/layout/TabBar.tsx +39 -0
  65. package/src/components/ux/layout/Toast.tsx +116 -0
  66. package/src/components/ux/layout/TransparentOverlay.tsx +21 -0
  67. package/src/components/ux/navigation/BackButton.tsx +29 -0
  68. package/src/components/ux/navigation/LinkButton.tsx +21 -0
  69. package/src/components/ux/navigation/UrlButton.tsx +25 -0
  70. package/src/components/ux/scroll/RefreshableFlatList.tsx +35 -0
  71. package/src/components/ux/shapes/Shapes.tsx +23 -0
  72. package/src/components/ux/text/Typography.tsx +28 -0
  73. package/src/components/ux/user-image/UserImage.tsx +75 -0
  74. package/src/components/ux/user-image/UserImageVariants.tsx +104 -0
  75. package/src/components/wallet/WalletComponents.tsx +116 -0
  76. package/src/contexts/AuthContext.tsx +45 -0
  77. package/src/contexts/EventProcessorContext.tsx +41 -0
  78. package/src/contexts/PlayerQueueContext.tsx +95 -0
  79. package/src/hooks/useAuth.ts +23 -0
  80. package/src/hooks/useDataRefresh.ts +30 -0
  81. package/src/hooks/useEventProcessor.ts +6 -0
  82. package/src/hooks/useImageOptimization.ts +59 -0
  83. package/src/hooks/useOnboardingStepGuard.ts +36 -0
  84. package/src/hooks/usePendingNavigation.ts +26 -0
  85. package/src/hooks/useSquadData.ts +84 -0
  86. package/src/hooks/useUserCreated.ts +25 -0
  87. package/src/hooks/useUserUpdate.ts +25 -0
  88. package/src/hooks/useViewabilityTracker.ts +40 -0
  89. package/src/index.ts +109 -0
  90. package/src/navigation/SquadNavigator.tsx +262 -0
  91. package/src/realtime/DeepLinkHandler.ts +113 -0
  92. package/src/realtime/EventProcessor.ts +313 -0
  93. package/src/realtime/NetworkMonitor.ts +84 -0
  94. package/src/realtime/OfflineQueue.ts +133 -0
  95. package/src/realtime/PushNotificationHandler.ts +125 -0
  96. package/src/realtime/useRealtimeSync.ts +84 -0
  97. package/src/screens/auth/EmailVerificationScreen.tsx +201 -0
  98. package/src/screens/auth/EnterCodeScreen.tsx +253 -0
  99. package/src/screens/auth/EnterEmailScreen.tsx +234 -0
  100. package/src/screens/auth/LandingScreen.tsx +90 -0
  101. package/src/screens/auth/LoginScreen.tsx +126 -0
  102. package/src/screens/events/EventScreen.tsx +163 -0
  103. package/src/screens/freestyle/CommunityFreestyleScreen.tsx +82 -0
  104. package/src/screens/freestyle/FreestyleCreationScreen.tsx +255 -0
  105. package/src/screens/freestyle/FreestyleListensScreen.tsx +44 -0
  106. package/src/screens/freestyle/FreestyleReactionsScreen.tsx +44 -0
  107. package/src/screens/freestyle/FreestyleScreen.tsx +64 -0
  108. package/src/screens/home/HomeScreen.tsx +365 -0
  109. package/src/screens/home/slivers/SquadCircle.tsx +77 -0
  110. package/src/screens/invite/InvitationQrCodeScreen.tsx +114 -0
  111. package/src/screens/invite/InviteScreen.tsx +175 -0
  112. package/src/screens/messaging/MessagingScreen.tsx +360 -0
  113. package/src/screens/onboarding/OnboardingAccountSetupScreen.tsx +221 -0
  114. package/src/screens/onboarding/OnboardingTeamSelectScreen.tsx +215 -0
  115. package/src/screens/onboarding/OnboardingWelcomeScreen.tsx +93 -0
  116. package/src/screens/polls/PollResponseScreen.tsx +229 -0
  117. package/src/screens/polls/PollSummationScreen.tsx +78 -0
  118. package/src/screens/profile/ProfileScreen.tsx +234 -0
  119. package/src/screens/settings/BlockedUsersScreen.tsx +139 -0
  120. package/src/screens/settings/DeleteAccountScreen.tsx +182 -0
  121. package/src/screens/settings/EditProfileScreen.tsx +154 -0
  122. package/src/screens/settings/NetworkStatusScreen.tsx +59 -0
  123. package/src/screens/settings/SettingsScreen.tsx +194 -0
  124. package/src/screens/squad-line/AddCallTitleScreen.tsx +293 -0
  125. package/src/screens/wallet/WalletScreen.tsx +174 -0
  126. package/src/services/AuthStateManager.ts +93 -0
  127. package/src/services/NavigationService.ts +40 -0
  128. package/src/services/UserDataManager.ts +59 -0
  129. package/src/services/UserUpdateService.ts +31 -0
  130. package/src/services/VerificationStateManager.ts +41 -0
  131. package/src/squad-line/CallScreen.tsx +158 -0
  132. package/src/squad-line/IncomingCallOverlay.tsx +113 -0
  133. package/src/squad-line/SquadLineClient.ts +327 -0
  134. package/src/squad-line/useSquadLine.ts +80 -0
  135. package/src/state/audio.ts +38 -0
  136. package/src/state/client.ts +26 -0
  137. package/src/state/communities.ts +45 -0
  138. package/src/state/contacts.ts +28 -0
  139. package/src/state/device-info.ts +22 -0
  140. package/src/state/events.ts +16 -0
  141. package/src/state/features.ts +57 -0
  142. package/src/state/index.ts +121 -0
  143. package/src/state/invitations.ts +16 -0
  144. package/src/state/modal-keys.ts +63 -0
  145. package/src/state/modal-queue.ts +104 -0
  146. package/src/state/navigation.ts +34 -0
  147. package/src/state/permissions.ts +43 -0
  148. package/src/state/session.ts +223 -0
  149. package/src/state/squaddie-of-the-day.ts +21 -0
  150. package/src/state/sync/crdt.ts +70 -0
  151. package/src/state/sync/dependable.ts +213 -0
  152. package/src/state/sync/feed-v2.ts +42 -0
  153. package/src/state/sync/messages.ts +44 -0
  154. package/src/state/sync/offline-support.ts +42 -0
  155. package/src/state/sync/polls.ts +37 -0
  156. package/src/state/sync/refresh.ts +24 -0
  157. package/src/state/sync/squad-v2.ts +25 -0
  158. package/src/state/ui.ts +36 -0
  159. package/src/state/user.ts +46 -0
  160. package/src/state/wallet.ts +26 -0
  161. package/src/storage/SecureStorage.ts +77 -0
  162. package/src/theme/ThemeContext.tsx +159 -0
  163. package/src/types/modules.d.ts +165 -0
@@ -0,0 +1,125 @@
1
+ import { EventProcessor } from './EventProcessor';
2
+
3
+ export type NotificationType =
4
+ | 'message'
5
+ | 'squad_invite'
6
+ | 'incoming_call'
7
+ | 'poll'
8
+ | 'freestyle'
9
+ | 'reaction';
10
+
11
+ export interface PushNotificationPayload {
12
+ type: NotificationType;
13
+ title?: string;
14
+ body?: string;
15
+ data?: Record<string, string>;
16
+ }
17
+
18
+ export interface NotificationRouteAction {
19
+ screen: string;
20
+ params?: Record<string, string>;
21
+ }
22
+
23
+ /**
24
+ * Handles incoming push notifications and routes them to the correct screen.
25
+ * Integrators should call `handleNotification()` from their notification handler.
26
+ *
27
+ * Usage:
28
+ * ```ts
29
+ * import { PushNotificationHandler } from '@squad-sports/react-native';
30
+ *
31
+ * // In your app's notification handler:
32
+ * Notifications.addNotificationResponseReceivedListener(response => {
33
+ * const data = response.notification.request.content.data;
34
+ * const action = PushNotificationHandler.handleNotification(data);
35
+ * if (action) {
36
+ * navigation.navigate(action.screen, action.params);
37
+ * }
38
+ * });
39
+ * ```
40
+ */
41
+ export class PushNotificationHandler {
42
+ /**
43
+ * Process a push notification payload and return the navigation action.
44
+ */
45
+ static handleNotification(
46
+ payload: PushNotificationPayload | Record<string, string>,
47
+ ): NotificationRouteAction | null {
48
+ const type = (payload as any).type as NotificationType;
49
+ const data = (payload as any).data ?? payload;
50
+
51
+ switch (type) {
52
+ case 'message':
53
+ return {
54
+ screen: 'Messaging',
55
+ params: { connectionId: data.connectionId ?? data.connection_id },
56
+ };
57
+
58
+ case 'squad_invite':
59
+ return {
60
+ screen: 'Home',
61
+ };
62
+
63
+ case 'incoming_call': {
64
+ // Emit call event to EventProcessor for the IncomingCallOverlay
65
+ EventProcessor.shared.emitter.emit('incoming_call', {
66
+ callerId: data.callerId ?? data.caller_id,
67
+ callerName: data.callerName ?? data.caller_name,
68
+ connectionId: data.connectionId ?? data.connection_id,
69
+ title: data.title,
70
+ });
71
+ return null; // IncomingCallOverlay handles this
72
+ }
73
+
74
+ case 'poll':
75
+ return {
76
+ screen: 'PollResponse',
77
+ params: { pollId: data.pollId ?? data.poll_id },
78
+ };
79
+
80
+ case 'freestyle':
81
+ return {
82
+ screen: 'Home',
83
+ };
84
+
85
+ case 'reaction':
86
+ if (data.connectionId || data.connection_id) {
87
+ return {
88
+ screen: 'Messaging',
89
+ params: { connectionId: data.connectionId ?? data.connection_id },
90
+ };
91
+ }
92
+ return { screen: 'Home' };
93
+
94
+ default:
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Register for push notifications.
101
+ * Call this after authentication.
102
+ */
103
+ static async registerForPushNotifications(): Promise<string | null> {
104
+ try {
105
+ const Notifications = await import('expo-notifications');
106
+
107
+ const { status: existingStatus } = await Notifications.getPermissionsAsync();
108
+ let finalStatus = existingStatus;
109
+
110
+ if (existingStatus !== 'granted') {
111
+ const { status } = await Notifications.requestPermissionsAsync();
112
+ finalStatus = status;
113
+ }
114
+
115
+ if (finalStatus !== 'granted') {
116
+ return null;
117
+ }
118
+
119
+ const tokenData = await Notifications.getExpoPushTokenAsync();
120
+ return tokenData.data;
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,84 @@
1
+ import { useEffect, useState, useCallback } from 'react';
2
+ import { EventProcessor, type ConnectionQuality } from './EventProcessor';
3
+ import { useApiClient } from '../SquadProvider';
4
+
5
+ /**
6
+ * Hook that manages the real-time SSE connection lifecycle.
7
+ * Wire this into your root provider to enable live updates.
8
+ */
9
+ export function useRealtimeSync() {
10
+ const apiClient = useApiClient();
11
+ const [quality, setQuality] = useState<ConnectionQuality>('disconnected');
12
+ const [isConnected, setIsConnected] = useState(false);
13
+
14
+ useEffect(() => {
15
+ const processor = EventProcessor.shared;
16
+ processor.setApiClient(apiClient);
17
+ processor.setShouldAllowEvents(true);
18
+
19
+ // Listen for quality changes
20
+ const onQuality = (q: ConnectionQuality) => {
21
+ setQuality(q);
22
+ setIsConnected(q === 'good');
23
+ };
24
+ processor.emitter.on('connection:quality', onQuality);
25
+
26
+ // Connect
27
+ processor.connect();
28
+
29
+ return () => {
30
+ processor.emitter.off('connection:quality', onQuality);
31
+ // Don't disconnect on unmount — EventProcessor is a singleton
32
+ // that persists across the app lifecycle
33
+ };
34
+ }, [apiClient]);
35
+
36
+ return { quality, isConnected };
37
+ }
38
+
39
+ /**
40
+ * Hook that subscribes to a specific real-time event type.
41
+ * Automatically cleans up the subscription on unmount.
42
+ *
43
+ * Usage:
44
+ * ```ts
45
+ * useRealtimeEvent('feed:update', (data) => {
46
+ * console.log('Feed updated:', data);
47
+ * refreshFeed();
48
+ * });
49
+ * ```
50
+ */
51
+ export function useRealtimeEvent<T = unknown>(
52
+ eventType: string,
53
+ handler: (data: T) => void,
54
+ ) {
55
+ useEffect(() => {
56
+ const processor = EventProcessor.shared;
57
+ processor.emitter.on(eventType, handler);
58
+
59
+ return () => {
60
+ processor.emitter.off(eventType, handler);
61
+ };
62
+ }, [eventType, handler]);
63
+ }
64
+
65
+ /**
66
+ * Hook that subscribes to connection-specific message events.
67
+ */
68
+ export function useMessageUpdates(
69
+ connectionId: string,
70
+ onNewMessage: (data: unknown) => void,
71
+ ) {
72
+ useRealtimeEvent(`connection:${connectionId}:message:create`, onNewMessage);
73
+ }
74
+
75
+ /**
76
+ * Hook that subscribes to squad membership changes.
77
+ */
78
+ export function useSquadUpdates(
79
+ onMemberAdded?: (data: unknown) => void,
80
+ onMemberRemoved?: (data: unknown) => void,
81
+ ) {
82
+ useRealtimeEvent('squad:member:add', onMemberAdded ?? (() => {}));
83
+ useRealtimeEvent('squad:member:remove', onMemberRemoved ?? (() => {}));
84
+ }
@@ -0,0 +1,201 @@
1
+ import React, { useCallback, useState, useEffect } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ StyleSheet,
6
+ ActivityIndicator,
7
+ Pressable,
8
+ } from 'react-native';
9
+ import { useNavigation } from '@react-navigation/native';
10
+
11
+ import { useSquadSDK } from '../../SquadProvider';
12
+ import { Colors } from '../../theme/ThemeContext';
13
+ import Button from '../../components/ux/buttons/Button';
14
+ import { TitleRegular, BodyRegular, BodyMedium } from '../../components/ux/text/Typography';
15
+
16
+ export function EmailVerificationScreen() {
17
+ const navigation = useNavigation();
18
+ const sdk = useSquadSDK();
19
+ const [status, setStatus] = useState<'waiting' | 'checking' | 'error'>('waiting');
20
+ const [error, setError] = useState<string | null>(null);
21
+
22
+ // Poll for email verification (magic link flow)
23
+ useEffect(() => {
24
+ let interval: ReturnType<typeof setInterval>;
25
+ let attempts = 0;
26
+ const maxAttempts = 60; // 5 minutes at 5s intervals
27
+
28
+ const checkVerification = async () => {
29
+ attempts++;
30
+ if (attempts > maxAttempts) {
31
+ clearInterval(interval);
32
+ setStatus('error');
33
+ setError('Verification timed out. Please try again.');
34
+ return;
35
+ }
36
+
37
+ try {
38
+ const isValid = await sdk.sessionManager.validateSession();
39
+ if (isValid) {
40
+ clearInterval(interval);
41
+ // Session validated — navigator will handle routing
42
+ }
43
+ } catch {
44
+ // Keep polling
45
+ }
46
+ };
47
+
48
+ interval = setInterval(checkVerification, 5000);
49
+ return () => clearInterval(interval);
50
+ }, [sdk]);
51
+
52
+ const resendEmail = useCallback(async () => {
53
+ setStatus('waiting');
54
+ setError(null);
55
+ // Re-trigger the session creation
56
+ // The SDK will handle sending another verification email
57
+ }, []);
58
+
59
+ return (
60
+ <View style={styles.container}>
61
+ <Pressable style={styles.backButton} onPress={() => navigation.goBack()}>
62
+ <Text style={styles.backButtonText}>{'<'}</Text>
63
+ </Pressable>
64
+
65
+ <View style={styles.content}>
66
+ <View style={styles.iconContainer}>
67
+ <Text style={styles.icon}>@</Text>
68
+ </View>
69
+
70
+ <TitleRegular style={styles.title}>Check Your Email</TitleRegular>
71
+
72
+ <BodyRegular style={styles.subtitle}>
73
+ We sent a verification link to your email address. Tap the link to continue.
74
+ </BodyRegular>
75
+
76
+ {status === 'waiting' && (
77
+ <View style={styles.waitingContainer}>
78
+ <ActivityIndicator color={Colors.purple1} size="small" />
79
+ <BodyMedium style={styles.waitingText}>
80
+ Waiting for verification...
81
+ </BodyMedium>
82
+ </View>
83
+ )}
84
+
85
+ {status === 'error' && (
86
+ <View style={styles.errorContainer}>
87
+ <Text style={styles.errorText}>{error}</Text>
88
+ </View>
89
+ )}
90
+ </View>
91
+
92
+ <View style={styles.footer}>
93
+ <Button style={styles.resendButton} onPress={resendEmail}>
94
+ <BodyRegular style={styles.resendText}>
95
+ Didn't receive the email?{' '}
96
+ <Text style={styles.resendAction}>Resend</Text>
97
+ </BodyRegular>
98
+ </Button>
99
+
100
+ <Button
101
+ style={styles.codeButton}
102
+ onPress={() => navigation.goBack()}
103
+ >
104
+ <BodyRegular style={styles.codeButtonText}>
105
+ Use verification code instead
106
+ </BodyRegular>
107
+ </Button>
108
+ </View>
109
+ </View>
110
+ );
111
+ }
112
+
113
+ const styles = StyleSheet.create({
114
+ container: {
115
+ flex: 1,
116
+ backgroundColor: Colors.black,
117
+ paddingHorizontal: 24,
118
+ },
119
+ backButton: {
120
+ padding: 10,
121
+ marginTop: 60,
122
+ alignSelf: 'flex-start',
123
+ },
124
+ backButtonText: {
125
+ fontSize: 24,
126
+ color: Colors.white,
127
+ fontWeight: '600',
128
+ },
129
+ content: {
130
+ flex: 1,
131
+ justifyContent: 'center',
132
+ alignItems: 'center',
133
+ },
134
+ iconContainer: {
135
+ width: 80,
136
+ height: 80,
137
+ borderRadius: 40,
138
+ backgroundColor: Colors.purple1,
139
+ justifyContent: 'center',
140
+ alignItems: 'center',
141
+ marginBottom: 24,
142
+ },
143
+ icon: {
144
+ fontSize: 36,
145
+ color: Colors.white,
146
+ fontWeight: '700',
147
+ },
148
+ title: {
149
+ color: Colors.white,
150
+ textAlign: 'center',
151
+ marginBottom: 12,
152
+ },
153
+ subtitle: {
154
+ color: Colors.gray6,
155
+ textAlign: 'center',
156
+ maxWidth: 300,
157
+ marginBottom: 32,
158
+ },
159
+ waitingContainer: {
160
+ flexDirection: 'row',
161
+ alignItems: 'center',
162
+ gap: 8,
163
+ },
164
+ waitingText: {
165
+ color: Colors.gray6,
166
+ },
167
+ errorContainer: {
168
+ paddingVertical: 12,
169
+ paddingHorizontal: 16,
170
+ backgroundColor: 'rgba(233, 120, 92, 0.14)',
171
+ borderRadius: 8,
172
+ },
173
+ errorText: {
174
+ color: Colors.orange1,
175
+ fontSize: 14,
176
+ },
177
+ footer: {
178
+ paddingBottom: 32,
179
+ gap: 16,
180
+ },
181
+ resendButton: {
182
+ alignItems: 'center',
183
+ },
184
+ resendText: {
185
+ color: Colors.gray6,
186
+ },
187
+ resendAction: {
188
+ color: Colors.white,
189
+ fontWeight: '500',
190
+ },
191
+ codeButton: {
192
+ alignItems: 'center',
193
+ paddingVertical: 16,
194
+ borderWidth: 1,
195
+ borderColor: Colors.gray5,
196
+ borderRadius: 8,
197
+ },
198
+ codeButtonText: {
199
+ color: Colors.white,
200
+ },
201
+ });
@@ -0,0 +1,253 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ StyleSheet,
6
+ Pressable,
7
+ useWindowDimensions,
8
+ } from 'react-native';
9
+ import { useNavigation, useRoute } from '@react-navigation/native';
10
+ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
11
+ import type { RouteProp } from '@react-navigation/native';
12
+
13
+ import type { RootStackParamList } from '../../navigation/SquadNavigator';
14
+ import { useSquadSDK } from '../../SquadProvider';
15
+ import { Colors } from '../../theme/ThemeContext';
16
+ import { ErrorHint } from '../../components/ux/errors/ErrorHint';
17
+ import Button from '../../components/ux/buttons/Button';
18
+ import AvoidKeyboardScreen from '../../components/ux/layout/AvoidKeyboardScreen';
19
+ import { CodeInput } from '../../components/ux/inputs/CodeInput';
20
+ import { TitleRegular, BodyRegular } from '../../components/ux/text/Typography';
21
+
22
+ type Nav = NativeStackNavigationProp<RootStackParamList, 'EnterCode'>;
23
+ type Route = RouteProp<RootStackParamList, 'EnterCode'>;
24
+
25
+ export function EnterCodeScreen() {
26
+ const navigation = useNavigation<Nav>();
27
+ const route = useRoute<Route>();
28
+ const sdk = useSquadSDK();
29
+ const { height } = useWindowDimensions();
30
+ const isShort = height < 700;
31
+
32
+ const phone = route.params?.phone;
33
+ const email = route.params?.email;
34
+
35
+ const [code, setCode] = useState('');
36
+ const [error, setError] = useState<string | null>(null);
37
+ const [isLoading, setIsLoading] = useState(false);
38
+ const [resendCooldown, setResendCooldown] = useState(0);
39
+ const cooldownRef = useRef<ReturnType<typeof setInterval> | null>(null);
40
+
41
+ const COOLDOWN_SECONDS = 60;
42
+
43
+ useEffect(() => {
44
+ return () => {
45
+ if (cooldownRef.current) clearInterval(cooldownRef.current);
46
+ };
47
+ }, []);
48
+
49
+ const startCooldown = useCallback(() => {
50
+ setResendCooldown(COOLDOWN_SECONDS);
51
+ if (cooldownRef.current) clearInterval(cooldownRef.current);
52
+ cooldownRef.current = setInterval(() => {
53
+ setResendCooldown((prev: number) => {
54
+ if (prev <= 1) {
55
+ if (cooldownRef.current) clearInterval(cooldownRef.current);
56
+ cooldownRef.current = null;
57
+ return 0;
58
+ }
59
+ return prev - 1;
60
+ });
61
+ }, 1000);
62
+ }, []);
63
+
64
+ const buttonDisabled = code.length !== 6 || isLoading;
65
+
66
+ const verifyCode = useCallback(async (verificationCode?: string) => {
67
+ const codeToVerify = verificationCode ?? code;
68
+ if (codeToVerify.length !== 6) return;
69
+
70
+ setIsLoading(true);
71
+ setError(null);
72
+
73
+ try {
74
+ const result = await sdk.sessionManager.fulfillSession({
75
+ phone,
76
+ email,
77
+ code: codeToVerify,
78
+ });
79
+
80
+ if (result.status === 'active') {
81
+ // Session is active — navigator will automatically route to main/onboarding
82
+ // based on user state. No manual navigation needed.
83
+ } else {
84
+ setError(result.error ?? 'Invalid verification code. Please try again.');
85
+ }
86
+ } catch (err) {
87
+ const message = err instanceof Error && err.message.includes('timed out')
88
+ ? 'Request timed out. Please check your connection and try again.'
89
+ : 'Invalid verification code. Please try again.';
90
+ setError(message);
91
+ } finally {
92
+ setIsLoading(false);
93
+ }
94
+ }, [code, phone, email, sdk]);
95
+
96
+ const resendCode = useCallback(async () => {
97
+ if (resendCooldown > 0) return;
98
+
99
+ setIsLoading(true);
100
+ setError(null);
101
+ setCode('');
102
+
103
+ try {
104
+ const result = await sdk.sessionManager.createSession({ phone, email });
105
+ if (result.status !== 'pending') {
106
+ setError('Failed to resend code. Please try again.');
107
+ }
108
+ startCooldown();
109
+ } catch {
110
+ setError('Failed to resend code. Please try again.');
111
+ } finally {
112
+ setIsLoading(false);
113
+ }
114
+ }, [phone, email, sdk, resendCooldown, startCooldown]);
115
+
116
+ return (
117
+ <AvoidKeyboardScreen>
118
+ {/* Progress bar */}
119
+ <View style={styles.progressBarContainer}>
120
+ <View style={styles.progressBar} />
121
+ </View>
122
+
123
+ <View style={[styles.headerContainer, isShort && styles.headerContainerShort]}>
124
+ <Pressable style={styles.backButton} onPress={() => navigation.goBack()}>
125
+ <Text style={styles.backButtonText}>{'<'}</Text>
126
+ </Pressable>
127
+ </View>
128
+
129
+ <View style={[styles.container, isShort && styles.containerShort]}>
130
+ <View style={styles.contentContainer}>
131
+ <TitleRegular style={styles.title}>Enter Verification Code</TitleRegular>
132
+
133
+ <View style={styles.codeContainer}>
134
+ <CodeInput
135
+ value={code}
136
+ onChangeText={setCode}
137
+ onComplete={verifyCode}
138
+ length={6}
139
+ />
140
+
141
+ <Button style={styles.resendContainer} onPress={resendCode} disabled={resendCooldown > 0}>
142
+ <BodyRegular style={styles.resendBody}>
143
+ Didn't receive a code?
144
+ </BodyRegular>
145
+ <BodyRegular style={[styles.resendAction, resendCooldown > 0 && styles.resendDisabled]}>
146
+ {resendCooldown > 0 ? ` Resend (${resendCooldown}s)` : ' Resend'}
147
+ </BodyRegular>
148
+ </Button>
149
+
150
+ <ErrorHint hint={error} />
151
+ </View>
152
+ </View>
153
+
154
+ <Button
155
+ style={[styles.button, buttonDisabled && styles.buttonDisabled]}
156
+ onPress={() => verifyCode()}
157
+ disabled={buttonDisabled}
158
+ >
159
+ <Text style={[styles.buttonText, buttonDisabled && styles.buttonDisabledText]}>
160
+ {isLoading ? 'Verifying...' : 'Verify'}
161
+ </Text>
162
+ </Button>
163
+ </View>
164
+ </AvoidKeyboardScreen>
165
+ );
166
+ }
167
+
168
+ const styles = StyleSheet.create({
169
+ progressBarContainer: {
170
+ position: 'absolute',
171
+ top: 0,
172
+ left: 0,
173
+ right: 0,
174
+ zIndex: 10,
175
+ },
176
+ progressBar: {
177
+ backgroundColor: Colors.white,
178
+ height: 4,
179
+ width: '16%',
180
+ },
181
+ headerContainer: {
182
+ flexDirection: 'row',
183
+ alignItems: 'center',
184
+ paddingHorizontal: 20,
185
+ paddingTop: 20,
186
+ marginBottom: 46,
187
+ marginTop: 24,
188
+ },
189
+ headerContainerShort: {
190
+ marginBottom: 40,
191
+ marginTop: 24,
192
+ },
193
+ backButton: {
194
+ padding: 10,
195
+ },
196
+ backButtonText: {
197
+ fontSize: 24,
198
+ color: Colors.white,
199
+ fontWeight: '600',
200
+ },
201
+ container: {
202
+ flex: 1,
203
+ paddingBottom: 16,
204
+ paddingHorizontal: 24,
205
+ },
206
+ containerShort: {
207
+ paddingBottom: 8,
208
+ },
209
+ contentContainer: {
210
+ flex: 1,
211
+ },
212
+ title: {
213
+ color: Colors.white,
214
+ marginBottom: 24,
215
+ },
216
+ codeContainer: {
217
+ width: '100%',
218
+ maxWidth: 400,
219
+ alignSelf: 'center',
220
+ },
221
+ resendContainer: {
222
+ alignItems: 'center',
223
+ flexDirection: 'row',
224
+ marginTop: 24,
225
+ },
226
+ resendBody: {
227
+ color: Colors.gray6,
228
+ },
229
+ resendAction: {
230
+ color: Colors.white,
231
+ fontWeight: '500',
232
+ },
233
+ resendDisabled: {
234
+ color: Colors.gray6,
235
+ },
236
+ button: {
237
+ backgroundColor: Colors.white,
238
+ borderRadius: 8,
239
+ padding: 16,
240
+ alignItems: 'center',
241
+ },
242
+ buttonDisabled: {
243
+ backgroundColor: Colors.gray2,
244
+ },
245
+ buttonText: {
246
+ fontSize: 16,
247
+ color: Colors.gray1,
248
+ fontWeight: '600',
249
+ },
250
+ buttonDisabledText: {
251
+ color: Colors.gray6,
252
+ },
253
+ });