@croacroa/react-native-template 2.1.0 → 3.2.0

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 (172) hide show
  1. package/.env.example +5 -0
  2. package/.eslintrc.js +8 -0
  3. package/.github/workflows/ci.yml +187 -187
  4. package/.github/workflows/eas-build.yml +55 -55
  5. package/.github/workflows/eas-update.yml +50 -50
  6. package/.github/workflows/npm-publish.yml +57 -0
  7. package/CHANGELOG.md +195 -106
  8. package/CONTRIBUTING.md +377 -377
  9. package/LICENSE +21 -21
  10. package/README.md +446 -402
  11. package/__tests__/accessibility/components.test.tsx +285 -0
  12. package/__tests__/components/Button.test.tsx +2 -4
  13. package/__tests__/components/__snapshots__/snapshots.test.tsx.snap +512 -0
  14. package/__tests__/components/snapshots.test.tsx +131 -131
  15. package/__tests__/helpers/a11y.ts +54 -0
  16. package/__tests__/hooks/useAnalytics.test.ts +100 -0
  17. package/__tests__/hooks/useAnimations.test.ts +70 -0
  18. package/__tests__/hooks/useAuth.test.tsx +71 -28
  19. package/__tests__/hooks/useMedia.test.ts +318 -0
  20. package/__tests__/hooks/usePayments.test.tsx +307 -0
  21. package/__tests__/hooks/usePermission.test.ts +230 -0
  22. package/__tests__/hooks/useWebSocket.test.ts +329 -0
  23. package/__tests__/integration/auth-api.test.tsx +224 -227
  24. package/__tests__/performance/VirtualizedList.perf.test.tsx +385 -362
  25. package/__tests__/services/api.test.ts +24 -6
  26. package/app/(auth)/home.tsx +11 -9
  27. package/app/(auth)/profile.tsx +8 -6
  28. package/app/(auth)/settings.tsx +11 -9
  29. package/app/(public)/forgot-password.tsx +25 -15
  30. package/app/(public)/login.tsx +48 -12
  31. package/app/(public)/onboarding.tsx +5 -5
  32. package/app/(public)/register.tsx +24 -15
  33. package/app/_layout.tsx +6 -3
  34. package/app.config.ts +27 -2
  35. package/assets/images/.gitkeep +7 -7
  36. package/assets/images/adaptive-icon.png +0 -0
  37. package/assets/images/favicon.png +0 -0
  38. package/assets/images/icon.png +0 -0
  39. package/assets/images/notification-icon.png +0 -0
  40. package/assets/images/splash.png +0 -0
  41. package/components/ErrorBoundary.tsx +73 -28
  42. package/components/auth/SocialLoginButtons.tsx +168 -0
  43. package/components/forms/FormInput.tsx +5 -3
  44. package/components/onboarding/OnboardingScreen.tsx +370 -370
  45. package/components/onboarding/index.ts +2 -2
  46. package/components/providers/AnalyticsProvider.tsx +67 -0
  47. package/components/providers/SuspenseBoundary.tsx +359 -357
  48. package/components/providers/index.ts +24 -21
  49. package/components/ui/AnimatedButton.tsx +1 -9
  50. package/components/ui/AnimatedList.tsx +98 -0
  51. package/components/ui/AnimatedScreen.tsx +89 -0
  52. package/components/ui/Avatar.tsx +319 -316
  53. package/components/ui/Badge.tsx +416 -416
  54. package/components/ui/BottomSheet.tsx +307 -307
  55. package/components/ui/Button.tsx +11 -3
  56. package/components/ui/Checkbox.tsx +261 -261
  57. package/components/ui/FeatureGate.tsx +57 -0
  58. package/components/ui/ForceUpdateScreen.tsx +108 -0
  59. package/components/ui/ImagePickerButton.tsx +180 -0
  60. package/components/ui/Input.stories.tsx +2 -10
  61. package/components/ui/Input.tsx +2 -10
  62. package/components/ui/OptimizedImage.tsx +369 -369
  63. package/components/ui/Paywall.tsx +253 -0
  64. package/components/ui/PermissionGate.tsx +155 -0
  65. package/components/ui/PurchaseButton.tsx +84 -0
  66. package/components/ui/Select.tsx +240 -240
  67. package/components/ui/Skeleton.tsx +3 -1
  68. package/components/ui/Toast.tsx +427 -418
  69. package/components/ui/UploadProgress.tsx +189 -0
  70. package/components/ui/VirtualizedList.tsx +288 -285
  71. package/components/ui/index.ts +28 -30
  72. package/constants/config.ts +135 -97
  73. package/docs/adr/001-state-management.md +79 -79
  74. package/docs/adr/002-styling-approach.md +130 -130
  75. package/docs/adr/003-data-fetching.md +155 -155
  76. package/docs/adr/004-auth-adapter-pattern.md +144 -144
  77. package/docs/adr/README.md +78 -78
  78. package/docs/guides/analytics-posthog.md +121 -0
  79. package/docs/guides/auth-supabase.md +162 -0
  80. package/docs/guides/feature-flags-launchdarkly.md +150 -0
  81. package/docs/guides/payments-revenuecat.md +169 -0
  82. package/docs/plans/2026-02-22-phase6-implementation.md +3222 -0
  83. package/docs/plans/2026-02-22-phase6-template-completion-design.md +196 -0
  84. package/docs/plans/2026-02-23-npm-publish-design.md +31 -0
  85. package/docs/plans/2026-02-23-phase7-polish-documentation-design.md +79 -0
  86. package/docs/plans/2026-02-23-phase8-additional-features-design.md +136 -0
  87. package/eas.json +2 -1
  88. package/hooks/index.ts +70 -40
  89. package/hooks/useAnimatedEntry.ts +204 -0
  90. package/hooks/useApi.ts +5 -4
  91. package/hooks/useAuth.tsx +7 -3
  92. package/hooks/useBiometrics.ts +295 -295
  93. package/hooks/useChannel.ts +111 -0
  94. package/hooks/useDeepLinking.ts +256 -256
  95. package/hooks/useExperiment.ts +36 -0
  96. package/hooks/useFeatureFlag.ts +59 -0
  97. package/hooks/useForceUpdate.ts +91 -0
  98. package/hooks/useImagePicker.ts +281 -375
  99. package/hooks/useInAppReview.ts +64 -0
  100. package/hooks/useMFA.ts +509 -499
  101. package/hooks/useParallax.ts +142 -0
  102. package/hooks/usePerformance.ts +434 -434
  103. package/hooks/usePermission.ts +190 -0
  104. package/hooks/usePresence.ts +129 -0
  105. package/hooks/useProducts.ts +36 -0
  106. package/hooks/usePurchase.ts +103 -0
  107. package/hooks/useRateLimit.ts +70 -0
  108. package/hooks/useSubscription.ts +49 -0
  109. package/hooks/useTrackEvent.ts +52 -0
  110. package/hooks/useTrackScreen.ts +40 -0
  111. package/hooks/useUpdates.ts +358 -358
  112. package/hooks/useUpload.ts +165 -0
  113. package/hooks/useWebSocket.ts +111 -0
  114. package/i18n/index.ts +197 -194
  115. package/i18n/locales/ar.json +170 -101
  116. package/i18n/locales/de.json +170 -101
  117. package/i18n/locales/en.json +170 -101
  118. package/i18n/locales/es.json +170 -101
  119. package/i18n/locales/fr.json +170 -101
  120. package/jest.config.js +1 -1
  121. package/maestro/README.md +113 -113
  122. package/maestro/config.yaml +35 -35
  123. package/maestro/flows/login.yaml +62 -62
  124. package/maestro/flows/mfa-login.yaml +92 -92
  125. package/maestro/flows/mfa-setup.yaml +86 -86
  126. package/maestro/flows/navigation.yaml +68 -68
  127. package/maestro/flows/offline-conflict.yaml +101 -101
  128. package/maestro/flows/offline-sync.yaml +128 -128
  129. package/maestro/flows/offline.yaml +60 -60
  130. package/maestro/flows/register.yaml +94 -94
  131. package/package.json +188 -176
  132. package/scripts/generate-placeholders.js +38 -0
  133. package/services/analytics/adapters/console.ts +50 -0
  134. package/services/analytics/analytics-adapter.ts +94 -0
  135. package/services/analytics/types.ts +73 -0
  136. package/services/analytics.ts +428 -428
  137. package/services/api.ts +419 -340
  138. package/services/auth/social/apple.ts +110 -0
  139. package/services/auth/social/google.ts +159 -0
  140. package/services/auth/social/social-auth.ts +100 -0
  141. package/services/auth/social/types.ts +80 -0
  142. package/services/authAdapter.ts +333 -333
  143. package/services/backgroundSync.ts +652 -626
  144. package/services/feature-flags/adapters/mock.ts +108 -0
  145. package/services/feature-flags/feature-flag-adapter.ts +174 -0
  146. package/services/feature-flags/types.ts +79 -0
  147. package/services/force-update.ts +140 -0
  148. package/services/index.ts +116 -54
  149. package/services/media/compression.ts +91 -0
  150. package/services/media/media-picker.ts +151 -0
  151. package/services/media/media-upload.ts +160 -0
  152. package/services/payments/adapters/mock.ts +159 -0
  153. package/services/payments/payment-adapter.ts +118 -0
  154. package/services/payments/types.ts +131 -0
  155. package/services/permissions/permission-manager.ts +284 -0
  156. package/services/permissions/types.ts +104 -0
  157. package/services/realtime/types.ts +100 -0
  158. package/services/realtime/websocket-manager.ts +441 -0
  159. package/services/security.ts +289 -286
  160. package/services/sentry.ts +4 -4
  161. package/stores/appStore.ts +9 -0
  162. package/stores/notificationStore.ts +3 -1
  163. package/tailwind.config.js +47 -47
  164. package/tsconfig.json +37 -13
  165. package/types/user.ts +1 -1
  166. package/utils/accessibility.ts +446 -446
  167. package/utils/animations/presets.ts +182 -0
  168. package/utils/animations/transitions.ts +62 -0
  169. package/utils/index.ts +63 -52
  170. package/utils/toast.ts +9 -2
  171. package/utils/validation.ts +4 -1
  172. package/utils/withAccessibility.tsx +272 -272
