@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.
- 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 -21
- package/README.md +446 -402
- 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 -418
- package/components/ui/UploadProgress.tsx +189 -0
- package/components/ui/VirtualizedList.tsx +288 -285
- package/components/ui/index.ts +28 -30
- 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 -40
- package/hooks/useAnimatedEntry.ts +204 -0
- package/hooks/useApi.ts +5 -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 -375
- 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 -176
- 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,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Channel subscription hook
|
|
3
|
+
* Subscribes to a specific WebSocket channel and accumulates messages in state.
|
|
4
|
+
* @module hooks/useChannel
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useState, useCallback, useRef } from "react";
|
|
8
|
+
|
|
9
|
+
import { WebSocketManager } from "@/services/realtime/websocket-manager";
|
|
10
|
+
import type { WebSocketMessage } from "@/services/realtime/types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Options for the useChannel hook.
|
|
14
|
+
*/
|
|
15
|
+
export interface UseChannelOptions {
|
|
16
|
+
/** Maximum number of messages to keep in state (default: 500) */
|
|
17
|
+
maxMessages?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Return type for the useChannel hook.
|
|
22
|
+
*
|
|
23
|
+
* @typeParam T - The shape of the message payload
|
|
24
|
+
*/
|
|
25
|
+
export interface UseChannelReturn<T = unknown> {
|
|
26
|
+
/** All messages received on this channel (oldest first) */
|
|
27
|
+
messages: WebSocketMessage<T>[];
|
|
28
|
+
/** The most recently received message, or null if none */
|
|
29
|
+
lastMessage: WebSocketMessage<T> | null;
|
|
30
|
+
/** Send a message to this channel */
|
|
31
|
+
send: (type: string, payload: T) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Hook for subscribing to a WebSocket channel.
|
|
36
|
+
*
|
|
37
|
+
* Subscribes on mount, unsubscribes on unmount, and accumulates all received
|
|
38
|
+
* messages in a state array. Provides a convenience `send` function that
|
|
39
|
+
* automatically targets the subscribed channel.
|
|
40
|
+
*
|
|
41
|
+
* @typeParam T - The shape of the message payload
|
|
42
|
+
* @param manager - The WebSocketManager instance (from useWebSocket)
|
|
43
|
+
* @param channel - The channel name to subscribe to
|
|
44
|
+
* @returns Object with messages array, lastMessage, and channel-scoped send
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```tsx
|
|
48
|
+
* function ChatRoom({ roomId }: { roomId: string }) {
|
|
49
|
+
* const { manager } = useWebSocket({ url: WS_URL });
|
|
50
|
+
* const { messages, lastMessage, send } = useChannel<ChatPayload>(
|
|
51
|
+
* manager,
|
|
52
|
+
* `chat:${roomId}`
|
|
53
|
+
* );
|
|
54
|
+
*
|
|
55
|
+
* const handleSend = (text: string) => {
|
|
56
|
+
* send('chat:message', { text, sender: currentUser.id });
|
|
57
|
+
* };
|
|
58
|
+
*
|
|
59
|
+
* return (
|
|
60
|
+
* <FlatList
|
|
61
|
+
* data={messages}
|
|
62
|
+
* renderItem={({ item }) => <MessageBubble message={item} />}
|
|
63
|
+
* />
|
|
64
|
+
* );
|
|
65
|
+
* }
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export function useChannel<T = unknown>(
|
|
69
|
+
manager: WebSocketManager,
|
|
70
|
+
channel: string,
|
|
71
|
+
options: UseChannelOptions = {}
|
|
72
|
+
): UseChannelReturn<T> {
|
|
73
|
+
const { maxMessages = 500 } = options;
|
|
74
|
+
const [messages, setMessages] = useState<WebSocketMessage<T>[]>([]);
|
|
75
|
+
const [lastMessage, setLastMessage] = useState<WebSocketMessage<T> | null>(
|
|
76
|
+
null
|
|
77
|
+
);
|
|
78
|
+
const channelRef = useRef(channel);
|
|
79
|
+
channelRef.current = channel;
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
// Reset messages when channel changes
|
|
83
|
+
setMessages([]);
|
|
84
|
+
setLastMessage(null);
|
|
85
|
+
|
|
86
|
+
const unsubscribe = manager.subscribe<T>(channel, (message) => {
|
|
87
|
+
setMessages((prev) => {
|
|
88
|
+
const next = [...prev, message];
|
|
89
|
+
return next.length > maxMessages ? next.slice(-maxMessages) : next;
|
|
90
|
+
});
|
|
91
|
+
setLastMessage(message);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return () => {
|
|
95
|
+
unsubscribe();
|
|
96
|
+
};
|
|
97
|
+
}, [manager, channel]);
|
|
98
|
+
|
|
99
|
+
const send = useCallback(
|
|
100
|
+
(type: string, payload: T) => {
|
|
101
|
+
manager.send(type, payload, channelRef.current);
|
|
102
|
+
},
|
|
103
|
+
[manager]
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
messages,
|
|
108
|
+
lastMessage,
|
|
109
|
+
send,
|
|
110
|
+
};
|
|
111
|
+
}
|
package/hooks/useDeepLinking.ts
CHANGED
|
@@ -1,256 +1,256 @@
|
|
|
1
|
-
import { useEffect, useCallback } from "react";
|
|
2
|
-
import * as Linking from "expo-linking";
|
|
3
|
-
import { router } from "expo-router";
|
|
4
|
-
|
|
5
|
-
// Define your app's deep link routes
|
|
6
|
-
type DeepLinkRoute =
|
|
7
|
-
| { path: "login"; params?: never }
|
|
8
|
-
| { path: "register"; params?: never }
|
|
9
|
-
| { path: "reset-password"; params: { token: string } }
|
|
10
|
-
| { path: "profile"; params?: { userId?: string } }
|
|
11
|
-
| { path: "settings"; params?: never }
|
|
12
|
-
| { path: "post"; params: { postId: string } };
|
|
13
|
-
|
|
14
|
-
interface ParsedDeepLink {
|
|
15
|
-
route: DeepLinkRoute | null;
|
|
16
|
-
rawUrl: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Parse a deep link URL into a route and params
|
|
21
|
-
*/
|
|
22
|
-
function parseDeepLink(url: string): ParsedDeepLink {
|
|
23
|
-
try {
|
|
24
|
-
const parsed = Linking.parse(url);
|
|
25
|
-
const { path, queryParams } = parsed;
|
|
26
|
-
|
|
27
|
-
if (!path) {
|
|
28
|
-
return { route: null, rawUrl: url };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Map URL paths to app routes
|
|
32
|
-
switch (path) {
|
|
33
|
-
case "login":
|
|
34
|
-
return { route: { path: "login" }, rawUrl: url };
|
|
35
|
-
|
|
36
|
-
case "register":
|
|
37
|
-
case "signup":
|
|
38
|
-
return { route: { path: "register" }, rawUrl: url };
|
|
39
|
-
|
|
40
|
-
case "reset-password":
|
|
41
|
-
if (queryParams?.token && typeof queryParams.token === "string") {
|
|
42
|
-
return {
|
|
43
|
-
route: {
|
|
44
|
-
path: "reset-password",
|
|
45
|
-
params: { token: queryParams.token },
|
|
46
|
-
},
|
|
47
|
-
rawUrl: url,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
return { route: null, rawUrl: url };
|
|
51
|
-
|
|
52
|
-
case "profile":
|
|
53
|
-
return {
|
|
54
|
-
route: {
|
|
55
|
-
path: "profile",
|
|
56
|
-
params: queryParams?.userId
|
|
57
|
-
? { userId: String(queryParams.userId) }
|
|
58
|
-
: undefined,
|
|
59
|
-
},
|
|
60
|
-
rawUrl: url,
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
case "settings":
|
|
64
|
-
return { route: { path: "settings" }, rawUrl: url };
|
|
65
|
-
|
|
66
|
-
case "post":
|
|
67
|
-
if (queryParams?.id && typeof queryParams.id === "string") {
|
|
68
|
-
return {
|
|
69
|
-
route: { path: "post", params: { postId: queryParams.id } },
|
|
70
|
-
rawUrl: url,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
return { route: null, rawUrl: url };
|
|
74
|
-
|
|
75
|
-
default: {
|
|
76
|
-
// Try to handle paths like /post/123
|
|
77
|
-
const pathParts = path.split("/");
|
|
78
|
-
if (pathParts[0] === "post" && pathParts[1]) {
|
|
79
|
-
return {
|
|
80
|
-
route: { path: "post", params: { postId: pathParts[1] } },
|
|
81
|
-
rawUrl: url,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
if (pathParts[0] === "profile" && pathParts[1]) {
|
|
85
|
-
return {
|
|
86
|
-
route: { path: "profile", params: { userId: pathParts[1] } },
|
|
87
|
-
rawUrl: url,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
return { route: null, rawUrl: url };
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
} catch (error) {
|
|
94
|
-
console.error("Failed to parse deep link:", error);
|
|
95
|
-
return { route: null, rawUrl: url };
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Navigate to a deep link route
|
|
101
|
-
*/
|
|
102
|
-
function navigateToRoute(route: DeepLinkRoute): void {
|
|
103
|
-
switch (route.path) {
|
|
104
|
-
case "login":
|
|
105
|
-
router.replace("/(public)/login");
|
|
106
|
-
break;
|
|
107
|
-
|
|
108
|
-
case "register":
|
|
109
|
-
router.replace("/(public)/register");
|
|
110
|
-
break;
|
|
111
|
-
|
|
112
|
-
case "reset-password":
|
|
113
|
-
// Navigate to forgot password with token
|
|
114
|
-
router.push({
|
|
115
|
-
pathname: "/(public)/forgot-password",
|
|
116
|
-
params: { token: route.params.token },
|
|
117
|
-
});
|
|
118
|
-
break;
|
|
119
|
-
|
|
120
|
-
case "profile":
|
|
121
|
-
if (route.params?.userId) {
|
|
122
|
-
// Navigate to specific user profile
|
|
123
|
-
router.push({
|
|
124
|
-
pathname: "/(auth)/profile",
|
|
125
|
-
params: { userId: route.params.userId },
|
|
126
|
-
});
|
|
127
|
-
} else {
|
|
128
|
-
// Navigate to own profile
|
|
129
|
-
router.push("/(auth)/profile");
|
|
130
|
-
}
|
|
131
|
-
break;
|
|
132
|
-
|
|
133
|
-
case "settings":
|
|
134
|
-
router.push("/(auth)/settings");
|
|
135
|
-
break;
|
|
136
|
-
|
|
137
|
-
case "post":
|
|
138
|
-
// You'll need to create this route
|
|
139
|
-
router.push({
|
|
140
|
-
pathname: "/(auth)/post/[id]" as const,
|
|
141
|
-
params: { id: route.params.postId },
|
|
142
|
-
} as Parameters<typeof router.push>[0]);
|
|
143
|
-
break;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
interface UseDeepLinkingOptions {
|
|
148
|
-
/**
|
|
149
|
-
* Called when a deep link is received but couldn't be parsed
|
|
150
|
-
*/
|
|
151
|
-
onUnknownLink?: (url: string) => void;
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Called before navigating to allow custom handling
|
|
155
|
-
* Return false to prevent default navigation
|
|
156
|
-
*/
|
|
157
|
-
onBeforeNavigate?: (route: DeepLinkRoute) => boolean | void;
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Whether deep linking is enabled
|
|
161
|
-
* @default true
|
|
162
|
-
*/
|
|
163
|
-
enabled?: boolean;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Hook to handle deep links in your app
|
|
168
|
-
*
|
|
169
|
-
* @example
|
|
170
|
-
* ```tsx
|
|
171
|
-
* function App() {
|
|
172
|
-
* useDeepLinking({
|
|
173
|
-
* onUnknownLink: (url) => console.log('Unknown link:', url),
|
|
174
|
-
* onBeforeNavigate: (route) => {
|
|
175
|
-
* // Custom validation before navigating
|
|
176
|
-
* if (route.path === 'profile' && !isAuthenticated) {
|
|
177
|
-
* router.push('/login');
|
|
178
|
-
* return false; // Prevent default navigation
|
|
179
|
-
* }
|
|
180
|
-
* },
|
|
181
|
-
* });
|
|
182
|
-
*
|
|
183
|
-
* return <App />;
|
|
184
|
-
* }
|
|
185
|
-
* ```
|
|
186
|
-
*/
|
|
187
|
-
export function useDeepLinking(options: UseDeepLinkingOptions = {}): void {
|
|
188
|
-
const { onUnknownLink, onBeforeNavigate, enabled = true } = options;
|
|
189
|
-
|
|
190
|
-
const handleDeepLink = useCallback(
|
|
191
|
-
(event: { url: string }) => {
|
|
192
|
-
if (!enabled) return;
|
|
193
|
-
|
|
194
|
-
const { route, rawUrl } = parseDeepLink(event.url);
|
|
195
|
-
|
|
196
|
-
if (!route) {
|
|
197
|
-
onUnknownLink?.(rawUrl);
|
|
198
|
-
console.log("Unknown deep link:", rawUrl);
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Allow custom handling before navigation
|
|
203
|
-
const shouldNavigate = onBeforeNavigate?.(route);
|
|
204
|
-
if (shouldNavigate === false) {
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
navigateToRoute(route);
|
|
209
|
-
},
|
|
210
|
-
[enabled, onUnknownLink, onBeforeNavigate]
|
|
211
|
-
);
|
|
212
|
-
|
|
213
|
-
useEffect(() => {
|
|
214
|
-
if (!enabled) return;
|
|
215
|
-
|
|
216
|
-
// Handle deep links when app is already open
|
|
217
|
-
const subscription = Linking.addEventListener("url", handleDeepLink);
|
|
218
|
-
|
|
219
|
-
// Handle deep link that opened the app
|
|
220
|
-
Linking.getInitialURL().then((url) => {
|
|
221
|
-
if (url) {
|
|
222
|
-
handleDeepLink({ url });
|
|
223
|
-
}
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
return () => {
|
|
227
|
-
subscription.remove();
|
|
228
|
-
};
|
|
229
|
-
}, [enabled, handleDeepLink]);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Get the app's deep link URL prefix
|
|
234
|
-
*/
|
|
235
|
-
export function getDeepLinkPrefix(): string {
|
|
236
|
-
return Linking.createURL("/");
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Create a deep link URL for the app
|
|
241
|
-
*
|
|
242
|
-
* @example
|
|
243
|
-
* ```ts
|
|
244
|
-
* const url = createDeepLink('profile', { userId: '123' });
|
|
245
|
-
* // Returns: yourapp://profile?userId=123
|
|
246
|
-
* ```
|
|
247
|
-
*/
|
|
248
|
-
export function createDeepLink(
|
|
249
|
-
path: string,
|
|
250
|
-
params?: Record<string, string>
|
|
251
|
-
): string {
|
|
252
|
-
return Linking.createURL(path, { queryParams: params });
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
export { parseDeepLink, navigateToRoute };
|
|
256
|
-
export type { DeepLinkRoute, ParsedDeepLink };
|
|
1
|
+
import { useEffect, useCallback } from "react";
|
|
2
|
+
import * as Linking from "expo-linking";
|
|
3
|
+
import { router } from "expo-router";
|
|
4
|
+
|
|
5
|
+
// Define your app's deep link routes
|
|
6
|
+
type DeepLinkRoute =
|
|
7
|
+
| { path: "login"; params?: never }
|
|
8
|
+
| { path: "register"; params?: never }
|
|
9
|
+
| { path: "reset-password"; params: { token: string } }
|
|
10
|
+
| { path: "profile"; params?: { userId?: string } }
|
|
11
|
+
| { path: "settings"; params?: never }
|
|
12
|
+
| { path: "post"; params: { postId: string } };
|
|
13
|
+
|
|
14
|
+
interface ParsedDeepLink {
|
|
15
|
+
route: DeepLinkRoute | null;
|
|
16
|
+
rawUrl: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse a deep link URL into a route and params
|
|
21
|
+
*/
|
|
22
|
+
function parseDeepLink(url: string): ParsedDeepLink {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = Linking.parse(url);
|
|
25
|
+
const { path, queryParams } = parsed;
|
|
26
|
+
|
|
27
|
+
if (!path) {
|
|
28
|
+
return { route: null, rawUrl: url };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Map URL paths to app routes
|
|
32
|
+
switch (path) {
|
|
33
|
+
case "login":
|
|
34
|
+
return { route: { path: "login" }, rawUrl: url };
|
|
35
|
+
|
|
36
|
+
case "register":
|
|
37
|
+
case "signup":
|
|
38
|
+
return { route: { path: "register" }, rawUrl: url };
|
|
39
|
+
|
|
40
|
+
case "reset-password":
|
|
41
|
+
if (queryParams?.token && typeof queryParams.token === "string") {
|
|
42
|
+
return {
|
|
43
|
+
route: {
|
|
44
|
+
path: "reset-password",
|
|
45
|
+
params: { token: queryParams.token },
|
|
46
|
+
},
|
|
47
|
+
rawUrl: url,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { route: null, rawUrl: url };
|
|
51
|
+
|
|
52
|
+
case "profile":
|
|
53
|
+
return {
|
|
54
|
+
route: {
|
|
55
|
+
path: "profile",
|
|
56
|
+
params: queryParams?.userId
|
|
57
|
+
? { userId: String(queryParams.userId) }
|
|
58
|
+
: undefined,
|
|
59
|
+
},
|
|
60
|
+
rawUrl: url,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
case "settings":
|
|
64
|
+
return { route: { path: "settings" }, rawUrl: url };
|
|
65
|
+
|
|
66
|
+
case "post":
|
|
67
|
+
if (queryParams?.id && typeof queryParams.id === "string") {
|
|
68
|
+
return {
|
|
69
|
+
route: { path: "post", params: { postId: queryParams.id } },
|
|
70
|
+
rawUrl: url,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { route: null, rawUrl: url };
|
|
74
|
+
|
|
75
|
+
default: {
|
|
76
|
+
// Try to handle paths like /post/123
|
|
77
|
+
const pathParts = path.split("/");
|
|
78
|
+
if (pathParts[0] === "post" && pathParts[1]) {
|
|
79
|
+
return {
|
|
80
|
+
route: { path: "post", params: { postId: pathParts[1] } },
|
|
81
|
+
rawUrl: url,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (pathParts[0] === "profile" && pathParts[1]) {
|
|
85
|
+
return {
|
|
86
|
+
route: { path: "profile", params: { userId: pathParts[1] } },
|
|
87
|
+
rawUrl: url,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { route: null, rawUrl: url };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error("Failed to parse deep link:", error);
|
|
95
|
+
return { route: null, rawUrl: url };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Navigate to a deep link route
|
|
101
|
+
*/
|
|
102
|
+
function navigateToRoute(route: DeepLinkRoute): void {
|
|
103
|
+
switch (route.path) {
|
|
104
|
+
case "login":
|
|
105
|
+
router.replace("/(public)/login");
|
|
106
|
+
break;
|
|
107
|
+
|
|
108
|
+
case "register":
|
|
109
|
+
router.replace("/(public)/register");
|
|
110
|
+
break;
|
|
111
|
+
|
|
112
|
+
case "reset-password":
|
|
113
|
+
// Navigate to forgot password with token
|
|
114
|
+
router.push({
|
|
115
|
+
pathname: "/(public)/forgot-password",
|
|
116
|
+
params: { token: route.params.token },
|
|
117
|
+
});
|
|
118
|
+
break;
|
|
119
|
+
|
|
120
|
+
case "profile":
|
|
121
|
+
if (route.params?.userId) {
|
|
122
|
+
// Navigate to specific user profile
|
|
123
|
+
router.push({
|
|
124
|
+
pathname: "/(auth)/profile",
|
|
125
|
+
params: { userId: route.params.userId },
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
// Navigate to own profile
|
|
129
|
+
router.push("/(auth)/profile");
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
|
|
133
|
+
case "settings":
|
|
134
|
+
router.push("/(auth)/settings");
|
|
135
|
+
break;
|
|
136
|
+
|
|
137
|
+
case "post":
|
|
138
|
+
// You'll need to create this route
|
|
139
|
+
router.push({
|
|
140
|
+
pathname: "/(auth)/post/[id]" as const,
|
|
141
|
+
params: { id: route.params.postId },
|
|
142
|
+
} as Parameters<typeof router.push>[0]);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface UseDeepLinkingOptions {
|
|
148
|
+
/**
|
|
149
|
+
* Called when a deep link is received but couldn't be parsed
|
|
150
|
+
*/
|
|
151
|
+
onUnknownLink?: (url: string) => void;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Called before navigating to allow custom handling
|
|
155
|
+
* Return false to prevent default navigation
|
|
156
|
+
*/
|
|
157
|
+
onBeforeNavigate?: (route: DeepLinkRoute) => boolean | void;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether deep linking is enabled
|
|
161
|
+
* @default true
|
|
162
|
+
*/
|
|
163
|
+
enabled?: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Hook to handle deep links in your app
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* ```tsx
|
|
171
|
+
* function App() {
|
|
172
|
+
* useDeepLinking({
|
|
173
|
+
* onUnknownLink: (url) => console.log('Unknown link:', url),
|
|
174
|
+
* onBeforeNavigate: (route) => {
|
|
175
|
+
* // Custom validation before navigating
|
|
176
|
+
* if (route.path === 'profile' && !isAuthenticated) {
|
|
177
|
+
* router.push('/login');
|
|
178
|
+
* return false; // Prevent default navigation
|
|
179
|
+
* }
|
|
180
|
+
* },
|
|
181
|
+
* });
|
|
182
|
+
*
|
|
183
|
+
* return <App />;
|
|
184
|
+
* }
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
export function useDeepLinking(options: UseDeepLinkingOptions = {}): void {
|
|
188
|
+
const { onUnknownLink, onBeforeNavigate, enabled = true } = options;
|
|
189
|
+
|
|
190
|
+
const handleDeepLink = useCallback(
|
|
191
|
+
(event: { url: string }) => {
|
|
192
|
+
if (!enabled) return;
|
|
193
|
+
|
|
194
|
+
const { route, rawUrl } = parseDeepLink(event.url);
|
|
195
|
+
|
|
196
|
+
if (!route) {
|
|
197
|
+
onUnknownLink?.(rawUrl);
|
|
198
|
+
console.log("Unknown deep link:", rawUrl);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Allow custom handling before navigation
|
|
203
|
+
const shouldNavigate = onBeforeNavigate?.(route);
|
|
204
|
+
if (shouldNavigate === false) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
navigateToRoute(route);
|
|
209
|
+
},
|
|
210
|
+
[enabled, onUnknownLink, onBeforeNavigate]
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
if (!enabled) return;
|
|
215
|
+
|
|
216
|
+
// Handle deep links when app is already open
|
|
217
|
+
const subscription = Linking.addEventListener("url", handleDeepLink);
|
|
218
|
+
|
|
219
|
+
// Handle deep link that opened the app
|
|
220
|
+
Linking.getInitialURL().then((url) => {
|
|
221
|
+
if (url) {
|
|
222
|
+
handleDeepLink({ url });
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return () => {
|
|
227
|
+
subscription.remove();
|
|
228
|
+
};
|
|
229
|
+
}, [enabled, handleDeepLink]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get the app's deep link URL prefix
|
|
234
|
+
*/
|
|
235
|
+
export function getDeepLinkPrefix(): string {
|
|
236
|
+
return Linking.createURL("/");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Create a deep link URL for the app
|
|
241
|
+
*
|
|
242
|
+
* @example
|
|
243
|
+
* ```ts
|
|
244
|
+
* const url = createDeepLink('profile', { userId: '123' });
|
|
245
|
+
* // Returns: yourapp://profile?userId=123
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
export function createDeepLink(
|
|
249
|
+
path: string,
|
|
250
|
+
params?: Record<string, string>
|
|
251
|
+
): string {
|
|
252
|
+
return Linking.createURL(path, { queryParams: params });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export { parseDeepLink, navigateToRoute };
|
|
256
|
+
export type { DeepLinkRoute, ParsedDeepLink };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview React hook for A/B test experiment variants
|
|
3
|
+
* Returns the assigned variant for a given experiment, backed by the
|
|
4
|
+
* FeatureFlags facade.
|
|
5
|
+
* @module hooks/useExperiment
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect } from "react";
|
|
9
|
+
import { FeatureFlags } from "@/services/feature-flags/feature-flag-adapter";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hook that resolves the assigned variant for an A/B test experiment.
|
|
13
|
+
*
|
|
14
|
+
* @param experimentId - The experiment identifier
|
|
15
|
+
* @returns `{ variant, isLoading }`
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* const { variant, isLoading } = useExperiment("onboarding_flow");
|
|
20
|
+
*
|
|
21
|
+
* if (isLoading) return <Loader />;
|
|
22
|
+
* if (variant === "variant_a") return <OnboardingA />;
|
|
23
|
+
* return <OnboardingControl />;
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function useExperiment(experimentId: string) {
|
|
27
|
+
const [variant, setVariant] = useState<string | null>(null);
|
|
28
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
setVariant(FeatureFlags.getExperimentVariant(experimentId));
|
|
32
|
+
setIsLoading(false);
|
|
33
|
+
}, [experimentId]);
|
|
34
|
+
|
|
35
|
+
return { variant, isLoading };
|
|
36
|
+
}
|