@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,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Paywall component for displaying subscription products
|
|
3
|
+
* Shows available products, feature highlights, and a restore purchases link.
|
|
4
|
+
* @module components/ui/Paywall
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { View, Text, Pressable, ScrollView } from "react-native";
|
|
8
|
+
import { useTranslation } from "react-i18next";
|
|
9
|
+
import { cn } from "@/utils/cn";
|
|
10
|
+
import { useProducts } from "@/hooks/useProducts";
|
|
11
|
+
import { usePurchase } from "@/hooks/usePurchase";
|
|
12
|
+
import { Skeleton, SkeletonText } from "@/components/ui/Skeleton";
|
|
13
|
+
import { Button } from "@/components/ui/Button";
|
|
14
|
+
import type { Product, Purchase } from "@/services/payments/types";
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Props
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
interface PaywallProps {
|
|
21
|
+
/** Product IDs to display (must match store product IDs) */
|
|
22
|
+
productIds: string[];
|
|
23
|
+
/** Optional list of feature descriptions to display */
|
|
24
|
+
features?: string[];
|
|
25
|
+
/** Paywall title */
|
|
26
|
+
title?: string;
|
|
27
|
+
/** Paywall subtitle */
|
|
28
|
+
subtitle?: string;
|
|
29
|
+
/** Called after a successful purchase */
|
|
30
|
+
onPurchaseSuccess?: (purchase: Purchase) => void;
|
|
31
|
+
/** Called after a successful restore */
|
|
32
|
+
onRestore?: (purchases: Purchase[]) => void;
|
|
33
|
+
/** Additional className for the outer container */
|
|
34
|
+
className?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Sub-components
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
/** Loading skeleton shown while products are being fetched */
|
|
42
|
+
function PaywallSkeleton() {
|
|
43
|
+
return (
|
|
44
|
+
<View className="gap-4 px-6">
|
|
45
|
+
<Skeleton height={28} width="60%" />
|
|
46
|
+
<SkeletonText width="80%" />
|
|
47
|
+
<View className="mt-4 gap-3">
|
|
48
|
+
<Skeleton height={120} borderRadius={16} />
|
|
49
|
+
<Skeleton height={120} borderRadius={16} />
|
|
50
|
+
</View>
|
|
51
|
+
</View>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Feature list item with a checkmark icon */
|
|
56
|
+
function FeatureItem({ text }: { text: string }) {
|
|
57
|
+
return (
|
|
58
|
+
<View className="flex-row items-center gap-3">
|
|
59
|
+
<View className="h-6 w-6 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
|
|
60
|
+
<Text className="text-sm text-green-600 dark:text-green-400">
|
|
61
|
+
{"\u2713"}
|
|
62
|
+
</Text>
|
|
63
|
+
</View>
|
|
64
|
+
<Text className="flex-1 text-base text-text-light dark:text-text-dark">
|
|
65
|
+
{text}
|
|
66
|
+
</Text>
|
|
67
|
+
</View>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Card displaying a single product option */
|
|
72
|
+
function ProductCard({
|
|
73
|
+
product,
|
|
74
|
+
onPress,
|
|
75
|
+
isLoading,
|
|
76
|
+
}: {
|
|
77
|
+
product: Product;
|
|
78
|
+
onPress: () => void;
|
|
79
|
+
isLoading: boolean;
|
|
80
|
+
}) {
|
|
81
|
+
const isYearly = product.subscriptionPeriod === "yearly";
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<Pressable
|
|
85
|
+
onPress={onPress}
|
|
86
|
+
disabled={isLoading}
|
|
87
|
+
className={cn(
|
|
88
|
+
"rounded-2xl border-2 p-4",
|
|
89
|
+
isYearly
|
|
90
|
+
? "border-primary-600 bg-primary-50 dark:bg-primary-950"
|
|
91
|
+
: "border-gray-200 bg-surface-light dark:border-gray-700 dark:bg-surface-dark"
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
{isYearly && (
|
|
95
|
+
<View className="mb-2 self-start rounded-full bg-primary-600 px-3 py-1">
|
|
96
|
+
<Text className="text-xs font-bold text-white">BEST VALUE</Text>
|
|
97
|
+
</View>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
<Text className="text-lg font-bold text-text-light dark:text-text-dark">
|
|
101
|
+
{product.title}
|
|
102
|
+
</Text>
|
|
103
|
+
|
|
104
|
+
<Text className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
105
|
+
{product.description}
|
|
106
|
+
</Text>
|
|
107
|
+
|
|
108
|
+
<View className="mt-3 flex-row items-baseline gap-1">
|
|
109
|
+
<Text className="text-2xl font-bold text-primary-600 dark:text-primary-400">
|
|
110
|
+
{product.priceString}
|
|
111
|
+
</Text>
|
|
112
|
+
{product.subscriptionPeriod && (
|
|
113
|
+
<Text className="text-sm text-gray-500 dark:text-gray-400">
|
|
114
|
+
/{product.subscriptionPeriod === "monthly" ? "mo" : "yr"}
|
|
115
|
+
</Text>
|
|
116
|
+
)}
|
|
117
|
+
</View>
|
|
118
|
+
|
|
119
|
+
<Button className="mt-3" isLoading={isLoading} onPress={onPress}>
|
|
120
|
+
{isYearly ? "Subscribe & Save" : "Subscribe"}
|
|
121
|
+
</Button>
|
|
122
|
+
</Pressable>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ============================================================================
|
|
127
|
+
// Paywall Component
|
|
128
|
+
// ============================================================================
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Full-screen paywall component for presenting subscription options.
|
|
132
|
+
*
|
|
133
|
+
* Features:
|
|
134
|
+
* - Loads products from the payment adapter via useProducts
|
|
135
|
+
* - Shows a loading skeleton while products are being fetched
|
|
136
|
+
* - Displays a features list with checkmark icons
|
|
137
|
+
* - Maps products to selectable ProductCard sub-components
|
|
138
|
+
* - Includes a "Restore Purchases" link at the bottom
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```tsx
|
|
142
|
+
* <Paywall
|
|
143
|
+
* productIds={["premium_monthly", "premium_yearly"]}
|
|
144
|
+
* title="Go Premium"
|
|
145
|
+
* subtitle="Unlock all features and remove ads"
|
|
146
|
+
* features={["Unlimited access", "No ads", "Priority support"]}
|
|
147
|
+
* onPurchaseSuccess={(purchase) => router.back()}
|
|
148
|
+
* />
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
export function Paywall({
|
|
152
|
+
productIds,
|
|
153
|
+
features,
|
|
154
|
+
title,
|
|
155
|
+
subtitle,
|
|
156
|
+
onPurchaseSuccess,
|
|
157
|
+
onRestore,
|
|
158
|
+
className,
|
|
159
|
+
}: PaywallProps) {
|
|
160
|
+
const { t } = useTranslation();
|
|
161
|
+
const resolvedTitle = title ?? t("payments.upgradeToPremium");
|
|
162
|
+
const resolvedSubtitle =
|
|
163
|
+
subtitle ??
|
|
164
|
+
"Unlock all features and take your experience to the next level.";
|
|
165
|
+
const { data: products, isLoading: productsLoading } =
|
|
166
|
+
useProducts(productIds);
|
|
167
|
+
const { purchase, restore, isLoading: purchaseLoading } = usePurchase();
|
|
168
|
+
|
|
169
|
+
const handlePurchase = async (productId: string) => {
|
|
170
|
+
const result = await purchase(productId);
|
|
171
|
+
if (result) {
|
|
172
|
+
onPurchaseSuccess?.(result);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const handleRestore = async () => {
|
|
177
|
+
const restored = await restore();
|
|
178
|
+
if (restored.length > 0) {
|
|
179
|
+
onRestore?.(restored);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// Loading state
|
|
184
|
+
if (productsLoading) {
|
|
185
|
+
return <PaywallSkeleton />;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<ScrollView
|
|
190
|
+
className={cn("flex-1", className)}
|
|
191
|
+
contentContainerClassName="px-6 py-8"
|
|
192
|
+
showsVerticalScrollIndicator={false}
|
|
193
|
+
>
|
|
194
|
+
{/* Header */}
|
|
195
|
+
<View className="mb-6">
|
|
196
|
+
<Text className="text-2xl font-bold text-text-light dark:text-text-dark">
|
|
197
|
+
{resolvedTitle}
|
|
198
|
+
</Text>
|
|
199
|
+
<Text className="mt-2 text-base text-gray-500 dark:text-gray-400">
|
|
200
|
+
{resolvedSubtitle}
|
|
201
|
+
</Text>
|
|
202
|
+
</View>
|
|
203
|
+
|
|
204
|
+
{/* Features */}
|
|
205
|
+
{features && features.length > 0 && (
|
|
206
|
+
<View className="mb-6 gap-3">
|
|
207
|
+
{features.map((feature) => (
|
|
208
|
+
<FeatureItem key={feature} text={feature} />
|
|
209
|
+
))}
|
|
210
|
+
</View>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{/* Products */}
|
|
214
|
+
<View className="gap-4">
|
|
215
|
+
{products?.map((product) => (
|
|
216
|
+
<ProductCard
|
|
217
|
+
key={product.id}
|
|
218
|
+
product={product}
|
|
219
|
+
onPress={() => handlePurchase(product.id)}
|
|
220
|
+
isLoading={purchaseLoading}
|
|
221
|
+
/>
|
|
222
|
+
))}
|
|
223
|
+
</View>
|
|
224
|
+
|
|
225
|
+
{/* Empty state */}
|
|
226
|
+
{(!products || products.length === 0) && !productsLoading && (
|
|
227
|
+
<View className="items-center py-8">
|
|
228
|
+
<Text className="text-base text-gray-500 dark:text-gray-400">
|
|
229
|
+
No products available at the moment.
|
|
230
|
+
</Text>
|
|
231
|
+
</View>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
{/* Restore purchases */}
|
|
235
|
+
<Pressable
|
|
236
|
+
onPress={handleRestore}
|
|
237
|
+
disabled={purchaseLoading}
|
|
238
|
+
className="mt-6 items-center py-3"
|
|
239
|
+
>
|
|
240
|
+
<Text
|
|
241
|
+
className={cn(
|
|
242
|
+
"text-sm",
|
|
243
|
+
purchaseLoading
|
|
244
|
+
? "text-gray-400 dark:text-gray-600"
|
|
245
|
+
: "text-primary-600 dark:text-primary-400"
|
|
246
|
+
)}
|
|
247
|
+
>
|
|
248
|
+
{t("payments.restore")}
|
|
249
|
+
</Text>
|
|
250
|
+
</Pressable>
|
|
251
|
+
</ScrollView>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Permission-gated content component
|
|
3
|
+
* Renders children only when a specific permission is granted,
|
|
4
|
+
* otherwise shows a configurable permission request UI.
|
|
5
|
+
* @module components/ui/PermissionGate
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ReactNode } from "react";
|
|
9
|
+
import { View, Text, ActivityIndicator } from "react-native";
|
|
10
|
+
import { Ionicons } from "@expo/vector-icons";
|
|
11
|
+
import { useTranslation } from "react-i18next";
|
|
12
|
+
|
|
13
|
+
import { usePermission } from "@/hooks/usePermission";
|
|
14
|
+
import { useTheme } from "@/hooks";
|
|
15
|
+
import { Button } from "@/components/ui/Button";
|
|
16
|
+
import type {
|
|
17
|
+
PermissionType,
|
|
18
|
+
PermissionConfig,
|
|
19
|
+
} from "@/services/permissions/types";
|
|
20
|
+
|
|
21
|
+
interface PermissionGateProps {
|
|
22
|
+
/** The permission type required to show children */
|
|
23
|
+
type: PermissionType;
|
|
24
|
+
/** Optional custom permission config to override defaults */
|
|
25
|
+
config?: Partial<PermissionConfig>;
|
|
26
|
+
/** Content to render when permission is granted */
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
/** Custom fallback to render when permission is not granted */
|
|
29
|
+
fallback?: ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Component that gates content behind a permission check.
|
|
34
|
+
* Shows children when the permission is granted, otherwise displays
|
|
35
|
+
* a permission request UI (or a custom fallback).
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* // Basic usage
|
|
40
|
+
* <PermissionGate type="camera">
|
|
41
|
+
* <CameraView />
|
|
42
|
+
* </PermissionGate>
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```tsx
|
|
47
|
+
* // With custom config
|
|
48
|
+
* <PermissionGate
|
|
49
|
+
* type="location"
|
|
50
|
+
* config={{
|
|
51
|
+
* title: "Find Nearby Places",
|
|
52
|
+
* message: "Allow location access to discover restaurants near you.",
|
|
53
|
+
* }}
|
|
54
|
+
* >
|
|
55
|
+
* <MapView />
|
|
56
|
+
* </PermissionGate>
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```tsx
|
|
61
|
+
* // With custom fallback
|
|
62
|
+
* <PermissionGate
|
|
63
|
+
* type="notifications"
|
|
64
|
+
* fallback={<Text>Notifications are disabled</Text>}
|
|
65
|
+
* >
|
|
66
|
+
* <NotificationsList />
|
|
67
|
+
* </PermissionGate>
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function PermissionGate({
|
|
71
|
+
type,
|
|
72
|
+
config: customConfig,
|
|
73
|
+
children,
|
|
74
|
+
fallback,
|
|
75
|
+
}: PermissionGateProps) {
|
|
76
|
+
const { isGranted, isBlocked, isLoading, config, request, openSettings } =
|
|
77
|
+
usePermission(type, customConfig);
|
|
78
|
+
const { isDark } = useTheme();
|
|
79
|
+
const { t } = useTranslation();
|
|
80
|
+
|
|
81
|
+
// Show loading state while checking permission
|
|
82
|
+
if (isLoading) {
|
|
83
|
+
return (
|
|
84
|
+
<View className="flex-1 items-center justify-center bg-background-light dark:bg-background-dark">
|
|
85
|
+
<ActivityIndicator
|
|
86
|
+
size="large"
|
|
87
|
+
color={isDark ? "#f8fafc" : "#0f172a"}
|
|
88
|
+
/>
|
|
89
|
+
</View>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Permission is granted — render children
|
|
94
|
+
if (isGranted) {
|
|
95
|
+
return <>{children}</>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Permission not granted — show fallback or default UI
|
|
99
|
+
if (fallback) {
|
|
100
|
+
return <>{fallback}</>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<View className="flex-1 items-center justify-center px-8 bg-background-light dark:bg-background-dark">
|
|
105
|
+
<View className="w-full items-center rounded-2xl bg-surface-light dark:bg-surface-dark p-8">
|
|
106
|
+
{/* Icon */}
|
|
107
|
+
<View className="mb-6 h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
|
|
108
|
+
<Ionicons
|
|
109
|
+
name={config.icon as keyof typeof Ionicons.glyphMap}
|
|
110
|
+
size={36}
|
|
111
|
+
color={isDark ? "#34d399" : "#059669"}
|
|
112
|
+
/>
|
|
113
|
+
</View>
|
|
114
|
+
|
|
115
|
+
{/* Title */}
|
|
116
|
+
<Text className="mb-3 text-center text-xl font-bold text-text-light dark:text-text-dark">
|
|
117
|
+
{config.title}
|
|
118
|
+
</Text>
|
|
119
|
+
|
|
120
|
+
{/* Message */}
|
|
121
|
+
<Text className="mb-8 text-center text-base leading-6 text-muted-light dark:text-muted-dark">
|
|
122
|
+
{config.message}
|
|
123
|
+
</Text>
|
|
124
|
+
|
|
125
|
+
{/* Action Button */}
|
|
126
|
+
{isBlocked ? (
|
|
127
|
+
<Button
|
|
128
|
+
variant="primary"
|
|
129
|
+
size="lg"
|
|
130
|
+
className="w-full"
|
|
131
|
+
onPress={openSettings}
|
|
132
|
+
>
|
|
133
|
+
{t("permissions.openSettings")}
|
|
134
|
+
</Button>
|
|
135
|
+
) : (
|
|
136
|
+
<Button
|
|
137
|
+
variant="primary"
|
|
138
|
+
size="lg"
|
|
139
|
+
className="w-full"
|
|
140
|
+
onPress={request}
|
|
141
|
+
>
|
|
142
|
+
{t("permissions.allowAccess")}
|
|
143
|
+
</Button>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{/* Secondary hint for blocked state */}
|
|
147
|
+
{isBlocked && (
|
|
148
|
+
<Text className="mt-4 text-center text-sm text-muted-light dark:text-muted-dark">
|
|
149
|
+
Permission was denied. Please enable it in your device settings.
|
|
150
|
+
</Text>
|
|
151
|
+
)}
|
|
152
|
+
</View>
|
|
153
|
+
</View>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Standalone purchase button component
|
|
3
|
+
* Wraps the Button component with purchase flow logic.
|
|
4
|
+
* @module components/ui/PurchaseButton
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback } from "react";
|
|
8
|
+
import { Button } from "@/components/ui/Button";
|
|
9
|
+
import { usePurchase } from "@/hooks/usePurchase";
|
|
10
|
+
import type { Purchase } from "@/services/payments/types";
|
|
11
|
+
import type { PressableProps } from "react-native";
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Props
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
interface PurchaseButtonProps extends Omit<
|
|
18
|
+
PressableProps,
|
|
19
|
+
"onPress" | "children"
|
|
20
|
+
> {
|
|
21
|
+
/** The product ID to purchase */
|
|
22
|
+
productId: string;
|
|
23
|
+
/** Button label text */
|
|
24
|
+
label?: string;
|
|
25
|
+
/** Called after a successful purchase */
|
|
26
|
+
onSuccess?: (purchase: Purchase) => void;
|
|
27
|
+
/** Called when a purchase fails */
|
|
28
|
+
onError?: (error: Error) => void;
|
|
29
|
+
/** Additional className for the button */
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// Component
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A button that initiates an in-app purchase when pressed.
|
|
39
|
+
* Delegates rendering to the Button component and handles loading state
|
|
40
|
+
* automatically via the usePurchase hook.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```tsx
|
|
44
|
+
* <PurchaseButton
|
|
45
|
+
* productId="premium_monthly"
|
|
46
|
+
* label="Subscribe Now"
|
|
47
|
+
* onSuccess={(purchase) => console.log("Purchased!", purchase.id)}
|
|
48
|
+
* onError={(error) => Alert.alert("Error", error.message)}
|
|
49
|
+
* />
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function PurchaseButton({
|
|
53
|
+
productId,
|
|
54
|
+
label = "Subscribe",
|
|
55
|
+
onSuccess,
|
|
56
|
+
onError,
|
|
57
|
+
className,
|
|
58
|
+
...props
|
|
59
|
+
}: PurchaseButtonProps) {
|
|
60
|
+
const { purchase, isLoading, error } = usePurchase();
|
|
61
|
+
|
|
62
|
+
const handlePress = useCallback(async () => {
|
|
63
|
+
const result = await purchase(productId);
|
|
64
|
+
|
|
65
|
+
if (result) {
|
|
66
|
+
onSuccess?.(result);
|
|
67
|
+
} else if (error) {
|
|
68
|
+
// Only call onError when the hook recorded an actual error.
|
|
69
|
+
// A null result without an error means the user cancelled.
|
|
70
|
+
onError?.(error);
|
|
71
|
+
}
|
|
72
|
+
}, [productId, purchase, onSuccess, onError, error]);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Button
|
|
76
|
+
onPress={handlePress}
|
|
77
|
+
isLoading={isLoading}
|
|
78
|
+
className={className}
|
|
79
|
+
{...props}
|
|
80
|
+
>
|
|
81
|
+
{label}
|
|
82
|
+
</Button>
|
|
83
|
+
);
|
|
84
|
+
}
|