package/services/api.ts CHANGED
@@ -1,340 +1,419 @@
1
- import * as SecureStore from "expo-secure-store";
2
- import { router } from "expo-router";
3
- import Bottleneck from "bottleneck";
4
- import { API_URL } from "@/constants/config";
5
- import { toast } from "@/utils/toast";
6
- import type { AuthTokens } from "@/types";
7
-
8
- type RequestMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
9
-
10
- // ============================================================================
11
- // Rate Limiting Configuration
12
- // ============================================================================
13
-
14
- /**
15
- * Rate limiter to prevent API abuse and handle rate limiting gracefully
16
- * - maxConcurrent: Maximum concurrent requests
17
- * - minTime: Minimum time between requests (ms)
18
- * - reservoir: Number of requests allowed in the reservoir
19
- * - reservoirRefreshAmount: How many requests to add on refresh
20
- * - reservoirRefreshInterval: How often to refresh the reservoir (ms)
21
- */
22
- const limiter = new Bottleneck({
23
- maxConcurrent: 5, // Max 5 concurrent requests
24
- minTime: 100, // At least 100ms between requests
25
- reservoir: 50, // 50 requests per interval
26
- reservoirRefreshAmount: 50,
27
- reservoirRefreshInterval: 60 * 1000, // Refresh every minute
28
- });
29
-
30
- // Track rate limit errors
31
- let rateLimitRetryAfter = 0;
32
-
33
- limiter.on("failed", async (error, _jobInfo) => {
34
- // If we hit a rate limit, wait and retry
35
- if (error instanceof Error && error.message.includes("429")) {
36
- const retryAfter = rateLimitRetryAfter || 1000;
37
- console.warn(`Rate limited, retrying in ${retryAfter}ms`);
38
- return retryAfter;
39
- }
40
- return null;
41
- });
42
-
43
- limiter.on("retry", (error, jobInfo) => {
44
- console.log(`Retrying request (attempt ${jobInfo.retryCount + 1})`);
45
- });
46
-
47
- interface RequestOptions {
48
- method?: RequestMethod;
49
- body?: Record<string, unknown>;
50
- headers?: Record<string, string>;
51
- requiresAuth?: boolean;
52
- skipRefresh?: boolean;
53
- }
54
-
55
- interface ApiError extends Error {
56
- status: number;
57
- data?: unknown;
58
- }
59
-
60
- const TOKEN_KEY = "auth_tokens";
61
- const TOKEN_REFRESH_THRESHOLD = 5 * 60 * 1000;
62
-
63
- // Track if we're currently refreshing to prevent multiple refresh calls
64
- let isRefreshing = false;
65
- let refreshPromise: Promise<string | null> | null = null;
66
-
67
- /**
68
- * Get current auth tokens from secure storage
69
- */
70
- async function getTokens(): Promise<AuthTokens | null> {
71
- try {
72
- const stored = await SecureStore.getItemAsync(TOKEN_KEY);
73
- return stored ? JSON.parse(stored) : null;
74
- } catch {
75
- return null;
76
- }
77
- }
78
-
79
- /**
80
- * Save new tokens to secure storage
81
- */
82
- async function saveTokens(tokens: AuthTokens): Promise<void> {
83
- await SecureStore.setItemAsync(TOKEN_KEY, JSON.stringify(tokens));
84
- }
85
-
86
- /**
87
- * Clear tokens and redirect to login
88
- */
89
- async function handleAuthFailure(): Promise<void> {
90
- await SecureStore.deleteItemAsync(TOKEN_KEY);
91
- await SecureStore.deleteItemAsync("auth_user");
92
- toast.error("Session expired", "Please sign in again");
93
- router.replace("/(public)/login");
94
- }
95
-
96
- /**
97
- * Refresh the access token using the refresh token
98
- */
99
- async function refreshAccessToken(): Promise<string | null> {
100
- // If already refreshing, wait for that request
101
- if (isRefreshing && refreshPromise) {
102
- return refreshPromise;
103
- }
104
-
105
- isRefreshing = true;
106
- refreshPromise = (async () => {
107
- try {
108
- const tokens = await getTokens();
109
- if (!tokens?.refreshToken) {
110
- throw new Error("No refresh token");
111
- }
112
-
113
- // TODO: Replace with your actual refresh endpoint
114
- const response = await fetch(`${API_URL}/auth/refresh`, {
115
- method: "POST",
116
- headers: { "Content-Type": "application/json" },
117
- body: JSON.stringify({ refreshToken: tokens.refreshToken }),
118
- });
119
-
120
- if (!response.ok) {
121
- throw new Error("Refresh failed");
122
- }
123
-
124
- const data = await response.json();
125
- const newTokens: AuthTokens = {
126
- accessToken: data.accessToken,
127
- refreshToken: data.refreshToken || tokens.refreshToken,
128
- expiresAt: Date.now() + (data.expiresIn || 3600) * 1000,
129
- };
130
-
131
- await saveTokens(newTokens);
132
- return newTokens.accessToken;
133
- } catch (error) {
134
- console.error("Token refresh failed:", error);
135
- await handleAuthFailure();
136
- return null;
137
- } finally {
138
- isRefreshing = false;
139
- refreshPromise = null;
140
- }
141
- })();
142
-
143
- return refreshPromise;
144
- }
145
-
146
- /**
147
- * Get a valid access token, refreshing if necessary
148
- */
149
- async function getValidAccessToken(): Promise<string | null> {
150
- const tokens = await getTokens();
151
- if (!tokens) return null;
152
-
153
- // Check if token needs refresh
154
- const timeUntilExpiry = tokens.expiresAt - Date.now();
155
- if (timeUntilExpiry < TOKEN_REFRESH_THRESHOLD) {
156
- return refreshAccessToken();
157
- }
158
-
159
- return tokens.accessToken;
160
- }
161
-
162
- class ApiClient {
163
- private baseUrl: string;
164
- private defaultTimeout: number;
165
- private enableRateLimiting: boolean;
166
-
167
- constructor(baseUrl: string, timeout = 30000, enableRateLimiting = true) {
168
- this.baseUrl = baseUrl;
169
- this.defaultTimeout = timeout;
170
- this.enableRateLimiting = enableRateLimiting;
171
- }
172
-
173
- /**
174
- * Execute a request with rate limiting
175
- */
176
- private async executeWithRateLimiting<T>(fn: () => Promise<T>): Promise<T> {
177
- if (!this.enableRateLimiting) {
178
- return fn();
179
- }
180
- return limiter.schedule(fn);
181
- }
182
-
183
- private async request<T>(
184
- endpoint: string,
185
- options: RequestOptions = {}
186
- ): Promise<T> {
187
- const {
188
- method = "GET",
189
- body,
190
- headers = {},
191
- requiresAuth = true,
192
- skipRefresh = false,
193
- } = options;
194
-
195
- const requestHeaders: Record<string, string> = {
196
- "Content-Type": "application/json",
197
- ...headers,
198
- };
199
-
200
- // Add auth token if required
201
- if (requiresAuth) {
202
- const token = await getValidAccessToken();
203
- if (token) {
204
- requestHeaders.Authorization = `Bearer ${token}`;
205
- }
206
- }
207
-
208
- // Setup abort controller for timeout
209
- const controller = new AbortController();
210
- const timeoutId = setTimeout(() => controller.abort(), this.defaultTimeout);
211
-
212
- try {
213
- const config: RequestInit = {
214
- method,
215
- headers: requestHeaders,
216
- signal: controller.signal,
217
- };
218
-
219
- if (body && method !== "GET") {
220
- config.body = JSON.stringify(body);
221
- }
222
-
223
- const response = await this.executeWithRateLimiting(() =>
224
- fetch(`${this.baseUrl}${endpoint}`, config)
225
- );
226
-
227
- // Handle 401 - try refresh once
228
- if (response.status === 401 && requiresAuth && !skipRefresh) {
229
- const newToken = await refreshAccessToken();
230
- if (newToken) {
231
- // Retry the request with new token
232
- return this.request(endpoint, { ...options, skipRefresh: true });
233
- }
234
- throw new Error("Authentication failed");
235
- }
236
-
237
- // Handle rate limiting (429)
238
- if (response.status === 429) {
239
- const retryAfter = response.headers.get("Retry-After");
240
- rateLimitRetryAfter = retryAfter
241
- ? parseInt(retryAfter, 10) * 1000
242
- : 1000;
243
- const error = new Error("Rate limited - too many requests") as ApiError;
244
- error.status = 429;
245
- throw error;
246
- }
247
-
248
- // Handle other errors
249
- if (!response.ok) {
250
- const error = new Error(
251
- `API Error: ${response.status} ${response.statusText}`
252
- ) as ApiError;
253
- error.status = response.status;
254
- try {
255
- error.data = await response.json();
256
- } catch {
257
- // Response body is not JSON
258
- }
259
- throw error;
260
- }
261
-
262
- // Handle empty responses
263
- const text = await response.text();
264
- if (!text) {
265
- return {} as T;
266
- }
267
-
268
- return JSON.parse(text);
269
- } catch (error) {
270
- if (error instanceof Error) {
271
- // Handle abort (timeout)
272
- if (error.name === "AbortError") {
273
- const timeoutError = new Error("Request timeout") as ApiError;
274
- timeoutError.status = 408;
275
- throw timeoutError;
276
- }
277
-
278
- // Handle network errors
279
- if (
280
- error.message.includes("Network") ||
281
- error.message.includes("fetch")
282
- ) {
283
- const networkError = new Error("Network error") as ApiError;
284
- networkError.status = 0;
285
- throw networkError;
286
- }
287
- }
288
- throw error;
289
- } finally {
290
- clearTimeout(timeoutId);
291
- }
292
- }
293
-
294
- async get<T>(
295
- endpoint: string,
296
- options?: Omit<RequestOptions, "method" | "body">
297
- ) {
298
- return this.request<T>(endpoint, { ...options, method: "GET" });
299
- }
300
-
301
- async post<T>(
302
- endpoint: string,
303
- body?: Record<string, unknown>,
304
- options?: Omit<RequestOptions, "method">
305
- ) {
306
- return this.request<T>(endpoint, { ...options, method: "POST", body });
307
- }
308
-
309
- async put<T>(
310
- endpoint: string,
311
- body?: Record<string, unknown>,
312
- options?: Omit<RequestOptions, "method">
313
- ) {
314
- return this.request<T>(endpoint, { ...options, method: "PUT", body });
315
- }
316
-
317
- async patch<T>(
318
- endpoint: string,
319
- body?: Record<string, unknown>,
320
- options?: Omit<RequestOptions, "method">
321
- ) {
322
- return this.request<T>(endpoint, { ...options, method: "PATCH", body });
323
- }
324
-
325
- async delete<T>(
326
- endpoint: string,
327
- options?: Omit<RequestOptions, "method" | "body">
328
- ) {
329
- return this.request<T>(endpoint, { ...options, method: "DELETE" });
330
- }
331
- }
332
-
333
- // Export singleton instance
334
- export const api = new ApiClient(API_URL);
335
-
336
- // Export class for testing or creating additional instances
337
- export { ApiClient };
338
-
339
- // Export token utilities for auth hook
340
- export { getTokens, saveTokens, getValidAccessToken };
1
+ import * as SecureStore from "expo-secure-store";
2
+ import { router } from "expo-router";
3
+ import Bottleneck from "bottleneck";
4
+ import i18next from "i18next";
5
+ import { API_URL, API_CONFIG } from "@/constants/config";
6
+ import { toast } from "@/utils/toast";
7
+ import type { AuthTokens } from "@/types";
8
+
9
+ type RequestMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
10
+
11
+ // ============================================================================
12
+ // Rate Limiting Configuration
13
+ // ============================================================================
14
+
15
+ /**
16
+ * Rate limiter to prevent API abuse and handle rate limiting gracefully
17
+ * - maxConcurrent: Maximum concurrent requests
18
+ * - minTime: Minimum time between requests (ms)
19
+ * - reservoir: Number of requests allowed in the reservoir
20
+ * - reservoirRefreshAmount: How many requests to add on refresh
21
+ * - reservoirRefreshInterval: How often to refresh the reservoir (ms)
22
+ */
23
+ const limiter = new Bottleneck({
24
+ maxConcurrent: 5, // Max 5 concurrent requests
25
+ minTime: 100, // At least 100ms between requests
26
+ reservoir: 50, // 50 requests per interval
27
+ reservoirRefreshAmount: 50,
28
+ reservoirRefreshInterval: 60 * 1000, // Refresh every minute
29
+ });
30
+
31
+ // Track rate limit errors
32
+ let rateLimitRetryAfter = 0;
33
+
34
+ limiter.on("failed", async (error, _jobInfo) => {
35
+ // If we hit a rate limit, wait and retry
36
+ if (error instanceof Error && error.message.includes("429")) {
37
+ const retryAfter = rateLimitRetryAfter || 1000;
38
+ console.warn(`Rate limited, retrying in ${retryAfter}ms`);
39
+ return retryAfter;
40
+ }
41
+ return null;
42
+ });
43
+
44
+ limiter.on("retry", (error, jobInfo) => {
45
+ console.log(`Retrying request (attempt ${jobInfo.retryCount + 1})`);
46
+ });
47
+
48
+ interface RequestOptions {
49
+ method?: RequestMethod;
50
+ body?: Record<string, unknown>;
51
+ headers?: Record<string, string>;
52
+ requiresAuth?: boolean;
53
+ skipRefresh?: boolean;
54
+ }
55
+
56
+ interface ApiError extends Error {
57
+ status: number;
58
+ data?: unknown;
59
+ }
60
+
61
+ interface RateLimitError extends Error {
62
+ status: 429;
63
+ retryAfter: number;
64
+ }
65
+
66
+ interface ETagCacheEntry {
67
+ etag: string;
68
+ data: unknown;
69
+ }
70
+
71
+ const TOKEN_KEY = "auth_tokens";
72
+ const TOKEN_REFRESH_THRESHOLD = 5 * 60 * 1000;
73
+
74
+ // Track if we're currently refreshing to prevent multiple refresh calls
75
+ let isRefreshing = false;
76
+ let refreshPromise: Promise<string | null> | null = null;
77
+
78
+ /**
79
+ * Get current auth tokens from secure storage
80
+ */
81
+ async function getTokens(): Promise<AuthTokens | null> {
82
+ try {
83
+ const stored = await SecureStore.getItemAsync(TOKEN_KEY);
84
+ return stored ? JSON.parse(stored) : null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Save new tokens to secure storage
92
+ */
93
+ async function saveTokens(tokens: AuthTokens): Promise<void> {
94
+ await SecureStore.setItemAsync(TOKEN_KEY, JSON.stringify(tokens));
95
+ }
96
+
97
+ /**
98
+ * Clear tokens and redirect to login
99
+ */
100
+ async function handleAuthFailure(): Promise<void> {
101
+ await SecureStore.deleteItemAsync(TOKEN_KEY);
102
+ await SecureStore.deleteItemAsync("auth_user");
103
+ toast.error("Session expired", "Please sign in again");
104
+ router.replace("/(public)/login");
105
+ }
106
+
107
+ /**
108
+ * Refresh the access token using the refresh token
109
+ */
110
+ async function refreshAccessToken(): Promise<string | null> {
111
+ // If already refreshing, wait for that request
112
+ if (isRefreshing && refreshPromise) {
113
+ return refreshPromise;
114
+ }
115
+
116
+ isRefreshing = true;
117
+ refreshPromise = (async () => {
118
+ try {
119
+ const tokens = await getTokens();
120
+ if (!tokens?.refreshToken) {
121
+ throw new Error("No refresh token");
122
+ }
123
+
124
+ // TODO: Replace with your actual refresh endpoint
125
+ const response = await fetch(`${API_URL}/auth/refresh`, {
126
+ method: "POST",
127
+ headers: { "Content-Type": "application/json" },
128
+ body: JSON.stringify({ refreshToken: tokens.refreshToken }),
129
+ });
130
+
131
+ if (!response.ok) {
132
+ throw new Error("Refresh failed");
133
+ }
134
+
135
+ const data = await response.json();
136
+ const newTokens: AuthTokens = {
137
+ accessToken: data.accessToken,
138
+ refreshToken: data.refreshToken || tokens.refreshToken,
139
+ expiresAt: Date.now() + (data.expiresIn || 3600) * 1000,
140
+ };
141
+
142
+ await saveTokens(newTokens);
143
+ return newTokens.accessToken;
144
+ } catch (error) {
145
+ console.error("Token refresh failed:", error);
146
+ await handleAuthFailure();
147
+ return null;
148
+ } finally {
149
+ isRefreshing = false;
150
+ refreshPromise = null;
151
+ }
152
+ })();
153
+
154
+ return refreshPromise;
155
+ }
156
+
157
+ /**
158
+ * Get a valid access token, refreshing if necessary
159
+ */
160
+ async function getValidAccessToken(): Promise<string | null> {
161
+ const tokens = await getTokens();
162
+ if (!tokens) return null;
163
+
164
+ // Check if token needs refresh
165
+ const timeUntilExpiry = tokens.expiresAt - Date.now();
166
+ if (timeUntilExpiry < TOKEN_REFRESH_THRESHOLD) {
167
+ return refreshAccessToken();
168
+ }
169
+
170
+ return tokens.accessToken;
171
+ }
172
+
173
+ class ApiClient {
174
+ private baseUrl: string;
175
+ private defaultTimeout: number;
176
+ private enableRateLimiting: boolean;
177
+
178
+ /** Timestamp (ms) until which we are rate limited by the server */
179
+ rateLimitedUntil: number = 0;
180
+
181
+ /** ETag cache: maps URL to cached ETag + response data */
182
+ private etagCache: Map<string, ETagCacheEntry> = new Map();
183
+
184
+ constructor(baseUrl: string, timeout = 30000, enableRateLimiting = true) {
185
+ this.baseUrl = baseUrl;
186
+ this.defaultTimeout = timeout;
187
+ this.enableRateLimiting = enableRateLimiting;
188
+ }
189
+
190
+ /**
191
+ * Execute a request with rate limiting
192
+ */
193
+ private async executeWithRateLimiting<T>(fn: () => Promise<T>): Promise<T> {
194
+ if (!this.enableRateLimiting) {
195
+ return fn();
196
+ }
197
+ return limiter.schedule(fn);
198
+ }
199
+
200
+ private async request<T>(
201
+ endpoint: string,
202
+ options: RequestOptions = {}
203
+ ): Promise<T> {
204
+ const {
205
+ method = "GET",
206
+ body,
207
+ headers = {},
208
+ requiresAuth = true,
209
+ skipRefresh = false,
210
+ } = options;
211
+
212
+ // ---- Part A: Pre-request rate limit check ----
213
+ if (Date.now() < this.rateLimitedUntil) {
214
+ const secondsLeft = Math.ceil(
215
+ (this.rateLimitedUntil - Date.now()) / 1000
216
+ );
217
+ const error = new Error(
218
+ `Rate limited — retry in ${secondsLeft}s`
219
+ ) as RateLimitError;
220
+ error.status = 429;
221
+ error.retryAfter = secondsLeft;
222
+ throw error;
223
+ }
224
+
225
+ const requestHeaders: Record<string, string> = {
226
+ "Content-Type": "application/json",
227
+ ...headers,
228
+ };
229
+
230
+ // Add auth token if required
231
+ if (requiresAuth) {
232
+ const token = await getValidAccessToken();
233
+ if (token) {
234
+ requestHeaders.Authorization = `Bearer ${token}`;
235
+ }
236
+ }
237
+
238
+ // ---- Part B: ETag — add If-None-Match for GET requests ----
239
+ const fullUrl = `${this.baseUrl}${endpoint}`;
240
+ if (
241
+ method === "GET" &&
242
+ API_CONFIG.ENABLE_ETAG_CACHE &&
243
+ this.etagCache.has(fullUrl)
244
+ ) {
245
+ const cached = this.etagCache.get(fullUrl)!;
246
+ requestHeaders["If-None-Match"] = cached.etag;
247
+ }
248
+
249
+ // Setup abort controller for timeout
250
+ const controller = new AbortController();
251
+ const timeoutId = setTimeout(() => controller.abort(), this.defaultTimeout);
252
+
253
+ try {
254
+ const config: RequestInit = {
255
+ method,
256
+ headers: requestHeaders,
257
+ signal: controller.signal,
258
+ };
259
+
260
+ if (body && method !== "GET") {
261
+ config.body = JSON.stringify(body);
262
+ }
263
+
264
+ const response = await this.executeWithRateLimiting(() =>
265
+ fetch(fullUrl, config)
266
+ );
267
+
268
+ // Handle 401 - try refresh once
269
+ if (response.status === 401 && requiresAuth && !skipRefresh) {
270
+ const newToken = await refreshAccessToken();
271
+ if (newToken) {
272
+ // Retry the request with new token
273
+ return this.request(endpoint, { ...options, skipRefresh: true });
274
+ }
275
+ throw new Error("Authentication failed");
276
+ }
277
+
278
+ // ---- Part A: Handle rate limiting (429) with UI feedback ----
279
+ if (response.status === 429) {
280
+ const retryAfterHeader = response.headers.get("Retry-After");
281
+ const retryAfterSeconds = retryAfterHeader
282
+ ? parseInt(retryAfterHeader, 10)
283
+ : 30;
284
+ rateLimitRetryAfter = retryAfterSeconds * 1000;
285
+
286
+ // Store expiry timestamp so subsequent requests are blocked locally
287
+ this.rateLimitedUntil = Date.now() + retryAfterSeconds * 1000;
288
+
289
+ // Show user-facing toast via i18n
290
+ toast.error(i18next.t("errors.rateLimited"));
291
+
292
+ const error = new Error(
293
+ "Rate limited - too many requests"
294
+ ) as RateLimitError;
295
+ error.status = 429;
296
+ error.retryAfter = retryAfterSeconds;
297
+ throw error;
298
+ }
299
+
300
+ // ---- Part B: ETag — handle 304 Not Modified ----
301
+ if (
302
+ response.status === 304 &&
303
+ method === "GET" &&
304
+ API_CONFIG.ENABLE_ETAG_CACHE
305
+ ) {
306
+ const cached = this.etagCache.get(fullUrl);
307
+ if (cached) {
308
+ return cached.data as T;
309
+ }
310
+ }
311
+
312
+ // Handle other errors
313
+ if (!response.ok) {
314
+ const error = new Error(
315
+ `API Error: ${response.status} ${response.statusText}`
316
+ ) as ApiError;
317
+ error.status = response.status;
318
+ try {
319
+ error.data = await response.json();
320
+ } catch {
321
+ // Response body is not JSON
322
+ }
323
+ throw error;
324
+ }
325
+
326
+ // Handle empty responses
327
+ const text = await response.text();
328
+ if (!text) {
329
+ return {} as T;
330
+ }
331
+
332
+ const parsed = JSON.parse(text) as T;
333
+
334
+ // ---- Part B: ETag — cache response if ETag header present ----
335
+ if (method === "GET" && API_CONFIG.ENABLE_ETAG_CACHE) {
336
+ const etagValue = response.headers.get("ETag");
337
+ if (etagValue) {
338
+ this.etagCache.set(fullUrl, { etag: etagValue, data: parsed });
339
+ // Evict oldest entry when cache exceeds max size
340
+ if (this.etagCache.size > 100) {
341
+ const oldest = this.etagCache.keys().next().value;
342
+ if (oldest) this.etagCache.delete(oldest);
343
+ }
344
+ }
345
+ }
346
+
347
+ return parsed;
348
+ } catch (error) {
349
+ if (error instanceof Error) {
350
+ // Handle abort (timeout)
351
+ if (error.name === "AbortError") {
352
+ const timeoutError = new Error("Request timeout") as ApiError;
353
+ timeoutError.status = 408;
354
+ throw timeoutError;
355
+ }
356
+
357
+ // Handle network errors
358
+ if (
359
+ error.message.includes("Network") ||
360
+ error.message.includes("fetch")
361
+ ) {
362
+ const networkError = new Error("Network error") as ApiError;
363
+ networkError.status = 0;
364
+ throw networkError;
365
+ }
366
+ }
367
+ throw error;
368
+ } finally {
369
+ clearTimeout(timeoutId);
370
+ }
371
+ }
372
+
373
+ async get<T>(
374
+ endpoint: string,
375
+ options?: Omit<RequestOptions, "method" | "body">
376
+ ) {
377
+ return this.request<T>(endpoint, { ...options, method: "GET" });
378
+ }
379
+
380
+ async post<T>(
381
+ endpoint: string,
382
+ body?: Record<string, unknown>,
383
+ options?: Omit<RequestOptions, "method">
384
+ ) {
385
+ return this.request<T>(endpoint, { ...options, method: "POST", body });
386
+ }
387
+
388
+ async put<T>(
389
+ endpoint: string,
390
+ body?: Record<string, unknown>,
391
+ options?: Omit<RequestOptions, "method">
392
+ ) {
393
+ return this.request<T>(endpoint, { ...options, method: "PUT", body });
394
+ }
395
+
396
+ async patch<T>(
397
+ endpoint: string,
398
+ body?: Record<string, unknown>,
399
+ options?: Omit<RequestOptions, "method">
400
+ ) {
401
+ return this.request<T>(endpoint, { ...options, method: "PATCH", body });
402
+ }
403
+
404
+ async delete<T>(
405
+ endpoint: string,
406
+ options?: Omit<RequestOptions, "method" | "body">
407
+ ) {
408
+ return this.request<T>(endpoint, { ...options, method: "DELETE" });
409
+ }
410
+ }
411
+
412
+ // Export singleton instance
413
+ export const api = new ApiClient(API_URL);
414
+
415
+ // Export class for testing or creating additional instances
416
+ export { ApiClient };
417
+
418
+ // Export token utilities for auth hook
419
+ export { getTokens, saveTokens, getValidAccessToken };