@croacroa/react-native-template 2.0.1 → 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 -0
- package/README.md +446 -399
- 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 -0
- package/components/ui/UploadProgress.tsx +189 -0
- package/components/ui/VirtualizedList.tsx +288 -285
- package/components/ui/index.ts +28 -23
- 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 -27
- package/hooks/useAnimatedEntry.ts +204 -0
- package/hooks/useApi.ts +64 -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 -0
- 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 -175
- 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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Image compression using expo-image-manipulator
|
|
3
|
+
* Provides a simple API to resize and compress images before upload.
|
|
4
|
+
* @module services/media/compression
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ImageManipulator, SaveFormat } from "expo-image-manipulator";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Options for image compression
|
|
11
|
+
*/
|
|
12
|
+
export interface CompressionOptions {
|
|
13
|
+
/** Maximum width in pixels (default: 1080) */
|
|
14
|
+
maxWidth?: number;
|
|
15
|
+
/** Maximum height in pixels (default: 1080) */
|
|
16
|
+
maxHeight?: number;
|
|
17
|
+
/** Compression quality 0-1 (default: 0.7) */
|
|
18
|
+
quality?: number;
|
|
19
|
+
/** Output format (default: 'jpeg') */
|
|
20
|
+
format?: "jpeg" | "png" | "webp";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Result of image compression
|
|
25
|
+
*/
|
|
26
|
+
export interface CompressionResult {
|
|
27
|
+
/** URI to the compressed image */
|
|
28
|
+
uri: string;
|
|
29
|
+
/** Width of the compressed image */
|
|
30
|
+
width: number;
|
|
31
|
+
/** Height of the compressed image */
|
|
32
|
+
height: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Maps format strings to SaveFormat enum values */
|
|
36
|
+
const formatMap: Record<string, SaveFormat> = {
|
|
37
|
+
jpeg: SaveFormat.JPEG,
|
|
38
|
+
png: SaveFormat.PNG,
|
|
39
|
+
webp: SaveFormat.WEBP,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Compress and optionally resize an image.
|
|
44
|
+
* Uses expo-image-manipulator to resize the image within the specified
|
|
45
|
+
* maximum dimensions while preserving aspect ratio, then saves it
|
|
46
|
+
* with the specified compression quality.
|
|
47
|
+
*
|
|
48
|
+
* @param uri - Local file URI of the image to compress
|
|
49
|
+
* @param options - Compression configuration
|
|
50
|
+
* @returns The compressed image result with URI and dimensions
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* const result = await compressImage(photo.uri, {
|
|
55
|
+
* maxWidth: 800,
|
|
56
|
+
* maxHeight: 800,
|
|
57
|
+
* quality: 0.6,
|
|
58
|
+
* });
|
|
59
|
+
* console.log('Compressed:', result.uri, result.width, result.height);
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export async function compressImage(
|
|
63
|
+
uri: string,
|
|
64
|
+
options: CompressionOptions = {}
|
|
65
|
+
): Promise<CompressionResult> {
|
|
66
|
+
const {
|
|
67
|
+
maxWidth = 1080,
|
|
68
|
+
maxHeight: _maxHeight = 1080,
|
|
69
|
+
quality = 0.7,
|
|
70
|
+
format = "jpeg",
|
|
71
|
+
} = options;
|
|
72
|
+
|
|
73
|
+
const context = ImageManipulator.manipulate(uri);
|
|
74
|
+
|
|
75
|
+
// Resize within max dimensions while preserving aspect ratio.
|
|
76
|
+
// Only pass width so the height scales proportionally, avoiding distortion.
|
|
77
|
+
context.resize({ width: maxWidth });
|
|
78
|
+
|
|
79
|
+
const imageRef = await context.renderAsync();
|
|
80
|
+
|
|
81
|
+
const result = await imageRef.saveAsync({
|
|
82
|
+
compress: quality,
|
|
83
|
+
format: formatMap[format] ?? SaveFormat.JPEG,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
uri: result.uri,
|
|
88
|
+
width: result.width,
|
|
89
|
+
height: result.height,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Image/video selection wrapper around expo-image-picker
|
|
3
|
+
* Provides a unified interface for picking media from library or camera.
|
|
4
|
+
* @module services/media/media-picker
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as ImagePicker from "expo-image-picker";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Represents a media item selected by the user
|
|
11
|
+
*/
|
|
12
|
+
export interface PickedMedia {
|
|
13
|
+
/** Local file URI */
|
|
14
|
+
uri: string;
|
|
15
|
+
/** Type of media */
|
|
16
|
+
type: "image" | "video";
|
|
17
|
+
/** Width in pixels */
|
|
18
|
+
width: number;
|
|
19
|
+
/** Height in pixels */
|
|
20
|
+
height: number;
|
|
21
|
+
/** File size in bytes */
|
|
22
|
+
fileSize?: number;
|
|
23
|
+
/** Original file name */
|
|
24
|
+
fileName?: string;
|
|
25
|
+
/** Duration in milliseconds (video only) */
|
|
26
|
+
duration?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Options for picking media
|
|
31
|
+
*/
|
|
32
|
+
export interface PickOptions {
|
|
33
|
+
/** Type of media to allow */
|
|
34
|
+
mediaTypes?: "images" | "videos" | "all";
|
|
35
|
+
/** Allow editing/cropping */
|
|
36
|
+
allowsEditing?: boolean;
|
|
37
|
+
/** Image quality (0-1) */
|
|
38
|
+
quality?: number;
|
|
39
|
+
/** Aspect ratio for cropping [width, height] */
|
|
40
|
+
aspect?: [number, number];
|
|
41
|
+
/** Allow multiple selection (library only) */
|
|
42
|
+
allowsMultipleSelection?: boolean;
|
|
43
|
+
/** Maximum number of items to select */
|
|
44
|
+
selectionLimit?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Maps our simplified media type strings to ImagePicker.MediaTypeOptions
|
|
49
|
+
*/
|
|
50
|
+
const mediaTypeMap: Record<string, ImagePicker.MediaTypeOptions> = {
|
|
51
|
+
images: ImagePicker.MediaTypeOptions.Images,
|
|
52
|
+
videos: ImagePicker.MediaTypeOptions.Videos,
|
|
53
|
+
all: ImagePicker.MediaTypeOptions.All,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Convert an ImagePickerAsset to our PickedMedia interface
|
|
58
|
+
*/
|
|
59
|
+
function mapAsset(asset: ImagePicker.ImagePickerAsset): PickedMedia {
|
|
60
|
+
return {
|
|
61
|
+
uri: asset.uri,
|
|
62
|
+
type: asset.type === "video" ? "video" : "image",
|
|
63
|
+
width: asset.width,
|
|
64
|
+
height: asset.height,
|
|
65
|
+
fileSize: asset.fileSize ?? undefined,
|
|
66
|
+
fileName: asset.fileName ?? undefined,
|
|
67
|
+
duration: asset.duration ?? undefined,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Pick media from the device photo library.
|
|
73
|
+
* Returns an empty array if the user cancels.
|
|
74
|
+
*
|
|
75
|
+
* @param options - Configuration for the picker
|
|
76
|
+
* @returns Array of picked media items (empty if cancelled)
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* const images = await pickFromLibrary({ mediaTypes: 'images', quality: 0.8 });
|
|
81
|
+
* if (images.length > 0) {
|
|
82
|
+
* console.log('Selected:', images[0].uri);
|
|
83
|
+
* }
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export async function pickFromLibrary(
|
|
87
|
+
options: PickOptions = {}
|
|
88
|
+
): Promise<PickedMedia[]> {
|
|
89
|
+
const {
|
|
90
|
+
mediaTypes = "images",
|
|
91
|
+
allowsEditing = false,
|
|
92
|
+
quality = 0.8,
|
|
93
|
+
aspect,
|
|
94
|
+
allowsMultipleSelection = false,
|
|
95
|
+
selectionLimit,
|
|
96
|
+
} = options;
|
|
97
|
+
|
|
98
|
+
const result = await ImagePicker.launchImageLibraryAsync({
|
|
99
|
+
mediaTypes: mediaTypeMap[mediaTypes] ?? ImagePicker.MediaTypeOptions.Images,
|
|
100
|
+
allowsEditing: allowsMultipleSelection ? false : allowsEditing,
|
|
101
|
+
quality,
|
|
102
|
+
aspect,
|
|
103
|
+
allowsMultipleSelection,
|
|
104
|
+
selectionLimit,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (result.canceled) {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return result.assets.map(mapAsset);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Take a photo or video using the device camera.
|
|
116
|
+
* Returns null if the user cancels.
|
|
117
|
+
*
|
|
118
|
+
* @param options - Configuration for the camera
|
|
119
|
+
* @returns The captured media or null if cancelled
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* const photo = await pickFromCamera({ quality: 0.7, allowsEditing: true });
|
|
124
|
+
* if (photo) {
|
|
125
|
+
* console.log('Captured:', photo.uri);
|
|
126
|
+
* }
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
export async function pickFromCamera(
|
|
130
|
+
options: PickOptions = {}
|
|
131
|
+
): Promise<PickedMedia | null> {
|
|
132
|
+
const {
|
|
133
|
+
mediaTypes = "images",
|
|
134
|
+
allowsEditing = false,
|
|
135
|
+
quality = 0.8,
|
|
136
|
+
aspect,
|
|
137
|
+
} = options;
|
|
138
|
+
|
|
139
|
+
const result = await ImagePicker.launchCameraAsync({
|
|
140
|
+
mediaTypes: mediaTypeMap[mediaTypes] ?? ImagePicker.MediaTypeOptions.Images,
|
|
141
|
+
allowsEditing,
|
|
142
|
+
quality,
|
|
143
|
+
aspect,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (result.canceled) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return mapAsset(result.assets[0]);
|
|
151
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview File upload service with progress tracking
|
|
3
|
+
* Uses XMLHttpRequest for real-time upload progress reporting and abort support.
|
|
4
|
+
* @module services/media/media-upload
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Upload progress information
|
|
9
|
+
*/
|
|
10
|
+
export interface UploadProgress {
|
|
11
|
+
/** Bytes uploaded so far */
|
|
12
|
+
loaded: number;
|
|
13
|
+
/** Total bytes to upload */
|
|
14
|
+
total: number;
|
|
15
|
+
/** Upload percentage (0-100) */
|
|
16
|
+
percentage: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Options for file upload
|
|
21
|
+
*/
|
|
22
|
+
export interface UploadOptions {
|
|
23
|
+
/** Server URL to upload to */
|
|
24
|
+
url: string;
|
|
25
|
+
/** Local file URI to upload */
|
|
26
|
+
uri: string;
|
|
27
|
+
/** Form field name for the file (default: 'file') */
|
|
28
|
+
fieldName?: string;
|
|
29
|
+
/** MIME type of the file (default: 'image/jpeg') */
|
|
30
|
+
mimeType?: string;
|
|
31
|
+
/** Additional HTTP headers */
|
|
32
|
+
headers?: Record<string, string>;
|
|
33
|
+
/** Extra form fields to include */
|
|
34
|
+
extraFields?: Record<string, string>;
|
|
35
|
+
/** Progress callback */
|
|
36
|
+
onProgress?: (progress: UploadProgress) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Upload result from the server
|
|
41
|
+
*/
|
|
42
|
+
export interface UploadResult {
|
|
43
|
+
/** HTTP status code */
|
|
44
|
+
status: number;
|
|
45
|
+
/** Response body as string */
|
|
46
|
+
body: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Upload a file to a server with progress tracking.
|
|
51
|
+
* Returns both a promise that resolves with the upload result and
|
|
52
|
+
* an abort function to cancel the upload.
|
|
53
|
+
*
|
|
54
|
+
* Uses XMLHttpRequest instead of fetch to support upload progress events.
|
|
55
|
+
*
|
|
56
|
+
* @param options - Upload configuration
|
|
57
|
+
* @returns Object with `promise` (resolves with UploadResult) and `abort` function
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* const { promise, abort } = uploadFile({
|
|
62
|
+
* url: 'https://api.example.com/upload',
|
|
63
|
+
* uri: image.uri,
|
|
64
|
+
* mimeType: 'image/jpeg',
|
|
65
|
+
* headers: { Authorization: 'Bearer token' },
|
|
66
|
+
* onProgress: ({ percentage }) => console.log(`${percentage}%`),
|
|
67
|
+
* });
|
|
68
|
+
*
|
|
69
|
+
* // Cancel if needed
|
|
70
|
+
* // abort();
|
|
71
|
+
*
|
|
72
|
+
* const result = await promise;
|
|
73
|
+
* console.log('Upload complete:', result.status, result.body);
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export function uploadFile(options: UploadOptions): {
|
|
77
|
+
promise: Promise<UploadResult>;
|
|
78
|
+
abort: () => void;
|
|
79
|
+
} {
|
|
80
|
+
const {
|
|
81
|
+
url,
|
|
82
|
+
uri,
|
|
83
|
+
fieldName = "file",
|
|
84
|
+
mimeType = "image/jpeg",
|
|
85
|
+
headers = {},
|
|
86
|
+
extraFields = {},
|
|
87
|
+
onProgress,
|
|
88
|
+
} = options;
|
|
89
|
+
|
|
90
|
+
const xhr = new XMLHttpRequest();
|
|
91
|
+
|
|
92
|
+
const promise = new Promise<UploadResult>((resolve, reject) => {
|
|
93
|
+
// Build FormData
|
|
94
|
+
const formData = new FormData();
|
|
95
|
+
|
|
96
|
+
// Extract file name from URI
|
|
97
|
+
const uriParts = uri.split("/");
|
|
98
|
+
const fileName = uriParts[uriParts.length - 1] || "upload";
|
|
99
|
+
|
|
100
|
+
// Append the file — React Native's XMLHttpRequest accepts this format
|
|
101
|
+
formData.append(fieldName, {
|
|
102
|
+
uri,
|
|
103
|
+
type: mimeType,
|
|
104
|
+
name: fileName,
|
|
105
|
+
} as unknown as Blob);
|
|
106
|
+
|
|
107
|
+
// Append any extra form fields
|
|
108
|
+
for (const [key, value] of Object.entries(extraFields)) {
|
|
109
|
+
formData.append(key, value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Track upload progress
|
|
113
|
+
if (onProgress) {
|
|
114
|
+
xhr.upload.addEventListener("progress", (event) => {
|
|
115
|
+
if (event.lengthComputable) {
|
|
116
|
+
onProgress({
|
|
117
|
+
loaded: event.loaded,
|
|
118
|
+
total: event.total,
|
|
119
|
+
percentage: Math.round((event.loaded / event.total) * 100),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Handle successful completion
|
|
126
|
+
xhr.addEventListener("load", () => {
|
|
127
|
+
resolve({
|
|
128
|
+
status: xhr.status,
|
|
129
|
+
body: xhr.responseText,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Handle network errors
|
|
134
|
+
xhr.addEventListener("error", () => {
|
|
135
|
+
reject(new Error("Upload failed: network error"));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Handle abort
|
|
139
|
+
xhr.addEventListener("abort", () => {
|
|
140
|
+
reject(new Error("Upload cancelled"));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Open and configure the request
|
|
144
|
+
xhr.open("POST", url);
|
|
145
|
+
|
|
146
|
+
// Set custom headers
|
|
147
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
148
|
+
xhr.setRequestHeader(key, value);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Send the form data
|
|
152
|
+
xhr.send(formData);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const abort = () => {
|
|
156
|
+
xhr.abort();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return { promise, abort };
|
|
160
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Mock payment adapter for development
|
|
3
|
+
* Simulates in-app purchases with configurable delays and an in-memory store.
|
|
4
|
+
* Use this adapter during development to test purchase flows without real stores.
|
|
5
|
+
* @module services/payments/adapters/mock
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
PaymentAdapter,
|
|
10
|
+
Product,
|
|
11
|
+
Purchase,
|
|
12
|
+
SubscriptionInfo,
|
|
13
|
+
} from "../types";
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Mock Data
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/** Pre-configured mock products for development */
|
|
20
|
+
export const MOCK_PRODUCTS: Product[] = [
|
|
21
|
+
{
|
|
22
|
+
id: "premium_monthly",
|
|
23
|
+
title: "Premium Monthly",
|
|
24
|
+
description: "Unlock all premium features with a monthly subscription",
|
|
25
|
+
price: 9.99,
|
|
26
|
+
priceString: "$9.99",
|
|
27
|
+
currency: "USD",
|
|
28
|
+
type: "subscription",
|
|
29
|
+
subscriptionPeriod: "monthly",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "premium_yearly",
|
|
33
|
+
title: "Premium Yearly",
|
|
34
|
+
description: "Unlock all premium features — save 42% with yearly billing",
|
|
35
|
+
price: 69.99,
|
|
36
|
+
priceString: "$69.99",
|
|
37
|
+
currency: "USD",
|
|
38
|
+
type: "subscription",
|
|
39
|
+
subscriptionPeriod: "yearly",
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Helpers
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
/** Simulate network/store latency */
|
|
48
|
+
function delay(ms: number): Promise<void> {
|
|
49
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Random delay between min and max milliseconds */
|
|
53
|
+
function randomDelay(min = 500, max = 1000): Promise<void> {
|
|
54
|
+
const ms = Math.floor(Math.random() * (max - min + 1)) + min;
|
|
55
|
+
return delay(ms);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// Mock Adapter
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Development adapter that simulates in-app purchases in memory.
|
|
64
|
+
* All purchases are stored in a local array and reset when the app restarts.
|
|
65
|
+
* Includes configurable artificial delays to mimic real store behaviour.
|
|
66
|
+
*/
|
|
67
|
+
export class MockPaymentAdapter implements PaymentAdapter {
|
|
68
|
+
/** In-memory record of completed purchases */
|
|
69
|
+
private purchases: Purchase[] = [];
|
|
70
|
+
|
|
71
|
+
async initialize(): Promise<void> {
|
|
72
|
+
await randomDelay();
|
|
73
|
+
|
|
74
|
+
if (__DEV__) {
|
|
75
|
+
console.log("[Payments] Initialized (mock adapter)");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async getProducts(ids: string[]): Promise<Product[]> {
|
|
80
|
+
await randomDelay();
|
|
81
|
+
|
|
82
|
+
const products = MOCK_PRODUCTS.filter((p) => ids.includes(p.id));
|
|
83
|
+
|
|
84
|
+
if (__DEV__) {
|
|
85
|
+
console.log(
|
|
86
|
+
`[Payments] getProducts: requested ${ids.length}, found ${products.length}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return products;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async purchase(productId: string): Promise<Purchase> {
|
|
94
|
+
await randomDelay();
|
|
95
|
+
|
|
96
|
+
const product = MOCK_PRODUCTS.find((p) => p.id === productId);
|
|
97
|
+
if (!product) {
|
|
98
|
+
throw new Error(`Product not found: ${productId}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const purchase: Purchase = {
|
|
102
|
+
id: `mock_txn_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
103
|
+
productId,
|
|
104
|
+
transactionDate: new Date().toISOString(),
|
|
105
|
+
transactionReceipt: `mock_receipt_${Date.now()}`,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
this.purchases.push(purchase);
|
|
109
|
+
|
|
110
|
+
if (__DEV__) {
|
|
111
|
+
console.log(`[Payments] Purchase completed:`, purchase);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return purchase;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async restorePurchases(): Promise<Purchase[]> {
|
|
118
|
+
await randomDelay();
|
|
119
|
+
|
|
120
|
+
if (__DEV__) {
|
|
121
|
+
console.log(`[Payments] Restored ${this.purchases.length} purchase(s)`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return [...this.purchases];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async getSubscriptionStatus(): Promise<SubscriptionInfo> {
|
|
128
|
+
await randomDelay();
|
|
129
|
+
|
|
130
|
+
// Check if the user has any premium purchase
|
|
131
|
+
const hasPremium = this.purchases.some(
|
|
132
|
+
(p) =>
|
|
133
|
+
p.productId === "premium_monthly" || p.productId === "premium_yearly"
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const info: SubscriptionInfo = hasPremium
|
|
137
|
+
? {
|
|
138
|
+
status: "active",
|
|
139
|
+
productId:
|
|
140
|
+
this.purchases[this.purchases.length - 1]?.productId ?? null,
|
|
141
|
+
expiresAt: new Date(
|
|
142
|
+
Date.now() + 30 * 24 * 60 * 60 * 1000
|
|
143
|
+
).toISOString(),
|
|
144
|
+
willRenew: true,
|
|
145
|
+
}
|
|
146
|
+
: {
|
|
147
|
+
status: "none",
|
|
148
|
+
productId: null,
|
|
149
|
+
expiresAt: null,
|
|
150
|
+
willRenew: false,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (__DEV__) {
|
|
154
|
+
console.log(`[Payments] Subscription status:`, info.status);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return info;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Payment adapter manager
|
|
3
|
+
* Singleton-style module that delegates all payment calls to a pluggable
|
|
4
|
+
* adapter. Defaults to the mock adapter so purchase flows work out of the box
|
|
5
|
+
* in development without any extra setup.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { Payments } from "@/services/payments/payment-adapter";
|
|
9
|
+
*
|
|
10
|
+
* // Swap the adapter for production (e.g. RevenueCat)
|
|
11
|
+
* Payments.setAdapter(new RevenueCatAdapter());
|
|
12
|
+
*
|
|
13
|
+
* // Initialize at app start
|
|
14
|
+
* await Payments.initialize();
|
|
15
|
+
*
|
|
16
|
+
* // Fetch products
|
|
17
|
+
* const products = await Payments.getProducts(["premium_monthly"]);
|
|
18
|
+
*
|
|
19
|
+
* @module services/payments/payment-adapter
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type {
|
|
23
|
+
PaymentAdapter,
|
|
24
|
+
Product,
|
|
25
|
+
Purchase,
|
|
26
|
+
SubscriptionInfo,
|
|
27
|
+
} from "./types";
|
|
28
|
+
import { MockPaymentAdapter } from "./adapters/mock";
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Module-level state
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
/** The currently active payment adapter */
|
|
35
|
+
let activeAdapter: PaymentAdapter = new MockPaymentAdapter();
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Public API
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Central payments facade.
|
|
43
|
+
*
|
|
44
|
+
* Every method delegates to the active adapter so the underlying provider
|
|
45
|
+
* can be swapped without touching calling code.
|
|
46
|
+
*/
|
|
47
|
+
export const Payments = {
|
|
48
|
+
// --------------------------------------------------------------------------
|
|
49
|
+
// Configuration
|
|
50
|
+
// --------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Replace the active payment adapter.
|
|
54
|
+
* Call this before `initialize()` to switch providers.
|
|
55
|
+
*/
|
|
56
|
+
setAdapter(adapter: PaymentAdapter): void {
|
|
57
|
+
activeAdapter = adapter;
|
|
58
|
+
|
|
59
|
+
if (__DEV__) {
|
|
60
|
+
console.log("[Payments] Adapter set:", adapter.constructor.name);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
// --------------------------------------------------------------------------
|
|
65
|
+
// Lifecycle
|
|
66
|
+
// --------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/** Initialize the active adapter. Should be called once at app start. */
|
|
69
|
+
async initialize(): Promise<void> {
|
|
70
|
+
await activeAdapter.initialize();
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
// --------------------------------------------------------------------------
|
|
74
|
+
// Products & Purchases
|
|
75
|
+
// --------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Fetch available products by their store IDs.
|
|
79
|
+
*
|
|
80
|
+
* @param ids - Array of product identifiers to fetch
|
|
81
|
+
* @returns Array of available products
|
|
82
|
+
*/
|
|
83
|
+
async getProducts(ids: string[]): Promise<Product[]> {
|
|
84
|
+
return activeAdapter.getProducts(ids);
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Initiate a purchase for the given product.
|
|
89
|
+
*
|
|
90
|
+
* @param productId - The product to purchase
|
|
91
|
+
* @returns The completed purchase record
|
|
92
|
+
*/
|
|
93
|
+
async purchase(productId: string): Promise<Purchase> {
|
|
94
|
+
return activeAdapter.purchase(productId);
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Restore previously completed purchases.
|
|
99
|
+
*
|
|
100
|
+
* @returns Array of restored purchases
|
|
101
|
+
*/
|
|
102
|
+
async restorePurchases(): Promise<Purchase[]> {
|
|
103
|
+
return activeAdapter.restorePurchases();
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
// --------------------------------------------------------------------------
|
|
107
|
+
// Subscriptions
|
|
108
|
+
// --------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get the current subscription status for the user.
|
|
112
|
+
*
|
|
113
|
+
* @returns Current subscription information
|
|
114
|
+
*/
|
|
115
|
+
async getSubscriptionStatus(): Promise<SubscriptionInfo> {
|
|
116
|
+
return activeAdapter.getSubscriptionStatus();
|
|
117
|
+
},
|
|
118
|
+
};
|