@croacroa/react-native-template 2.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/.env.example +5 -0
  2. package/.eslintrc.js +8 -0
  3. package/.github/workflows/ci.yml +187 -187
  4. package/.github/workflows/eas-build.yml +55 -55
  5. package/.github/workflows/eas-update.yml +50 -50
  6. package/.github/workflows/npm-publish.yml +57 -0
  7. package/CHANGELOG.md +195 -106
  8. package/CONTRIBUTING.md +377 -377
  9. package/LICENSE +21 -21
  10. package/README.md +446 -402
  11. package/__tests__/accessibility/components.test.tsx +285 -0
  12. package/__tests__/components/Button.test.tsx +2 -4
  13. package/__tests__/components/__snapshots__/snapshots.test.tsx.snap +512 -0
  14. package/__tests__/components/snapshots.test.tsx +131 -131
  15. package/__tests__/helpers/a11y.ts +54 -0
  16. package/__tests__/hooks/useAnalytics.test.ts +100 -0
  17. package/__tests__/hooks/useAnimations.test.ts +70 -0
  18. package/__tests__/hooks/useAuth.test.tsx +71 -28
  19. package/__tests__/hooks/useMedia.test.ts +318 -0
  20. package/__tests__/hooks/usePayments.test.tsx +307 -0
  21. package/__tests__/hooks/usePermission.test.ts +230 -0
  22. package/__tests__/hooks/useWebSocket.test.ts +329 -0
  23. package/__tests__/integration/auth-api.test.tsx +224 -227
  24. package/__tests__/performance/VirtualizedList.perf.test.tsx +385 -362
  25. package/__tests__/services/api.test.ts +24 -6
  26. package/app/(auth)/home.tsx +11 -9
  27. package/app/(auth)/profile.tsx +8 -6
  28. package/app/(auth)/settings.tsx +11 -9
  29. package/app/(public)/forgot-password.tsx +25 -15
  30. package/app/(public)/login.tsx +48 -12
  31. package/app/(public)/onboarding.tsx +5 -5
  32. package/app/(public)/register.tsx +24 -15
  33. package/app/_layout.tsx +6 -3
  34. package/app.config.ts +27 -2
  35. package/assets/images/.gitkeep +7 -7
  36. package/assets/images/adaptive-icon.png +0 -0
  37. package/assets/images/favicon.png +0 -0
  38. package/assets/images/icon.png +0 -0
  39. package/assets/images/notification-icon.png +0 -0
  40. package/assets/images/splash.png +0 -0
  41. package/components/ErrorBoundary.tsx +73 -28
  42. package/components/auth/SocialLoginButtons.tsx +168 -0
  43. package/components/forms/FormInput.tsx +5 -3
  44. package/components/onboarding/OnboardingScreen.tsx +370 -370
  45. package/components/onboarding/index.ts +2 -2
  46. package/components/providers/AnalyticsProvider.tsx +67 -0
  47. package/components/providers/SuspenseBoundary.tsx +359 -357
  48. package/components/providers/index.ts +24 -21
  49. package/components/ui/AnimatedButton.tsx +1 -9
  50. package/components/ui/AnimatedList.tsx +98 -0
  51. package/components/ui/AnimatedScreen.tsx +89 -0
  52. package/components/ui/Avatar.tsx +319 -316
  53. package/components/ui/Badge.tsx +416 -416
  54. package/components/ui/BottomSheet.tsx +307 -307
  55. package/components/ui/Button.tsx +11 -3
  56. package/components/ui/Checkbox.tsx +261 -261
  57. package/components/ui/FeatureGate.tsx +57 -0
  58. package/components/ui/ForceUpdateScreen.tsx +108 -0
  59. package/components/ui/ImagePickerButton.tsx +180 -0
  60. package/components/ui/Input.stories.tsx +2 -10
  61. package/components/ui/Input.tsx +2 -10
  62. package/components/ui/OptimizedImage.tsx +369 -369
  63. package/components/ui/Paywall.tsx +253 -0
  64. package/components/ui/PermissionGate.tsx +155 -0
  65. package/components/ui/PurchaseButton.tsx +84 -0
  66. package/components/ui/Select.tsx +240 -240
  67. package/components/ui/Skeleton.tsx +3 -1
  68. package/components/ui/Toast.tsx +427 -418
  69. package/components/ui/UploadProgress.tsx +189 -0
  70. package/components/ui/VirtualizedList.tsx +288 -285
  71. package/components/ui/index.ts +28 -30
  72. package/constants/config.ts +135 -97
  73. package/docs/adr/001-state-management.md +79 -79
  74. package/docs/adr/002-styling-approach.md +130 -130
  75. package/docs/adr/003-data-fetching.md +155 -155
  76. package/docs/adr/004-auth-adapter-pattern.md +144 -144
  77. package/docs/adr/README.md +78 -78
  78. package/docs/guides/analytics-posthog.md +121 -0
  79. package/docs/guides/auth-supabase.md +162 -0
  80. package/docs/guides/feature-flags-launchdarkly.md +150 -0
  81. package/docs/guides/payments-revenuecat.md +169 -0
  82. package/docs/plans/2026-02-22-phase6-implementation.md +3222 -0
  83. package/docs/plans/2026-02-22-phase6-template-completion-design.md +196 -0
  84. package/docs/plans/2026-02-23-npm-publish-design.md +31 -0
  85. package/docs/plans/2026-02-23-phase7-polish-documentation-design.md +79 -0
  86. package/docs/plans/2026-02-23-phase8-additional-features-design.md +136 -0
  87. package/eas.json +2 -1
  88. package/hooks/index.ts +70 -40
  89. package/hooks/useAnimatedEntry.ts +204 -0
  90. package/hooks/useApi.ts +5 -4
  91. package/hooks/useAuth.tsx +7 -3
  92. package/hooks/useBiometrics.ts +295 -295
  93. package/hooks/useChannel.ts +111 -0
  94. package/hooks/useDeepLinking.ts +256 -256
  95. package/hooks/useExperiment.ts +36 -0
  96. package/hooks/useFeatureFlag.ts +59 -0
  97. package/hooks/useForceUpdate.ts +91 -0
  98. package/hooks/useImagePicker.ts +281 -375
  99. package/hooks/useInAppReview.ts +64 -0
  100. package/hooks/useMFA.ts +509 -499
  101. package/hooks/useParallax.ts +142 -0
  102. package/hooks/usePerformance.ts +434 -434
  103. package/hooks/usePermission.ts +190 -0
  104. package/hooks/usePresence.ts +129 -0
  105. package/hooks/useProducts.ts +36 -0
  106. package/hooks/usePurchase.ts +103 -0
  107. package/hooks/useRateLimit.ts +70 -0
  108. package/hooks/useSubscription.ts +49 -0
  109. package/hooks/useTrackEvent.ts +52 -0
  110. package/hooks/useTrackScreen.ts +40 -0
  111. package/hooks/useUpdates.ts +358 -358
  112. package/hooks/useUpload.ts +165 -0
  113. package/hooks/useWebSocket.ts +111 -0
  114. package/i18n/index.ts +197 -194
  115. package/i18n/locales/ar.json +170 -101
  116. package/i18n/locales/de.json +170 -101
  117. package/i18n/locales/en.json +170 -101
  118. package/i18n/locales/es.json +170 -101
  119. package/i18n/locales/fr.json +170 -101
  120. package/jest.config.js +1 -1
  121. package/maestro/README.md +113 -113
  122. package/maestro/config.yaml +35 -35
  123. package/maestro/flows/login.yaml +62 -62
  124. package/maestro/flows/mfa-login.yaml +92 -92
  125. package/maestro/flows/mfa-setup.yaml +86 -86
  126. package/maestro/flows/navigation.yaml +68 -68
  127. package/maestro/flows/offline-conflict.yaml +101 -101
  128. package/maestro/flows/offline-sync.yaml +128 -128
  129. package/maestro/flows/offline.yaml +60 -60
  130. package/maestro/flows/register.yaml +94 -94
  131. package/package.json +188 -176
  132. package/scripts/generate-placeholders.js +38 -0
  133. package/services/analytics/adapters/console.ts +50 -0
  134. package/services/analytics/analytics-adapter.ts +94 -0
  135. package/services/analytics/types.ts +73 -0
  136. package/services/analytics.ts +428 -428
  137. package/services/api.ts +419 -340
  138. package/services/auth/social/apple.ts +110 -0
  139. package/services/auth/social/google.ts +159 -0
  140. package/services/auth/social/social-auth.ts +100 -0
  141. package/services/auth/social/types.ts +80 -0
  142. package/services/authAdapter.ts +333 -333
  143. package/services/backgroundSync.ts +652 -626
  144. package/services/feature-flags/adapters/mock.ts +108 -0
  145. package/services/feature-flags/feature-flag-adapter.ts +174 -0
  146. package/services/feature-flags/types.ts +79 -0
  147. package/services/force-update.ts +140 -0
  148. package/services/index.ts +116 -54
  149. package/services/media/compression.ts +91 -0
  150. package/services/media/media-picker.ts +151 -0
  151. package/services/media/media-upload.ts +160 -0
  152. package/services/payments/adapters/mock.ts +159 -0
  153. package/services/payments/payment-adapter.ts +118 -0
  154. package/services/payments/types.ts +131 -0
  155. package/services/permissions/permission-manager.ts +284 -0
  156. package/services/permissions/types.ts +104 -0
  157. package/services/realtime/types.ts +100 -0
  158. package/services/realtime/websocket-manager.ts +441 -0
  159. package/services/security.ts +289 -286
  160. package/services/sentry.ts +4 -4
  161. package/stores/appStore.ts +9 -0
  162. package/stores/notificationStore.ts +3 -1
  163. package/tailwind.config.js +47 -47
  164. package/tsconfig.json +37 -13
  165. package/types/user.ts +1 -1
  166. package/utils/accessibility.ts +446 -446
  167. package/utils/animations/presets.ts +182 -0
  168. package/utils/animations/transitions.ts +62 -0
  169. package/utils/index.ts +63 -52
  170. package/utils/toast.ts +9 -2
  171. package/utils/validation.ts +4 -1
  172. package/utils/withAccessibility.tsx +272 -272
