@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.
- package/.env.example +5 -0
- package/.eslintrc.js +8 -0
- package/.github/workflows/ci.yml +187 -187
- package/.github/workflows/eas-build.yml +55 -55
- package/.github/workflows/eas-update.yml +50 -50
- package/.github/workflows/npm-publish.yml +57 -0
- package/CHANGELOG.md +195 -106
- package/CONTRIBUTING.md +377 -377
- package/LICENSE +21 -21
- package/README.md +446 -402
- package/__tests__/accessibility/components.test.tsx +285 -0
- package/__tests__/components/Button.test.tsx +2 -4
- package/__tests__/components/__snapshots__/snapshots.test.tsx.snap +512 -0
- package/__tests__/components/snapshots.test.tsx +131 -131
- package/__tests__/helpers/a11y.ts +54 -0
- package/__tests__/hooks/useAnalytics.test.ts +100 -0
- package/__tests__/hooks/useAnimations.test.ts +70 -0
- package/__tests__/hooks/useAuth.test.tsx +71 -28
- package/__tests__/hooks/useMedia.test.ts +318 -0
- package/__tests__/hooks/usePayments.test.tsx +307 -0
- package/__tests__/hooks/usePermission.test.ts +230 -0
- package/__tests__/hooks/useWebSocket.test.ts +329 -0
- package/__tests__/integration/auth-api.test.tsx +224 -227
- package/__tests__/performance/VirtualizedList.perf.test.tsx +385 -362
- package/__tests__/services/api.test.ts +24 -6
- package/app/(auth)/home.tsx +11 -9
- package/app/(auth)/profile.tsx +8 -6
- package/app/(auth)/settings.tsx +11 -9
- package/app/(public)/forgot-password.tsx +25 -15
- package/app/(public)/login.tsx +48 -12
- package/app/(public)/onboarding.tsx +5 -5
- package/app/(public)/register.tsx +24 -15
- package/app/_layout.tsx +6 -3
- package/app.config.ts +27 -2
- package/assets/images/.gitkeep +7 -7
- package/assets/images/adaptive-icon.png +0 -0
- package/assets/images/favicon.png +0 -0
- package/assets/images/icon.png +0 -0
- package/assets/images/notification-icon.png +0 -0
- package/assets/images/splash.png +0 -0
- package/components/ErrorBoundary.tsx +73 -28
- package/components/auth/SocialLoginButtons.tsx +168 -0
- package/components/forms/FormInput.tsx +5 -3
- package/components/onboarding/OnboardingScreen.tsx +370 -370
- package/components/onboarding/index.ts +2 -2
- package/components/providers/AnalyticsProvider.tsx +67 -0
- package/components/providers/SuspenseBoundary.tsx +359 -357
- package/components/providers/index.ts +24 -21
- package/components/ui/AnimatedButton.tsx +1 -9
- package/components/ui/AnimatedList.tsx +98 -0
- package/components/ui/AnimatedScreen.tsx +89 -0
- package/components/ui/Avatar.tsx +319 -316
- package/components/ui/Badge.tsx +416 -416
- package/components/ui/BottomSheet.tsx +307 -307
- package/components/ui/Button.tsx +11 -3
- package/components/ui/Checkbox.tsx +261 -261
- package/components/ui/FeatureGate.tsx +57 -0
- package/components/ui/ForceUpdateScreen.tsx +108 -0
- package/components/ui/ImagePickerButton.tsx +180 -0
- package/components/ui/Input.stories.tsx +2 -10
- package/components/ui/Input.tsx +2 -10
- package/components/ui/OptimizedImage.tsx +369 -369
- package/components/ui/Paywall.tsx +253 -0
- package/components/ui/PermissionGate.tsx +155 -0
- package/components/ui/PurchaseButton.tsx +84 -0
- package/components/ui/Select.tsx +240 -240
- package/components/ui/Skeleton.tsx +3 -1
- package/components/ui/Toast.tsx +427 -418
- package/components/ui/UploadProgress.tsx +189 -0
- package/components/ui/VirtualizedList.tsx +288 -285
- package/components/ui/index.ts +28 -30
- package/constants/config.ts +135 -97
- package/docs/adr/001-state-management.md +79 -79
- package/docs/adr/002-styling-approach.md +130 -130
- package/docs/adr/003-data-fetching.md +155 -155
- package/docs/adr/004-auth-adapter-pattern.md +144 -144
- package/docs/adr/README.md +78 -78
- package/docs/guides/analytics-posthog.md +121 -0
- package/docs/guides/auth-supabase.md +162 -0
- package/docs/guides/feature-flags-launchdarkly.md +150 -0
- package/docs/guides/payments-revenuecat.md +169 -0
- package/docs/plans/2026-02-22-phase6-implementation.md +3222 -0
- package/docs/plans/2026-02-22-phase6-template-completion-design.md +196 -0
- package/docs/plans/2026-02-23-npm-publish-design.md +31 -0
- package/docs/plans/2026-02-23-phase7-polish-documentation-design.md +79 -0
- package/docs/plans/2026-02-23-phase8-additional-features-design.md +136 -0
- package/eas.json +2 -1
- package/hooks/index.ts +70 -40
- package/hooks/useAnimatedEntry.ts +204 -0
- package/hooks/useApi.ts +5 -4
- package/hooks/useAuth.tsx +7 -3
- package/hooks/useBiometrics.ts +295 -295
- package/hooks/useChannel.ts +111 -0
- package/hooks/useDeepLinking.ts +256 -256
- package/hooks/useExperiment.ts +36 -0
- package/hooks/useFeatureFlag.ts +59 -0
- package/hooks/useForceUpdate.ts +91 -0
- package/hooks/useImagePicker.ts +281 -375
- package/hooks/useInAppReview.ts +64 -0
- package/hooks/useMFA.ts +509 -499
- package/hooks/useParallax.ts +142 -0
- package/hooks/usePerformance.ts +434 -434
- package/hooks/usePermission.ts +190 -0
- package/hooks/usePresence.ts +129 -0
- package/hooks/useProducts.ts +36 -0
- package/hooks/usePurchase.ts +103 -0
- package/hooks/useRateLimit.ts +70 -0
- package/hooks/useSubscription.ts +49 -0
- package/hooks/useTrackEvent.ts +52 -0
- package/hooks/useTrackScreen.ts +40 -0
- package/hooks/useUpdates.ts +358 -358
- package/hooks/useUpload.ts +165 -0
- package/hooks/useWebSocket.ts +111 -0
- package/i18n/index.ts +197 -194
- package/i18n/locales/ar.json +170 -101
- package/i18n/locales/de.json +170 -101
- package/i18n/locales/en.json +170 -101
- package/i18n/locales/es.json +170 -101
- package/i18n/locales/fr.json +170 -101
- package/jest.config.js +1 -1
- package/maestro/README.md +113 -113
- package/maestro/config.yaml +35 -35
- package/maestro/flows/login.yaml +62 -62
- package/maestro/flows/mfa-login.yaml +92 -92
- package/maestro/flows/mfa-setup.yaml +86 -86
- package/maestro/flows/navigation.yaml +68 -68
- package/maestro/flows/offline-conflict.yaml +101 -101
- package/maestro/flows/offline-sync.yaml +128 -128
- package/maestro/flows/offline.yaml +60 -60
- package/maestro/flows/register.yaml +94 -94
- package/package.json +188 -176
- package/scripts/generate-placeholders.js +38 -0
- package/services/analytics/adapters/console.ts +50 -0
- package/services/analytics/analytics-adapter.ts +94 -0
- package/services/analytics/types.ts +73 -0
- package/services/analytics.ts +428 -428
- package/services/api.ts +419 -340
- package/services/auth/social/apple.ts +110 -0
- package/services/auth/social/google.ts +159 -0
- package/services/auth/social/social-auth.ts +100 -0
- package/services/auth/social/types.ts +80 -0
- package/services/authAdapter.ts +333 -333
- package/services/backgroundSync.ts +652 -626
- package/services/feature-flags/adapters/mock.ts +108 -0
- package/services/feature-flags/feature-flag-adapter.ts +174 -0
- package/services/feature-flags/types.ts +79 -0
- package/services/force-update.ts +140 -0
- package/services/index.ts +116 -54
- package/services/media/compression.ts +91 -0
- package/services/media/media-picker.ts +151 -0
- package/services/media/media-upload.ts +160 -0
- package/services/payments/adapters/mock.ts +159 -0
- package/services/payments/payment-adapter.ts +118 -0
- package/services/payments/types.ts +131 -0
- package/services/permissions/permission-manager.ts +284 -0
- package/services/permissions/types.ts +104 -0
- package/services/realtime/types.ts +100 -0
- package/services/realtime/websocket-manager.ts +441 -0
- package/services/security.ts +289 -286
- package/services/sentry.ts +4 -4
- package/stores/appStore.ts +9 -0
- package/stores/notificationStore.ts +3 -1
- package/tailwind.config.js +47 -47
- package/tsconfig.json +37 -13
- package/types/user.ts +1 -1
- package/utils/accessibility.ts +446 -446
- package/utils/animations/presets.ts +182 -0
- package/utils/animations/transitions.ts +62 -0
- package/utils/index.ts +63 -52
- package/utils/toast.ts +9 -2
- package/utils/validation.ts +4 -1
- 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
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
*
|
|
17
|
-
* -
|
|
18
|
-
* -
|
|
19
|
-
* -
|
|
20
|
-
* -
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (!
|
|
121
|
-
throw new Error("
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
await
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
private
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
//
|
|
340
|
-
|
|
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 };
|