@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,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
|
+
}
|