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