@croacroa/react-native-template 1.0.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 +18 -0
- package/.eslintrc.js +55 -0
- package/.github/workflows/ci.yml +184 -0
- package/.github/workflows/eas-build.yml +55 -0
- package/.github/workflows/eas-update.yml +50 -0
- package/.gitignore +62 -0
- package/.prettierrc +11 -0
- package/.storybook/main.ts +28 -0
- package/.storybook/preview.tsx +30 -0
- package/CHANGELOG.md +106 -0
- package/CONTRIBUTING.md +377 -0
- package/README.md +399 -0
- package/__tests__/components/Button.test.tsx +74 -0
- package/__tests__/hooks/useAuth.test.tsx +499 -0
- package/__tests__/services/api.test.ts +535 -0
- package/__tests__/utils/cn.test.ts +39 -0
- package/app/(auth)/_layout.tsx +36 -0
- package/app/(auth)/home.tsx +117 -0
- package/app/(auth)/profile.tsx +152 -0
- package/app/(auth)/settings.tsx +147 -0
- package/app/(public)/_layout.tsx +21 -0
- package/app/(public)/forgot-password.tsx +127 -0
- package/app/(public)/login.tsx +120 -0
- package/app/(public)/onboarding.tsx +5 -0
- package/app/(public)/register.tsx +139 -0
- package/app/_layout.tsx +97 -0
- package/app/index.tsx +21 -0
- package/app.config.ts +72 -0
- package/assets/images/.gitkeep +7 -0
- 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/babel.config.js +10 -0
- package/components/ErrorBoundary.tsx +169 -0
- package/components/forms/FormInput.tsx +78 -0
- package/components/forms/index.ts +1 -0
- package/components/onboarding/OnboardingScreen.tsx +370 -0
- package/components/onboarding/index.ts +2 -0
- package/components/ui/AnimatedButton.tsx +156 -0
- package/components/ui/AnimatedCard.tsx +108 -0
- package/components/ui/Avatar.tsx +316 -0
- package/components/ui/Badge.tsx +416 -0
- package/components/ui/BottomSheet.tsx +307 -0
- package/components/ui/Button.stories.tsx +115 -0
- package/components/ui/Button.tsx +104 -0
- package/components/ui/Card.stories.tsx +84 -0
- package/components/ui/Card.tsx +32 -0
- package/components/ui/Checkbox.tsx +261 -0
- package/components/ui/Input.stories.tsx +106 -0
- package/components/ui/Input.tsx +117 -0
- package/components/ui/Modal.tsx +98 -0
- package/components/ui/OptimizedImage.tsx +369 -0
- package/components/ui/Select.tsx +240 -0
- package/components/ui/Skeleton.tsx +180 -0
- package/components/ui/index.ts +18 -0
- package/constants/config.ts +54 -0
- package/docs/adr/001-state-management.md +79 -0
- package/docs/adr/002-styling-approach.md +130 -0
- package/docs/adr/003-data-fetching.md +155 -0
- package/docs/adr/004-auth-adapter-pattern.md +144 -0
- package/docs/adr/README.md +78 -0
- package/eas.json +47 -0
- package/global.css +10 -0
- package/hooks/index.ts +25 -0
- package/hooks/useApi.ts +236 -0
- package/hooks/useAuth.tsx +290 -0
- package/hooks/useBiometrics.ts +295 -0
- package/hooks/useDeepLinking.ts +256 -0
- package/hooks/useNotifications.ts +138 -0
- package/hooks/useOffline.ts +69 -0
- package/hooks/usePerformance.ts +434 -0
- package/hooks/useTheme.tsx +85 -0
- package/hooks/useUpdates.ts +358 -0
- package/i18n/index.ts +77 -0
- package/i18n/locales/en.json +101 -0
- package/i18n/locales/fr.json +101 -0
- package/jest.config.js +32 -0
- package/maestro/README.md +113 -0
- package/maestro/config.yaml +35 -0
- package/maestro/flows/login.yaml +62 -0
- package/maestro/flows/navigation.yaml +68 -0
- package/maestro/flows/offline.yaml +60 -0
- package/maestro/flows/register.yaml +94 -0
- package/metro.config.js +6 -0
- package/nativewind-env.d.ts +1 -0
- package/package.json +170 -0
- package/scripts/init.ps1 +162 -0
- package/scripts/init.sh +174 -0
- package/services/analytics.ts +428 -0
- package/services/api.ts +340 -0
- package/services/authAdapter.ts +333 -0
- package/services/index.ts +22 -0
- package/services/queryClient.ts +97 -0
- package/services/sentry.ts +131 -0
- package/services/storage.ts +82 -0
- package/stores/appStore.ts +54 -0
- package/stores/index.ts +2 -0
- package/stores/notificationStore.ts +40 -0
- package/tailwind.config.js +47 -0
- package/tsconfig.json +26 -0
- package/types/index.ts +42 -0
- package/types/user.ts +63 -0
- package/utils/accessibility.ts +446 -0
- package/utils/cn.ts +14 -0
- package/utils/index.ts +43 -0
- package/utils/toast.ts +113 -0
- package/utils/validation.ts +67 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { AccessibilityInfo, AccessibilityRole } from "react-native";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Types
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
export interface AccessibilityProps {
|
|
9
|
+
/**
|
|
10
|
+
* A brief description of the element
|
|
11
|
+
*/
|
|
12
|
+
accessibilityLabel?: string;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Additional context about what will happen when the element is activated
|
|
16
|
+
*/
|
|
17
|
+
accessibilityHint?: string;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The role of the element (button, link, header, etc.)
|
|
21
|
+
*/
|
|
22
|
+
accessibilityRole?: AccessibilityRole;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* State of the element (selected, disabled, checked, etc.)
|
|
26
|
+
*/
|
|
27
|
+
accessibilityState?: {
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
selected?: boolean;
|
|
30
|
+
checked?: boolean | "mixed";
|
|
31
|
+
busy?: boolean;
|
|
32
|
+
expanded?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Value for sliders, progress bars, etc.
|
|
37
|
+
*/
|
|
38
|
+
accessibilityValue?: {
|
|
39
|
+
min?: number;
|
|
40
|
+
max?: number;
|
|
41
|
+
now?: number;
|
|
42
|
+
text?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether the element is accessible
|
|
47
|
+
*/
|
|
48
|
+
accessible?: boolean;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Test ID for testing
|
|
52
|
+
*/
|
|
53
|
+
testID?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// Accessibility Builders
|
|
58
|
+
// ============================================================================
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build accessibility props for a button
|
|
62
|
+
*/
|
|
63
|
+
export function buttonA11y(
|
|
64
|
+
label: string,
|
|
65
|
+
options?: {
|
|
66
|
+
hint?: string;
|
|
67
|
+
disabled?: boolean;
|
|
68
|
+
loading?: boolean;
|
|
69
|
+
testID?: string;
|
|
70
|
+
}
|
|
71
|
+
): AccessibilityProps {
|
|
72
|
+
return {
|
|
73
|
+
accessible: true,
|
|
74
|
+
accessibilityRole: "button",
|
|
75
|
+
accessibilityLabel: label,
|
|
76
|
+
accessibilityHint: options?.hint,
|
|
77
|
+
accessibilityState: {
|
|
78
|
+
disabled: options?.disabled || options?.loading,
|
|
79
|
+
busy: options?.loading,
|
|
80
|
+
},
|
|
81
|
+
testID: options?.testID,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build accessibility props for a link
|
|
87
|
+
*/
|
|
88
|
+
export function linkA11y(
|
|
89
|
+
label: string,
|
|
90
|
+
options?: {
|
|
91
|
+
hint?: string;
|
|
92
|
+
testID?: string;
|
|
93
|
+
}
|
|
94
|
+
): AccessibilityProps {
|
|
95
|
+
return {
|
|
96
|
+
accessible: true,
|
|
97
|
+
accessibilityRole: "link",
|
|
98
|
+
accessibilityLabel: label,
|
|
99
|
+
accessibilityHint: options?.hint || "Double tap to open",
|
|
100
|
+
testID: options?.testID,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build accessibility props for a text input
|
|
106
|
+
*/
|
|
107
|
+
export function inputA11y(
|
|
108
|
+
label: string,
|
|
109
|
+
options?: {
|
|
110
|
+
hint?: string;
|
|
111
|
+
error?: string;
|
|
112
|
+
required?: boolean;
|
|
113
|
+
testID?: string;
|
|
114
|
+
}
|
|
115
|
+
): AccessibilityProps {
|
|
116
|
+
let accessibilityLabel = label;
|
|
117
|
+
if (options?.required) {
|
|
118
|
+
accessibilityLabel += ", required";
|
|
119
|
+
}
|
|
120
|
+
if (options?.error) {
|
|
121
|
+
accessibilityLabel += `, error: ${options.error}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
accessible: true,
|
|
126
|
+
accessibilityLabel,
|
|
127
|
+
accessibilityHint: options?.hint || "Double tap to edit",
|
|
128
|
+
testID: options?.testID,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build accessibility props for a checkbox/switch
|
|
134
|
+
*/
|
|
135
|
+
export function toggleA11y(
|
|
136
|
+
label: string,
|
|
137
|
+
checked: boolean,
|
|
138
|
+
options?: {
|
|
139
|
+
hint?: string;
|
|
140
|
+
disabled?: boolean;
|
|
141
|
+
testID?: string;
|
|
142
|
+
}
|
|
143
|
+
): AccessibilityProps {
|
|
144
|
+
return {
|
|
145
|
+
accessible: true,
|
|
146
|
+
accessibilityRole: "checkbox",
|
|
147
|
+
accessibilityLabel: label,
|
|
148
|
+
accessibilityHint:
|
|
149
|
+
options?.hint || `Double tap to ${checked ? "uncheck" : "check"}`,
|
|
150
|
+
accessibilityState: {
|
|
151
|
+
checked,
|
|
152
|
+
disabled: options?.disabled,
|
|
153
|
+
},
|
|
154
|
+
testID: options?.testID,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Build accessibility props for a header
|
|
160
|
+
*/
|
|
161
|
+
export function headerA11y(
|
|
162
|
+
label: string,
|
|
163
|
+
level: 1 | 2 | 3 | 4 | 5 | 6 = 1
|
|
164
|
+
): AccessibilityProps {
|
|
165
|
+
return {
|
|
166
|
+
accessible: true,
|
|
167
|
+
accessibilityRole: "header",
|
|
168
|
+
accessibilityLabel: `${label}, heading level ${level}`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build accessibility props for an image
|
|
174
|
+
*/
|
|
175
|
+
export function imageA11y(
|
|
176
|
+
description: string,
|
|
177
|
+
options?: {
|
|
178
|
+
isDecorative?: boolean;
|
|
179
|
+
testID?: string;
|
|
180
|
+
}
|
|
181
|
+
): AccessibilityProps {
|
|
182
|
+
if (options?.isDecorative) {
|
|
183
|
+
return {
|
|
184
|
+
accessible: false,
|
|
185
|
+
accessibilityElementsHidden: true,
|
|
186
|
+
} as AccessibilityProps;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
accessible: true,
|
|
191
|
+
accessibilityRole: "image",
|
|
192
|
+
accessibilityLabel: description,
|
|
193
|
+
testID: options?.testID,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build accessibility props for a list item
|
|
199
|
+
*/
|
|
200
|
+
export function listItemA11y(
|
|
201
|
+
label: string,
|
|
202
|
+
position: number,
|
|
203
|
+
total: number,
|
|
204
|
+
options?: {
|
|
205
|
+
hint?: string;
|
|
206
|
+
selected?: boolean;
|
|
207
|
+
testID?: string;
|
|
208
|
+
}
|
|
209
|
+
): AccessibilityProps {
|
|
210
|
+
return {
|
|
211
|
+
accessible: true,
|
|
212
|
+
accessibilityLabel: `${label}, ${position} of ${total}`,
|
|
213
|
+
accessibilityHint: options?.hint,
|
|
214
|
+
accessibilityState: {
|
|
215
|
+
selected: options?.selected,
|
|
216
|
+
},
|
|
217
|
+
testID: options?.testID,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Build accessibility props for a progress indicator
|
|
223
|
+
*/
|
|
224
|
+
export function progressA11y(
|
|
225
|
+
label: string,
|
|
226
|
+
value: number,
|
|
227
|
+
options?: {
|
|
228
|
+
min?: number;
|
|
229
|
+
max?: number;
|
|
230
|
+
testID?: string;
|
|
231
|
+
}
|
|
232
|
+
): AccessibilityProps {
|
|
233
|
+
const min = options?.min ?? 0;
|
|
234
|
+
const max = options?.max ?? 100;
|
|
235
|
+
const percentage = Math.round(((value - min) / (max - min)) * 100);
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
accessible: true,
|
|
239
|
+
accessibilityRole: "progressbar",
|
|
240
|
+
accessibilityLabel: `${label}, ${percentage}% complete`,
|
|
241
|
+
accessibilityValue: {
|
|
242
|
+
min,
|
|
243
|
+
max,
|
|
244
|
+
now: value,
|
|
245
|
+
text: `${percentage}%`,
|
|
246
|
+
},
|
|
247
|
+
testID: options?.testID,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Build accessibility props for a tab
|
|
253
|
+
*/
|
|
254
|
+
export function tabA11y(
|
|
255
|
+
label: string,
|
|
256
|
+
selected: boolean,
|
|
257
|
+
position: number,
|
|
258
|
+
total: number,
|
|
259
|
+
options?: {
|
|
260
|
+
hint?: string;
|
|
261
|
+
testID?: string;
|
|
262
|
+
}
|
|
263
|
+
): AccessibilityProps {
|
|
264
|
+
return {
|
|
265
|
+
accessible: true,
|
|
266
|
+
accessibilityRole: "tab",
|
|
267
|
+
accessibilityLabel: `${label}, tab ${position} of ${total}`,
|
|
268
|
+
accessibilityHint: options?.hint,
|
|
269
|
+
accessibilityState: {
|
|
270
|
+
selected,
|
|
271
|
+
},
|
|
272
|
+
testID: options?.testID,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Build accessibility props for an alert/notification
|
|
278
|
+
*/
|
|
279
|
+
export function alertA11y(
|
|
280
|
+
message: string,
|
|
281
|
+
options?: {
|
|
282
|
+
type?: "info" | "success" | "warning" | "error";
|
|
283
|
+
testID?: string;
|
|
284
|
+
}
|
|
285
|
+
): AccessibilityProps {
|
|
286
|
+
const typeLabel = options?.type ? `${options.type}: ` : "";
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
accessible: true,
|
|
290
|
+
accessibilityRole: "alert",
|
|
291
|
+
accessibilityLabel: `${typeLabel}${message}`,
|
|
292
|
+
accessibilityLiveRegion: "polite",
|
|
293
|
+
testID: options?.testID,
|
|
294
|
+
} as AccessibilityProps;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ============================================================================
|
|
298
|
+
// Hooks
|
|
299
|
+
// ============================================================================
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Hook to check if screen reader is enabled
|
|
303
|
+
*/
|
|
304
|
+
export function useScreenReader(): boolean {
|
|
305
|
+
const [isEnabled, setIsEnabled] = useState(false);
|
|
306
|
+
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
AccessibilityInfo.isScreenReaderEnabled().then(setIsEnabled);
|
|
309
|
+
|
|
310
|
+
const subscription = AccessibilityInfo.addEventListener(
|
|
311
|
+
"screenReaderChanged",
|
|
312
|
+
setIsEnabled
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
return () => subscription.remove();
|
|
316
|
+
}, []);
|
|
317
|
+
|
|
318
|
+
return isEnabled;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Hook to check if reduce motion is enabled
|
|
323
|
+
*/
|
|
324
|
+
export function useReduceMotion(): boolean {
|
|
325
|
+
const [isEnabled, setIsEnabled] = useState(false);
|
|
326
|
+
|
|
327
|
+
useEffect(() => {
|
|
328
|
+
AccessibilityInfo.isReduceMotionEnabled().then(setIsEnabled);
|
|
329
|
+
|
|
330
|
+
const subscription = AccessibilityInfo.addEventListener(
|
|
331
|
+
"reduceMotionChanged",
|
|
332
|
+
setIsEnabled
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
return () => subscription.remove();
|
|
336
|
+
}, []);
|
|
337
|
+
|
|
338
|
+
return isEnabled;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Hook to check if bold text is enabled
|
|
343
|
+
*/
|
|
344
|
+
export function useBoldText(): boolean {
|
|
345
|
+
const [isEnabled, setIsEnabled] = useState(false);
|
|
346
|
+
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
AccessibilityInfo.isBoldTextEnabled().then(setIsEnabled);
|
|
349
|
+
|
|
350
|
+
const subscription = AccessibilityInfo.addEventListener(
|
|
351
|
+
"boldTextChanged",
|
|
352
|
+
setIsEnabled
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
return () => subscription.remove();
|
|
356
|
+
}, []);
|
|
357
|
+
|
|
358
|
+
return isEnabled;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Hook to get all accessibility preferences
|
|
363
|
+
*/
|
|
364
|
+
export function useAccessibilityPreferences() {
|
|
365
|
+
const isScreenReaderEnabled = useScreenReader();
|
|
366
|
+
const isReduceMotionEnabled = useReduceMotion();
|
|
367
|
+
const isBoldTextEnabled = useBoldText();
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
isScreenReaderEnabled,
|
|
371
|
+
isReduceMotionEnabled,
|
|
372
|
+
isBoldTextEnabled,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ============================================================================
|
|
377
|
+
// Utilities
|
|
378
|
+
// ============================================================================
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Announce a message to screen readers
|
|
382
|
+
*/
|
|
383
|
+
export function announce(message: string): void {
|
|
384
|
+
AccessibilityInfo.announceForAccessibility(message);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Set focus to a specific element (requires a ref)
|
|
389
|
+
*/
|
|
390
|
+
export function setAccessibilityFocus(ref: React.RefObject<unknown>): void {
|
|
391
|
+
if (ref.current) {
|
|
392
|
+
AccessibilityInfo.setAccessibilityFocus(ref.current);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Format a price for accessibility
|
|
398
|
+
*/
|
|
399
|
+
export function formatPriceA11y(
|
|
400
|
+
amount: number,
|
|
401
|
+
currency = "EUR",
|
|
402
|
+
locale = "fr-FR"
|
|
403
|
+
): string {
|
|
404
|
+
const formatted = new Intl.NumberFormat(locale, {
|
|
405
|
+
style: "currency",
|
|
406
|
+
currency,
|
|
407
|
+
}).format(amount);
|
|
408
|
+
|
|
409
|
+
// Convert to speakable format
|
|
410
|
+
return formatted
|
|
411
|
+
.replace("€", "euros")
|
|
412
|
+
.replace("$", "dollars")
|
|
413
|
+
.replace("£", "pounds");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Format a date for accessibility
|
|
418
|
+
*/
|
|
419
|
+
export function formatDateA11y(date: Date | string, locale = "fr-FR"): string {
|
|
420
|
+
const d = typeof date === "string" ? new Date(date) : date;
|
|
421
|
+
|
|
422
|
+
return d.toLocaleDateString(locale, {
|
|
423
|
+
weekday: "long",
|
|
424
|
+
year: "numeric",
|
|
425
|
+
month: "long",
|
|
426
|
+
day: "numeric",
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Format a duration for accessibility
|
|
432
|
+
*/
|
|
433
|
+
export function formatDurationA11y(seconds: number): string {
|
|
434
|
+
const hours = Math.floor(seconds / 3600);
|
|
435
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
436
|
+
const secs = seconds % 60;
|
|
437
|
+
|
|
438
|
+
const parts = [];
|
|
439
|
+
if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? "s" : ""}`);
|
|
440
|
+
if (minutes > 0) parts.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`);
|
|
441
|
+
if (secs > 0 || parts.length === 0) {
|
|
442
|
+
parts.push(`${secs} second${secs !== 1 ? "s" : ""}`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return parts.join(", ");
|
|
446
|
+
}
|
package/utils/cn.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ClassValue, clsx } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Utility function to merge Tailwind CSS classes
|
|
6
|
+
* Combines clsx and tailwind-merge for conditional class handling
|
|
7
|
+
*/
|
|
8
|
+
export function cn(...inputs: ClassValue[]) {
|
|
9
|
+
return twMerge(clsx(inputs));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Re-export toast and validation utilities
|
|
13
|
+
export { toast, handleApiError } from "./toast";
|
|
14
|
+
export * from "./validation";
|
package/utils/index.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export { cn } from "./cn";
|
|
2
|
+
export { toast, handleApiError } from "./toast";
|
|
3
|
+
export {
|
|
4
|
+
emailSchema,
|
|
5
|
+
passwordSchema,
|
|
6
|
+
nameSchema,
|
|
7
|
+
loginSchema,
|
|
8
|
+
registerSchema,
|
|
9
|
+
forgotPasswordSchema,
|
|
10
|
+
changePasswordSchema,
|
|
11
|
+
profileSchema,
|
|
12
|
+
} from "./validation";
|
|
13
|
+
export type {
|
|
14
|
+
LoginFormData,
|
|
15
|
+
RegisterFormData,
|
|
16
|
+
ForgotPasswordFormData,
|
|
17
|
+
ChangePasswordFormData,
|
|
18
|
+
ProfileFormData,
|
|
19
|
+
} from "./validation";
|
|
20
|
+
|
|
21
|
+
// Accessibility utilities
|
|
22
|
+
export {
|
|
23
|
+
buttonA11y,
|
|
24
|
+
linkA11y,
|
|
25
|
+
inputA11y,
|
|
26
|
+
toggleA11y,
|
|
27
|
+
headerA11y,
|
|
28
|
+
imageA11y,
|
|
29
|
+
listItemA11y,
|
|
30
|
+
progressA11y,
|
|
31
|
+
tabA11y,
|
|
32
|
+
alertA11y,
|
|
33
|
+
useScreenReader,
|
|
34
|
+
useReduceMotion,
|
|
35
|
+
useBoldText,
|
|
36
|
+
useAccessibilityPreferences,
|
|
37
|
+
announce,
|
|
38
|
+
setAccessibilityFocus,
|
|
39
|
+
formatPriceA11y,
|
|
40
|
+
formatDateA11y,
|
|
41
|
+
formatDurationA11y,
|
|
42
|
+
} from "./accessibility";
|
|
43
|
+
export type { AccessibilityProps } from "./accessibility";
|
package/utils/toast.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as Burnt from "burnt";
|
|
2
|
+
|
|
3
|
+
type ToastPreset = "done" | "error" | "none";
|
|
4
|
+
|
|
5
|
+
interface ToastOptions {
|
|
6
|
+
title: string;
|
|
7
|
+
message?: string;
|
|
8
|
+
preset?: ToastPreset;
|
|
9
|
+
duration?: number;
|
|
10
|
+
haptic?: "success" | "warning" | "error" | "none";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Centralized toast notification system using Burnt
|
|
15
|
+
* Works on iOS (native) and Android (custom implementation)
|
|
16
|
+
*/
|
|
17
|
+
export const toast = {
|
|
18
|
+
/**
|
|
19
|
+
* Show a success toast
|
|
20
|
+
*/
|
|
21
|
+
success: (title: string, message?: string) => {
|
|
22
|
+
Burnt.toast({
|
|
23
|
+
title,
|
|
24
|
+
message,
|
|
25
|
+
preset: "done",
|
|
26
|
+
haptic: "success",
|
|
27
|
+
duration: 3,
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Show an error toast
|
|
33
|
+
*/
|
|
34
|
+
error: (title: string, message?: string) => {
|
|
35
|
+
Burnt.toast({
|
|
36
|
+
title,
|
|
37
|
+
message,
|
|
38
|
+
preset: "error",
|
|
39
|
+
haptic: "error",
|
|
40
|
+
duration: 4,
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Show an info toast
|
|
46
|
+
*/
|
|
47
|
+
info: (title: string, message?: string) => {
|
|
48
|
+
Burnt.toast({
|
|
49
|
+
title,
|
|
50
|
+
message,
|
|
51
|
+
preset: "none",
|
|
52
|
+
haptic: "none",
|
|
53
|
+
duration: 3,
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Show a custom toast with full options
|
|
59
|
+
*/
|
|
60
|
+
custom: (options: ToastOptions) => {
|
|
61
|
+
Burnt.toast({
|
|
62
|
+
title: options.title,
|
|
63
|
+
message: options.message,
|
|
64
|
+
preset: options.preset ?? "none",
|
|
65
|
+
haptic: options.haptic ?? "none",
|
|
66
|
+
duration: options.duration ?? 3,
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Show a native alert dialog
|
|
72
|
+
*/
|
|
73
|
+
alert: (title: string, message?: string, preset?: "done" | "error" | "heart") => {
|
|
74
|
+
Burnt.alert({
|
|
75
|
+
title,
|
|
76
|
+
message,
|
|
77
|
+
preset: preset ?? "done",
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Dismiss all visible toasts
|
|
83
|
+
*/
|
|
84
|
+
dismiss: () => {
|
|
85
|
+
Burnt.dismissAllAlerts();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Handle API errors and show appropriate toast
|
|
91
|
+
*/
|
|
92
|
+
export const handleApiError = (error: unknown, fallbackMessage = "Something went wrong") => {
|
|
93
|
+
if (error instanceof Error) {
|
|
94
|
+
// Check for network errors
|
|
95
|
+
if (error.message.includes("Network") || error.message.includes("fetch")) {
|
|
96
|
+
toast.error("Connection Error", "Please check your internet connection");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check for timeout
|
|
101
|
+
if (error.message.includes("timeout")) {
|
|
102
|
+
toast.error("Request Timeout", "The server took too long to respond");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// API error with message
|
|
107
|
+
toast.error("Error", error.message);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fallback for unknown errors
|
|
112
|
+
toast.error("Error", fallbackMessage);
|
|
113
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// Common field schemas
|
|
4
|
+
export const emailSchema = z
|
|
5
|
+
.string()
|
|
6
|
+
.min(1, "Email is required")
|
|
7
|
+
.email("Invalid email address");
|
|
8
|
+
|
|
9
|
+
export const passwordSchema = z
|
|
10
|
+
.string()
|
|
11
|
+
.min(1, "Password is required")
|
|
12
|
+
.min(8, "Password must be at least 8 characters")
|
|
13
|
+
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
|
14
|
+
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
|
15
|
+
.regex(/[0-9]/, "Password must contain at least one number");
|
|
16
|
+
|
|
17
|
+
export const nameSchema = z
|
|
18
|
+
.string()
|
|
19
|
+
.min(1, "Name is required")
|
|
20
|
+
.min(2, "Name must be at least 2 characters")
|
|
21
|
+
.max(50, "Name must be less than 50 characters");
|
|
22
|
+
|
|
23
|
+
// Form schemas
|
|
24
|
+
export const loginSchema = z.object({
|
|
25
|
+
email: emailSchema,
|
|
26
|
+
password: z.string().min(1, "Password is required"),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const registerSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
name: nameSchema,
|
|
32
|
+
email: emailSchema,
|
|
33
|
+
password: passwordSchema,
|
|
34
|
+
confirmPassword: z.string().min(1, "Please confirm your password"),
|
|
35
|
+
})
|
|
36
|
+
.refine((data) => data.password === data.confirmPassword, {
|
|
37
|
+
message: "Passwords don't match",
|
|
38
|
+
path: ["confirmPassword"],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const forgotPasswordSchema = z.object({
|
|
42
|
+
email: emailSchema,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const changePasswordSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
currentPassword: z.string().min(1, "Current password is required"),
|
|
48
|
+
newPassword: passwordSchema,
|
|
49
|
+
confirmNewPassword: z.string().min(1, "Please confirm your new password"),
|
|
50
|
+
})
|
|
51
|
+
.refine((data) => data.newPassword === data.confirmNewPassword, {
|
|
52
|
+
message: "Passwords don't match",
|
|
53
|
+
path: ["confirmNewPassword"],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const profileSchema = z.object({
|
|
57
|
+
name: nameSchema,
|
|
58
|
+
email: emailSchema,
|
|
59
|
+
bio: z.string().max(200, "Bio must be less than 200 characters").optional(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Type inference helpers
|
|
63
|
+
export type LoginFormData = z.infer<typeof loginSchema>;
|
|
64
|
+
export type RegisterFormData = z.infer<typeof registerSchema>;
|
|
65
|
+
export type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
|
|
66
|
+
export type ChangePasswordFormData = z.infer<typeof changePasswordSchema>;
|
|
67
|
+
export type ProfileFormData = z.infer<typeof profileSchema>;
|