@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.
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 -0
  10. package/README.md +446 -399
  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 -0
  69. package/components/ui/UploadProgress.tsx +189 -0
  70. package/components/ui/VirtualizedList.tsx +288 -285
  71. package/components/ui/index.ts +28 -23
  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 -27
  89. package/hooks/useAnimatedEntry.ts +204 -0
  90. package/hooks/useApi.ts +64 -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 -0
  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 -175
  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
@@ -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
+ };