@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,286 @@
1
+ import {
2
+ SquadApiClient,
3
+ SessionManager,
4
+ SSOManager,
5
+ TokenStorage,
6
+ InMemoryStorage,
7
+ NexusPartnerRegistry,
8
+ PartnerAuthManager,
9
+ AnalyticsTracker,
10
+ Logger,
11
+ getApiBaseUrl,
12
+ isPartnerConfig,
13
+ } from '@squad-sports/core';
14
+ import type {
15
+ SquadSDKConfig,
16
+ SquadPartnerConfig,
17
+ SquadConfig,
18
+ StorageAdapter,
19
+ } from '@squad-sports/core';
20
+ import { SecureStorageAdapter } from './storage/SecureStorage';
21
+
22
+ /**
23
+ * Main entry point for the Squad Sports SDK.
24
+ *
25
+ * Two ways to initialize:
26
+ *
27
+ * **Simple (recommended):** Just provide your partner ID.
28
+ * Everything auto-resolves from the Nexus partner registry.
29
+ * ```ts
30
+ * await SquadSportsSDK.setup({ partnerId: 'acme-sports' });
31
+ * ```
32
+ *
33
+ * **With SSO:** Pass a Ticketmaster (or other) token and users are auto-authenticated.
34
+ * ```ts
35
+ * await SquadSportsSDK.setup({
36
+ * partnerId: 'acme-sports',
37
+ * ssoToken: ticketmasterAccessToken,
38
+ * ssoProvider: 'ticketmaster',
39
+ * });
40
+ * ```
41
+ *
42
+ * **Full control:** Provide every config value manually.
43
+ * ```ts
44
+ * SquadSportsSDK.initialize({ apiKey: '...', environment: 'production', community: { ... } });
45
+ * ```
46
+ */
47
+ export class SquadSportsSDK {
48
+ private static instance: SquadSportsSDK | null = null;
49
+
50
+ readonly config: SquadSDKConfig;
51
+ readonly apiClient: SquadApiClient;
52
+ readonly sessionManager: SessionManager;
53
+ readonly ssoManager: SSOManager;
54
+ readonly partnerAuthManager: PartnerAuthManager;
55
+ readonly tokenStorage: TokenStorage;
56
+
57
+ private constructor(config: SquadSDKConfig) {
58
+ this.config = config;
59
+
60
+ const storage: StorageAdapter = config.storage ?? new SecureStorageAdapter();
61
+ this.tokenStorage = new TokenStorage(storage);
62
+
63
+ const baseUrl = getApiBaseUrl(config.environment, config.apiBaseUrl);
64
+
65
+ this.apiClient = new SquadApiClient({
66
+ baseUrl,
67
+ apiKey: config.apiKey,
68
+ });
69
+
70
+ this.sessionManager = new SessionManager(this.apiClient, this.tokenStorage);
71
+ this.ssoManager = new SSOManager(this.apiClient, this.tokenStorage);
72
+ this.partnerAuthManager = new PartnerAuthManager(this.apiClient, this.tokenStorage);
73
+ }
74
+
75
+ // --- Initialization paths ---
76
+
77
+ /**
78
+ * RECOMMENDED: One-step async setup.
79
+ * Accepts either a simple partnerId config or a full config.
80
+ * Resolves partner config from Nexus, restores session, and handles SSO.
81
+ */
82
+ static async setup(config: SquadConfig): Promise<SquadSportsSDK> {
83
+ let resolvedConfig: SquadSDKConfig;
84
+
85
+ if (isPartnerConfig(config)) {
86
+ // Simple path — resolve everything from partner ID
87
+ resolvedConfig = await NexusPartnerRegistry.resolve(config);
88
+ } else {
89
+ resolvedConfig = config;
90
+ }
91
+
92
+ SquadSportsSDK.reset();
93
+ const sdk = new SquadSportsSDK(resolvedConfig);
94
+ SquadSportsSDK.instance = sdk;
95
+
96
+ // Configure logger
97
+ Logger.shared.configure({
98
+ partnerId: resolvedConfig.partnerAuth?.partnerId,
99
+ environment: resolvedConfig.environment,
100
+ });
101
+
102
+ // Configure analytics
103
+ AnalyticsTracker.shared.configure({
104
+ apiClient: sdk.apiClient,
105
+ partnerId: resolvedConfig.partnerAuth?.partnerId,
106
+ enabled: true,
107
+ });
108
+
109
+ // Auto-restore previous session
110
+ const restored = await sdk.restoreSession();
111
+
112
+ if (restored) {
113
+ AnalyticsTracker.shared.track('session_restored');
114
+ const userId = await sdk.tokenStorage.getUserId();
115
+ if (userId) {
116
+ AnalyticsTracker.shared.setUserId(userId);
117
+ Logger.shared.setUserId(userId);
118
+ }
119
+ }
120
+
121
+ // If restored, check community scoping — re-auth if community changed
122
+ if (restored && resolvedConfig.partnerAuth?.communityId) {
123
+ const storedCommunityId = await sdk.tokenStorage.getCommunityId();
124
+ if (storedCommunityId && storedCommunityId !== resolvedConfig.partnerAuth.communityId) {
125
+ await sdk.tokenStorage.clearAll();
126
+ sdk.apiClient.clearAuthState();
127
+ }
128
+ }
129
+
130
+ // Auto-authenticate via SSO if configured
131
+ if (resolvedConfig.sso?.accessToken && !sdk.apiClient.currentToken) {
132
+ await sdk.ssoManager.authenticate(resolvedConfig.sso);
133
+ AnalyticsTracker.shared.track('sso_login', { provider: resolvedConfig.sso.provider });
134
+ // Persist community/partner context after SSO
135
+ if (resolvedConfig.partnerAuth && sdk.apiClient.currentToken) {
136
+ await sdk.tokenStorage.setCommunityId(resolvedConfig.partnerAuth.communityId);
137
+ await sdk.tokenStorage.setPartnerId(resolvedConfig.partnerAuth.partnerId);
138
+ }
139
+ }
140
+
141
+ // Partner user sync — if userData provided and still no valid token
142
+ if (resolvedConfig.partnerAuth?.userData && !sdk.apiClient.currentToken) {
143
+ const syncResult = await sdk.partnerAuthManager.syncUser(
144
+ resolvedConfig.partnerAuth.partnerId,
145
+ resolvedConfig.partnerAuth.communityId,
146
+ resolvedConfig.partnerAuth.userData,
147
+ );
148
+ if (syncResult.success) {
149
+ AnalyticsTracker.shared.track('partner_sync_success', { partnerId: resolvedConfig.partnerAuth.partnerId });
150
+ if (syncResult.userId) {
151
+ AnalyticsTracker.shared.setUserId(syncResult.userId);
152
+ Logger.shared.setUserId(syncResult.userId);
153
+ }
154
+ } else {
155
+ AnalyticsTracker.shared.track('partner_sync_failed', { partnerId: resolvedConfig.partnerAuth.partnerId });
156
+ Logger.shared.warn('Partner user sync failed. User will be directed to auth screen.');
157
+ }
158
+ }
159
+
160
+ // Wire silent re-auth on 401 (interceptor-level, can retry original request)
161
+ // Supports both partner sync and SSO token refresh
162
+ const ssoConfig = resolvedConfig.sso;
163
+ const partnerAuth = resolvedConfig.partnerAuth;
164
+ const hasPartnerUserData = !!partnerAuth?.userData;
165
+ const hasSSOToken = !!ssoConfig?.accessToken;
166
+
167
+ if (hasPartnerUserData || hasSSOToken) {
168
+ sdk.apiClient.setSilentReAuth(async () => {
169
+ // Try partner re-auth first (most reliable — server-side user sync)
170
+ if (hasPartnerUserData && partnerAuth?.userData) {
171
+ Logger.shared.info('Attempting silent partner re-auth');
172
+ const reSync = await sdk.partnerAuthManager.syncUser(
173
+ partnerAuth.partnerId,
174
+ partnerAuth.communityId,
175
+ partnerAuth.userData,
176
+ );
177
+ if (reSync.success) {
178
+ Logger.shared.info('Silent partner re-auth succeeded');
179
+ return true;
180
+ }
181
+ }
182
+
183
+ // Try SSO token re-exchange (works if the external SSO token is still valid)
184
+ if (hasSSOToken && ssoConfig) {
185
+ Logger.shared.info('Attempting silent SSO re-auth');
186
+ try {
187
+ const result = await sdk.ssoManager.authenticate(ssoConfig);
188
+ if (result?.success) {
189
+ Logger.shared.info('Silent SSO re-auth succeeded');
190
+ // Persist community/partner context after SSO refresh
191
+ if (partnerAuth && sdk.apiClient.currentToken) {
192
+ await sdk.tokenStorage.setCommunityId(partnerAuth.communityId);
193
+ await sdk.tokenStorage.setPartnerId(partnerAuth.partnerId);
194
+ }
195
+ return true;
196
+ }
197
+ } catch {
198
+ Logger.shared.warn('Silent SSO re-auth failed');
199
+ }
200
+ }
201
+
202
+ Logger.shared.warn('All silent re-auth attempts failed');
203
+ return false;
204
+ });
205
+ }
206
+
207
+ AnalyticsTracker.shared.track('sdk_initialized', {
208
+ partnerId: resolvedConfig.partnerAuth?.partnerId,
209
+ environment: resolvedConfig.environment,
210
+ });
211
+
212
+ return sdk;
213
+ }
214
+
215
+ /**
216
+ * Synchronous initialization with full config (no auto-resolution).
217
+ * Use this when you already have all config values.
218
+ */
219
+ static initialize(config: SquadSDKConfig): SquadSportsSDK {
220
+ if (SquadSportsSDK.instance) {
221
+ console.warn('[SquadSportsSDK] Already initialized. Call reset() first to reinitialize.');
222
+ return SquadSportsSDK.instance;
223
+ }
224
+
225
+ SquadSportsSDK.instance = new SquadSportsSDK(config);
226
+ return SquadSportsSDK.instance;
227
+ }
228
+
229
+ /**
230
+ * Get the current SDK instance. Throws if not initialized.
231
+ */
232
+ static get shared(): SquadSportsSDK {
233
+ if (!SquadSportsSDK.instance) {
234
+ throw new Error(
235
+ '[SquadSportsSDK] Not initialized. Call SquadSportsSDK.setup() or SquadSportsSDK.initialize() first.',
236
+ );
237
+ }
238
+ return SquadSportsSDK.instance;
239
+ }
240
+
241
+ static get isInitialized(): boolean {
242
+ return SquadSportsSDK.instance !== null;
243
+ }
244
+
245
+ static reset(): void {
246
+ if (SquadSportsSDK.instance) {
247
+ SquadSportsSDK.instance.apiClient.cancelAllRequests();
248
+ SquadSportsSDK.instance.apiClient.clearAllCaches();
249
+ AnalyticsTracker.shared.destroy();
250
+ }
251
+ SquadSportsSDK.instance = null;
252
+ }
253
+
254
+ // --- Session ---
255
+
256
+ /**
257
+ * Restore a previous session from stored tokens.
258
+ */
259
+ async restoreSession(): Promise<boolean> {
260
+ const token = await this.tokenStorage.getAccessToken();
261
+ if (!token) return false;
262
+
263
+ this.apiClient.updateAccessToken(token);
264
+ const isValid = await this.sessionManager.validateSession();
265
+
266
+ if (!isValid) {
267
+ await this.tokenStorage.clearAll();
268
+ this.apiClient.clearAuthState();
269
+ return false;
270
+ }
271
+
272
+ return true;
273
+ }
274
+
275
+ /**
276
+ * Authenticate via SSO with an external token.
277
+ * Convenience method for post-initialization SSO.
278
+ */
279
+ async authenticateWithSSO(
280
+ provider: 'ticketmaster' | 'oauth2' | 'custom',
281
+ token: string,
282
+ ): Promise<boolean> {
283
+ const result = await this.ssoManager.authenticateWithToken(provider, token);
284
+ return result.success;
285
+ }
286
+ }
@@ -0,0 +1,101 @@
1
+ jest.mock('react-native', () => ({
2
+ Linking: {
3
+ addEventListener: jest.fn(),
4
+ getInitialURL: jest.fn(),
5
+ openURL: jest.fn(),
6
+ },
7
+ }));
8
+
9
+ import { DeepLinkHandler } from '../realtime/DeepLinkHandler';
10
+
11
+ describe('DeepLinkHandler', () => {
12
+ describe('parseUrl', () => {
13
+ // Squad scheme
14
+ test('parses squad://invite/CODE', () => {
15
+ const route = DeepLinkHandler.parseUrl('squad://invite/ABC123');
16
+ expect(route).toEqual({ screen: 'Invite', params: { code: 'ABC123' } });
17
+ });
18
+
19
+ test('parses squad://profile/USER_ID', () => {
20
+ const route = DeepLinkHandler.parseUrl('squad://profile/user-456');
21
+ expect(route).toEqual({ screen: 'Profile', params: { userId: 'user-456' } });
22
+ });
23
+
24
+ test('parses squad://message/CONNECTION_ID', () => {
25
+ const route = DeepLinkHandler.parseUrl('squad://message/conn-789');
26
+ expect(route).toEqual({ screen: 'Messaging', params: { connectionId: 'conn-789' } });
27
+ });
28
+
29
+ test('parses squad://poll/POLL_ID', () => {
30
+ const route = DeepLinkHandler.parseUrl('squad://poll/poll-101');
31
+ expect(route).toEqual({ screen: 'PollResponse', params: { pollId: 'poll-101' } });
32
+ });
33
+
34
+ test('parses squad://events', () => {
35
+ const route = DeepLinkHandler.parseUrl('squad://events');
36
+ expect(route).toEqual({ screen: 'Events' });
37
+ });
38
+
39
+ test('parses squad://wallet', () => {
40
+ const route = DeepLinkHandler.parseUrl('squad://wallet');
41
+ expect(route).toEqual({ screen: 'Wallet' });
42
+ });
43
+
44
+ test('parses squad://freestyle', () => {
45
+ const route = DeepLinkHandler.parseUrl('squad://freestyle');
46
+ expect(route).toEqual({ screen: 'FreestyleCreate' });
47
+ });
48
+
49
+ // Universal links
50
+ test('parses https://app.withyoursquad.com/invite/CODE', () => {
51
+ const route = DeepLinkHandler.parseUrl('https://app.withyoursquad.com/invite/XYZ789');
52
+ expect(route).toEqual({ screen: 'Invite', params: { code: 'XYZ789' } });
53
+ });
54
+
55
+ test('parses https://app.withyoursquad.com/profile/USER_ID', () => {
56
+ const route = DeepLinkHandler.parseUrl('https://app.withyoursquad.com/profile/user-123');
57
+ expect(route).toEqual({ screen: 'Profile', params: { userId: 'user-123' } });
58
+ });
59
+
60
+ // Edge cases
61
+ test('returns null for unknown scheme', () => {
62
+ expect(DeepLinkHandler.parseUrl('https://random.com/path')).toBeNull();
63
+ });
64
+
65
+ test('returns Home for unknown squad path', () => {
66
+ const route = DeepLinkHandler.parseUrl('squad://unknown');
67
+ expect(route).toEqual({ screen: 'Home' });
68
+ });
69
+
70
+ test('returns null for empty URL', () => {
71
+ expect(DeepLinkHandler.parseUrl('')).toBeNull();
72
+ });
73
+
74
+ test('parses invite without code', () => {
75
+ const route = DeepLinkHandler.parseUrl('squad://invite');
76
+ expect(route).toEqual({ screen: 'Invite' });
77
+ });
78
+
79
+ test('profile without userId returns null', () => {
80
+ const route = DeepLinkHandler.parseUrl('squad://profile');
81
+ expect(route).toBeNull();
82
+ });
83
+ });
84
+
85
+ describe('linkingConfig', () => {
86
+ test('has correct prefixes', () => {
87
+ const config = DeepLinkHandler.linkingConfig;
88
+ expect(config.prefixes).toContain('squad://');
89
+ expect(config.prefixes).toContain('https://app.withyoursquad.com');
90
+ });
91
+
92
+ test('has screen mappings', () => {
93
+ const screens = DeepLinkHandler.linkingConfig.config.screens;
94
+ expect(screens).toHaveProperty('Home');
95
+ expect(screens).toHaveProperty('Invite');
96
+ expect(screens).toHaveProperty('Profile');
97
+ expect(screens).toHaveProperty('Messaging');
98
+ expect(screens).toHaveProperty('PollResponse');
99
+ });
100
+ });
101
+ });
@@ -0,0 +1,161 @@
1
+ import React from 'react';
2
+
3
+ // Mock react-native with proper function components
4
+ jest.mock('react-native', () => {
5
+ const React = require('react');
6
+ return {
7
+ View: (props: any) => React.createElement('View', props),
8
+ Text: (props: any) => React.createElement('Text', props),
9
+ Pressable: (props: any) => React.createElement('Pressable', props),
10
+ StyleSheet: {
11
+ create: (styles: Record<string, any>) => styles,
12
+ },
13
+ Platform: { OS: 'ios' },
14
+ };
15
+ });
16
+
17
+ // Mock the theme import
18
+ jest.mock('../theme/ThemeContext', () => ({
19
+ Colors: {
20
+ gray1: '#111',
21
+ gray6: '#666',
22
+ gray9: '#999',
23
+ white: '#fff',
24
+ orange1: '#e9785c',
25
+ purple1: '#7c3aed',
26
+ },
27
+ }));
28
+
29
+ import { create, act } from 'react-test-renderer';
30
+ import type { ReactTestRenderer } from 'react-test-renderer';
31
+ import { ScreenErrorBoundary } from '../components/ErrorBoundary';
32
+
33
+ // Suppress console.error noise from intentional errors
34
+ const originalConsoleError = console.error;
35
+ beforeAll(() => {
36
+ console.error = jest.fn();
37
+ });
38
+ afterAll(() => {
39
+ console.error = originalConsoleError;
40
+ });
41
+
42
+ // A component that throws on render
43
+ function ThrowingChild() {
44
+ throw new Error('Test render crash');
45
+ }
46
+
47
+ describe('ScreenErrorBoundary', () => {
48
+ test('renders children normally when no error', async () => {
49
+ let tree: ReactTestRenderer;
50
+ await act(async () => {
51
+ tree = create(
52
+ React.createElement(
53
+ ScreenErrorBoundary,
54
+ null,
55
+ React.createElement('Text', null, 'Hello'),
56
+ ),
57
+ );
58
+ });
59
+ const json = JSON.stringify(tree!.toJSON());
60
+ expect(json).toContain('Hello');
61
+ });
62
+
63
+ test('catches render error and shows default fallback', async () => {
64
+ let tree: ReactTestRenderer;
65
+ await act(async () => {
66
+ tree = create(
67
+ React.createElement(
68
+ ScreenErrorBoundary,
69
+ null,
70
+ React.createElement(ThrowingChild),
71
+ ),
72
+ );
73
+ });
74
+ const json = JSON.stringify(tree!.toJSON());
75
+ expect(json).toContain('Something went wrong');
76
+ expect(json).toContain('Try Again');
77
+ });
78
+
79
+ test('screenName appears in error message', async () => {
80
+ let tree: ReactTestRenderer;
81
+ await act(async () => {
82
+ tree = create(
83
+ React.createElement(
84
+ ScreenErrorBoundary,
85
+ { screenName: 'Home' },
86
+ React.createElement(ThrowingChild),
87
+ ),
88
+ );
89
+ });
90
+ const json = JSON.stringify(tree!.toJSON());
91
+ expect(json).toContain('Home');
92
+ });
93
+
94
+ test('onError callback fires', async () => {
95
+ const onError = jest.fn();
96
+ await act(async () => {
97
+ create(
98
+ React.createElement(
99
+ ScreenErrorBoundary,
100
+ { onError },
101
+ React.createElement(ThrowingChild),
102
+ ),
103
+ );
104
+ });
105
+ expect(onError).toHaveBeenCalledWith(
106
+ expect.any(Error),
107
+ expect.objectContaining({ componentStack: expect.any(String) }),
108
+ );
109
+ });
110
+
111
+ test('custom fallback prop is rendered', async () => {
112
+ let tree: ReactTestRenderer;
113
+ await act(async () => {
114
+ tree = create(
115
+ React.createElement(
116
+ ScreenErrorBoundary,
117
+ { fallback: React.createElement('Text', null, 'Custom Fallback') },
118
+ React.createElement(ThrowingChild),
119
+ ),
120
+ );
121
+ });
122
+ const json = JSON.stringify(tree!.toJSON());
123
+ expect(json).toContain('Custom Fallback');
124
+ });
125
+
126
+ test('"Try Again" button resets error state', async () => {
127
+ let shouldThrow = true;
128
+ function ConditionalThrower() {
129
+ if (shouldThrow) throw new Error('boom');
130
+ return React.createElement('Text', null, 'Recovered');
131
+ }
132
+
133
+ let tree: ReactTestRenderer;
134
+ await act(async () => {
135
+ tree = create(
136
+ React.createElement(
137
+ ScreenErrorBoundary,
138
+ null,
139
+ React.createElement(ConditionalThrower),
140
+ ),
141
+ );
142
+ });
143
+
144
+ // Verify we're in error state
145
+ const instance = tree!.root;
146
+ const retryButton = instance.findByProps({ accessibilityLabel: 'Try again' });
147
+ expect(retryButton).toBeTruthy();
148
+
149
+ // Fix the child so it won't throw on re-render
150
+ shouldThrow = false;
151
+
152
+ // Press "Try Again"
153
+ await act(async () => {
154
+ retryButton.props.onPress();
155
+ });
156
+
157
+ // Should now show recovered content
158
+ const json = JSON.stringify(tree!.toJSON());
159
+ expect(json).toContain('Recovered');
160
+ });
161
+ });