@@ -0,0 +1,190 @@
1
+ /**
2
+ * @fileoverview React hook for centralized permission management
3
+ * Provides a reactive interface for checking and requesting permissions
4
+ * with automatic refresh when returning from device Settings.
5
+ * @module hooks/usePermission
6
+ */
7
+
8
+ import { useState, useEffect, useCallback, useRef } from "react";
9
+ import { AppState, AppStateStatus } from "react-native";
10
+
11
+ import { PermissionManager } from "@/services/permissions/permission-manager";
12
+ import {
13
+ DEFAULT_PERMISSION_CONFIGS,
14
+ type PermissionType,
15
+ type PermissionStatus,
16
+ type PermissionConfig,
17
+ } from "@/services/permissions/types";
18
+
19
+ /**
20
+ * Return type for the usePermission hook
21
+ */
22
+ export interface UsePermissionReturn {
23
+ /** Current normalized permission status */
24
+ status: PermissionStatus;
25
+ /** Whether the permission is granted */
26
+ isGranted: boolean;
27
+ /** Whether the permission is blocked (must open Settings to change) */
28
+ isBlocked: boolean;
29
+ /** Whether the permission status is being loaded */
30
+ isLoading: boolean;
31
+ /** UI configuration for this permission (merged defaults + custom) */
32
+ config: PermissionConfig;
33
+ /** Request the permission from the user and return the resulting status */
34
+ request: () => Promise<PermissionStatus>;
35
+ /** Open device settings for this app */
36
+ openSettings: () => Promise<void>;
37
+ /** Manually refresh the permission status */
38
+ refresh: () => Promise<void>;
39
+ }
40
+
41
+ /**
42
+ * Hook for managing a single permission type.
43
+ * Checks the permission on mount and re-checks when the app returns
44
+ * from the background (e.g., after the user changes settings).
45
+ *
46
+ * @param type - The permission type to manage
47
+ * @param customConfig - Optional partial config to override defaults
48
+ * @returns Permission state and control functions
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * function CameraScreen() {
53
+ * const { status, isGranted, isBlocked, request, openSettings } = usePermission('camera');
54
+ *
55
+ * if (!isGranted) {
56
+ * return (
57
+ * <View>
58
+ * <Text>Camera access required</Text>
59
+ * {isBlocked ? (
60
+ * <Button onPress={openSettings}>Open Settings</Button>
61
+ * ) : (
62
+ * <Button onPress={request}>Allow Camera</Button>
63
+ * )}
64
+ * </View>
65
+ * );
66
+ * }
67
+ *
68
+ * return <CameraView />;
69
+ * }
70
+ * ```
71
+ *
72
+ * @example
73
+ * ```tsx
74
+ * // With custom config
75
+ * const permission = usePermission('location', {
76
+ * title: 'Share Your Location',
77
+ * message: 'We use your location to find nearby restaurants.',
78
+ * });
79
+ * ```
80
+ */
81
+ export function usePermission(
82
+ type: PermissionType,
83
+ customConfig?: Partial<PermissionConfig>
84
+ ): UsePermissionReturn {
85
+ const [status, setStatus] = useState<PermissionStatus>("undetermined");
86
+ const [isLoading, setIsLoading] = useState(true);
87
+ const appStateRef = useRef<AppStateStatus>(AppState.currentState);
88
+
89
+ // Merge custom config with defaults
90
+ const config: PermissionConfig = {
91
+ ...DEFAULT_PERMISSION_CONFIGS[type],
92
+ ...customConfig,
93
+ };
94
+
95
+ /**
96
+ * Check the current permission status
97
+ */
98
+ const checkPermission = useCallback(async () => {
99
+ try {
100
+ const result = await PermissionManager.check(type);
101
+ setStatus(result.status);
102
+ } catch (error) {
103
+ console.error(`[usePermission] Failed to check ${type}:`, error);
104
+ }
105
+ }, [type]);
106
+
107
+ /**
108
+ * Refresh permission status (public API)
109
+ */
110
+ const refresh = useCallback(async () => {
111
+ setIsLoading(true);
112
+ await checkPermission();
113
+ setIsLoading(false);
114
+ }, [checkPermission]);
115
+
116
+ /**
117
+ * Request the permission
118
+ */
119
+ const request = useCallback(async (): Promise<PermissionStatus> => {
120
+ setIsLoading(true);
121
+ try {
122
+ const result = await PermissionManager.request(type);
123
+ setStatus(result.status);
124
+ return result.status;
125
+ } catch (error) {
126
+ console.error(`[usePermission] Failed to request ${type}:`, error);
127
+ return "undetermined";
128
+ } finally {
129
+ setIsLoading(false);
130
+ }
131
+ }, [type]);
132
+
133
+ /**
134
+ * Open device settings
135
+ */
136
+ const openSettings = useCallback(async () => {
137
+ await PermissionManager.openSettings();
138
+ }, []);
139
+
140
+ // Check permission on mount
141
+ useEffect(() => {
142
+ let isMounted = true;
143
+
144
+ const initialCheck = async () => {
145
+ await checkPermission();
146
+ if (isMounted) {
147
+ setIsLoading(false);
148
+ }
149
+ };
150
+
151
+ initialCheck();
152
+
153
+ return () => {
154
+ isMounted = false;
155
+ };
156
+ }, [checkPermission]);
157
+
158
+ // Re-check when app comes back from background (e.g., returning from Settings)
159
+ useEffect(() => {
160
+ const handleAppStateChange = (nextAppState: AppStateStatus) => {
161
+ if (
162
+ appStateRef.current.match(/inactive|background/) &&
163
+ nextAppState === "active"
164
+ ) {
165
+ checkPermission();
166
+ }
167
+ appStateRef.current = nextAppState;
168
+ };
169
+
170
+ const subscription = AppState.addEventListener(
171
+ "change",
172
+ handleAppStateChange
173
+ );
174
+
175
+ return () => {
176
+ subscription.remove();
177
+ };
178
+ }, [checkPermission]);
179
+
180
+ return {
181
+ status,
182
+ isGranted: status === "granted",
183
+ isBlocked: status === "blocked",
184
+ isLoading,
185
+ config,
186
+ request,
187
+ openSettings,
188
+ refresh,
189
+ };
190
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @fileoverview User presence tracking hook
3
+ * Tracks online users in a channel via presence_join, presence_leave,
4
+ * and presence_sync WebSocket messages.
5
+ * @module hooks/usePresence
6
+ */
7
+
8
+ import { useEffect, useState, useCallback, useRef } from "react";
9
+
10
+ import { WebSocketManager } from "@/services/realtime/websocket-manager";
11
+ import type { PresenceUser } from "@/services/realtime/types";
12
+
13
+ /**
14
+ * Return type for the usePresence hook.
15
+ */
16
+ export interface UsePresenceReturn {
17
+ /** Array of currently online users in the channel */
18
+ onlineUsers: PresenceUser[];
19
+ /** Check whether a specific user is currently online */
20
+ isUserOnline: (userId: string) => boolean;
21
+ }
22
+
23
+ /**
24
+ * Hook for tracking user presence in a WebSocket channel.
25
+ *
26
+ * Subscribes to the given channel and listens for presence-related message types:
27
+ * - `presence_join` — A user has come online
28
+ * - `presence_leave` — A user has gone offline
29
+ * - `presence_sync` — Full list of currently online users (requested on mount)
30
+ *
31
+ * On mount, sends a `presence_sync` request to the server so the initial state
32
+ * is populated.
33
+ *
34
+ * @param manager - The WebSocketManager instance (from useWebSocket)
35
+ * @param channel - The channel to track presence for
36
+ * @returns Object with onlineUsers array and isUserOnline helper
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * function OnlineIndicator({ roomId }: { roomId: string }) {
41
+ * const { manager } = useWebSocket({ url: WS_URL });
42
+ * const { onlineUsers, isUserOnline } = usePresence(manager, `room:${roomId}`);
43
+ *
44
+ * return (
45
+ * <View>
46
+ * <Text>{onlineUsers.length} online</Text>
47
+ * {onlineUsers.map((user) => (
48
+ * <Text key={user.id}>{user.name ?? user.id}</Text>
49
+ * ))}
50
+ * </View>
51
+ * );
52
+ * }
53
+ * ```
54
+ */
55
+ export function usePresence(
56
+ manager: WebSocketManager,
57
+ channel: string
58
+ ): UsePresenceReturn {
59
+ const [onlineUsers, setOnlineUsers] = useState<PresenceUser[]>([]);
60
+ const usersRef = useRef<Map<string, PresenceUser>>(new Map());
61
+
62
+ useEffect(() => {
63
+ // Reset state when channel changes
64
+ usersRef.current = new Map();
65
+ setOnlineUsers([]);
66
+
67
+ const unsubscribe = manager.subscribe(channel, (message) => {
68
+ const { type, payload } = message;
69
+
70
+ switch (type) {
71
+ case "presence_join": {
72
+ const user = payload as PresenceUser;
73
+ if (!user?.id) break;
74
+ usersRef.current.set(user.id, {
75
+ ...user,
76
+ lastSeen: user.lastSeen ?? new Date().toISOString(),
77
+ });
78
+ setOnlineUsers(Array.from(usersRef.current.values()));
79
+ break;
80
+ }
81
+
82
+ case "presence_leave": {
83
+ const leavePayload = payload as { id: string };
84
+ if (!leavePayload?.id) break;
85
+ usersRef.current.delete(leavePayload.id);
86
+ setOnlineUsers(Array.from(usersRef.current.values()));
87
+ break;
88
+ }
89
+
90
+ case "presence_sync": {
91
+ const users = payload as PresenceUser[];
92
+ if (!Array.isArray(users)) break;
93
+ usersRef.current = new Map(
94
+ users
95
+ .filter((user) => user?.id)
96
+ .map((user) => [
97
+ user.id,
98
+ {
99
+ ...user,
100
+ lastSeen: user.lastSeen ?? new Date().toISOString(),
101
+ },
102
+ ])
103
+ );
104
+ setOnlineUsers(Array.from(usersRef.current.values()));
105
+ break;
106
+ }
107
+
108
+ default:
109
+ break;
110
+ }
111
+ });
112
+
113
+ // Request a full sync from the server on mount
114
+ manager.send("presence_sync", { channel }, channel);
115
+
116
+ return () => {
117
+ unsubscribe();
118
+ };
119
+ }, [manager, channel]);
120
+
121
+ const isUserOnline = useCallback((userId: string): boolean => {
122
+ return usersRef.current.has(userId);
123
+ }, []);
124
+
125
+ return {
126
+ onlineUsers,
127
+ isUserOnline,
128
+ };
129
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @fileoverview Hook for fetching available in-app products
3
+ * Uses TanStack Query for caching and automatic refetching.
4
+ * @module hooks/useProducts
5
+ */
6
+
7
+ import { useQuery } from "@tanstack/react-query";
8
+ import { Payments } from "@/services/payments/payment-adapter";
9
+ import type { Product } from "@/services/payments/types";
10
+
11
+ /**
12
+ * Fetch available products by their store IDs.
13
+ * Results are cached for 5 minutes to avoid unnecessary store lookups.
14
+ *
15
+ * @param productIds - Array of product identifiers to fetch
16
+ * @returns TanStack Query result with products array
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * function ProductList() {
21
+ * const { data: products, isLoading } = useProducts(["premium_monthly", "premium_yearly"]);
22
+ *
23
+ * if (isLoading) return <Skeleton />;
24
+ *
25
+ * return products?.map((p) => <Text key={p.id}>{p.title} — {p.priceString}</Text>);
26
+ * }
27
+ * ```
28
+ */
29
+ export function useProducts(productIds: string[]) {
30
+ return useQuery<Product[], Error>({
31
+ queryKey: ["products", productIds],
32
+ queryFn: () => Payments.getProducts(productIds),
33
+ staleTime: 1000 * 60 * 5, // 5 minutes
34
+ enabled: productIds.length > 0,
35
+ });
36
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @fileoverview Hook for initiating purchases and restoring transactions
3
+ * Wraps the Payments facade with loading/error state management.
4
+ * @module hooks/usePurchase
5
+ */
6
+
7
+ import { useState, useCallback } from "react";
8
+ import { Payments } from "@/services/payments/payment-adapter";
9
+ import type { Purchase } from "@/services/payments/types";
10
+
11
+ /**
12
+ * Return type for the usePurchase hook.
13
+ */
14
+ export interface UsePurchaseReturn {
15
+ /** Initiate a purchase for the given product ID */
16
+ purchase: (productId: string) => Promise<Purchase | null>;
17
+ /** Restore previously completed purchases */
18
+ restore: () => Promise<Purchase[]>;
19
+ /** Whether a purchase or restore operation is in progress */
20
+ isLoading: boolean;
21
+ /** The most recent error, or null */
22
+ error: Error | null;
23
+ }
24
+
25
+ /**
26
+ * Hook for initiating purchases and restoring transactions.
27
+ * Provides loading and error state so the UI can react accordingly.
28
+ *
29
+ * @returns Object with purchase/restore functions and state
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * function BuyButton({ productId }: { productId: string }) {
34
+ * const { purchase, isLoading, error } = usePurchase();
35
+ *
36
+ * const handleBuy = async () => {
37
+ * const result = await purchase(productId);
38
+ * if (result) {
39
+ * // Purchase succeeded
40
+ * }
41
+ * };
42
+ *
43
+ * return (
44
+ * <Button onPress={handleBuy} isLoading={isLoading}>
45
+ * Buy Now
46
+ * </Button>
47
+ * );
48
+ * }
49
+ * ```
50
+ */
51
+ export function usePurchase(): UsePurchaseReturn {
52
+ const [isLoading, setIsLoading] = useState(false);
53
+ const [error, setError] = useState<Error | null>(null);
54
+
55
+ const purchase = useCallback(
56
+ async (productId: string): Promise<Purchase | null> => {
57
+ setIsLoading(true);
58
+ setError(null);
59
+
60
+ try {
61
+ const result = await Payments.purchase(productId);
62
+ return result;
63
+ } catch (err) {
64
+ const purchaseError =
65
+ err instanceof Error ? err : new Error("Purchase failed");
66
+ setError(purchaseError);
67
+
68
+ if (__DEV__) {
69
+ console.warn("[usePurchase] Purchase error:", purchaseError.message);
70
+ }
71
+
72
+ return null;
73
+ } finally {
74
+ setIsLoading(false);
75
+ }
76
+ },
77
+ []
78
+ );
79
+
80
+ const restore = useCallback(async (): Promise<Purchase[]> => {
81
+ setIsLoading(true);
82
+ setError(null);
83
+
84
+ try {
85
+ const purchases = await Payments.restorePurchases();
86
+ return purchases;
87
+ } catch (err) {
88
+ const restoreError =
89
+ err instanceof Error ? err : new Error("Restore failed");
90
+ setError(restoreError);
91
+
92
+ if (__DEV__) {
93
+ console.warn("[usePurchase] Restore error:", restoreError.message);
94
+ }
95
+
96
+ return [];
97
+ } finally {
98
+ setIsLoading(false);
99
+ }
100
+ }, []);
101
+
102
+ return { purchase, restore, isLoading, error };
103
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @fileoverview Hook to expose the ApiClient's rate limit state to components.
3
+ * Polls the singleton ApiClient every second while rate limited.
4
+ * @module hooks/useRateLimit
5
+ */
6
+
7
+ import { useState, useEffect } from "react";
8
+ import { api } from "@/services/api";
9
+
10
+ interface UseRateLimitReturn {
11
+ /** true while the API client is rate limited */
12
+ isRateLimited: boolean;
13
+ /** Seconds remaining until the rate limit expires (0 when not limited) */
14
+ retryAfter: number;
15
+ /** Date when the rate limit expires, or null when not limited */
16
+ resetTime: Date | null;
17
+ }
18
+
19
+ /**
20
+ * Returns the current rate-limit state of the shared ApiClient singleton.
21
+ *
22
+ * While rate limited the hook polls every second so the UI can show a
23
+ * countdown or disable submit buttons.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * const { isRateLimited, retryAfter } = useRateLimit();
28
+ * if (isRateLimited) {
29
+ * return <Text>Try again in {retryAfter}s</Text>;
30
+ * }
31
+ * ```
32
+ */
33
+ export function useRateLimit(): UseRateLimitReturn {
34
+ const [state, setState] = useState<UseRateLimitReturn>({
35
+ isRateLimited: false,
36
+ retryAfter: 0,
37
+ resetTime: null,
38
+ });
39
+
40
+ useEffect(() => {
41
+ // Poll every second — the cost is negligible since setState short-circuits
42
+ // when values haven't changed. Continuous polling ensures we always detect
43
+ // new 429 responses even after a previous rate limit window has expired.
44
+ const interval = setInterval(() => {
45
+ const now = Date.now();
46
+ const until = api.rateLimitedUntil;
47
+
48
+ if (now < until) {
49
+ setState((prev) => {
50
+ const retryAfter = Math.ceil((until - now) / 1000);
51
+ if (prev.isRateLimited && prev.retryAfter === retryAfter) return prev;
52
+ return {
53
+ isRateLimited: true,
54
+ retryAfter,
55
+ resetTime: new Date(until),
56
+ };
57
+ });
58
+ } else {
59
+ setState((prev) => {
60
+ if (!prev.isRateLimited) return prev;
61
+ return { isRateLimited: false, retryAfter: 0, resetTime: null };
62
+ });
63
+ }
64
+ }, 1000);
65
+
66
+ return () => clearInterval(interval);
67
+ }, []);
68
+
69
+ return state;
70
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @fileoverview Hook for querying the current subscription status
3
+ * Uses TanStack Query for caching and provides derived boolean helpers.
4
+ * @module hooks/useSubscription
5
+ */
6
+
7
+ import { useQuery } from "@tanstack/react-query";
8
+ import { Payments } from "@/services/payments/payment-adapter";
9
+ import type { SubscriptionInfo } from "@/services/payments/types";
10
+
11
+ /**
12
+ * Query the current user's subscription status.
13
+ * Results are cached for 1 minute so the UI stays responsive without
14
+ * over-querying the payment provider.
15
+ *
16
+ * Returns the full TanStack Query result plus two derived helpers:
17
+ * - `isActive` — true when the subscription is "active" or in "grace_period"
18
+ * - `isPro` — true only when the subscription is "active"
19
+ *
20
+ * @returns TanStack Query result with subscription info and helpers
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * function PremiumBadge() {
25
+ * const { isPro, isLoading } = useSubscription();
26
+ *
27
+ * if (isLoading || !isPro) return null;
28
+ *
29
+ * return <Badge label="PRO" />;
30
+ * }
31
+ * ```
32
+ */
33
+ export function useSubscription() {
34
+ const query = useQuery<SubscriptionInfo, Error>({
35
+ queryKey: ["subscription-status"],
36
+ queryFn: () => Payments.getSubscriptionStatus(),
37
+ staleTime: 1000 * 60, // 1 minute
38
+ });
39
+
40
+ const status = query.data?.status;
41
+
42
+ return {
43
+ ...query,
44
+ /** True when subscription is active or in grace period */
45
+ isActive: status === "active" || status === "grace_period",
46
+ /** True only when subscription is fully active */
47
+ isPro: status === "active",
48
+ };
49
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @fileoverview Custom event tracking hook
3
+ * Returns a stable, memoized `track` function that delegates to the
4
+ * analytics adapter manager.
5
+ * @module hooks/useTrackEvent
6
+ */
7
+
8
+ import { useCallback } from "react";
9
+
10
+ import { Analytics } from "@/services/analytics/analytics-adapter";
11
+
12
+ /**
13
+ * Return type for the useTrackEvent hook.
14
+ */
15
+ export interface UseTrackEventReturn {
16
+ /** Fire an analytics event with optional properties */
17
+ track: (event: string, properties?: Record<string, unknown>) => void;
18
+ }
19
+
20
+ /**
21
+ * Provides a memoized `track` function for firing analytics events.
22
+ *
23
+ * The returned function is referentially stable (via `useCallback`) so it
24
+ * is safe to pass as a prop or include in dependency arrays without causing
25
+ * unnecessary re-renders.
26
+ *
27
+ * @returns Object containing the stable `track` function
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * function CheckoutButton() {
32
+ * const { track } = useTrackEvent();
33
+ *
34
+ * const handlePress = () => {
35
+ * track("Checkout Started", { cartSize: 3 });
36
+ * navigateToCheckout();
37
+ * };
38
+ *
39
+ * return <Button onPress={handlePress} title="Checkout" />;
40
+ * }
41
+ * ```
42
+ */
43
+ export function useTrackEvent(): UseTrackEventReturn {
44
+ const track = useCallback(
45
+ (event: string, properties?: Record<string, unknown>) => {
46
+ Analytics.track(event, properties);
47
+ },
48
+ []
49
+ );
50
+
51
+ return { track };
52
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @fileoverview Automatic screen tracking hook for Expo Router
3
+ * Listens to route changes via usePathname / useSegments and fires
4
+ * an analytics screen event on every navigation.
5
+ * @module hooks/useTrackScreen
6
+ */
7
+
8
+ import { useEffect, useRef } from "react";
9
+ import { usePathname, useSegments } from "expo-router";
10
+
11
+ import { Analytics } from "@/services/analytics/analytics-adapter";
12
+
13
+ /**
14
+ * Automatically tracks screen views whenever the Expo Router pathname changes.
15
+ *
16
+ * Place this hook once in your root layout or `AnalyticsProvider` so that every
17
+ * navigation event is recorded without manual instrumentation.
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * function RootLayout() {
22
+ * useTrackScreen();
23
+ * return <Slot />;
24
+ * }
25
+ * ```
26
+ */
27
+ export function useTrackScreen(): void {
28
+ const pathname = usePathname();
29
+ const segments = useSegments();
30
+ const previousPathname = useRef<string | null>(null);
31
+
32
+ useEffect(() => {
33
+ // Only fire when the pathname actually changes (avoids duplicate on mount)
34
+ if (pathname && pathname !== previousPathname.current) {
35
+ Analytics.screen(pathname, { segments });
36
+ previousPathname.current = pathname;
37
+ }
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- segments is derived from pathname; using pathname as the sole trigger avoids duplicate fires
39
+ }, [pathname]);
40
+ }