@sudobility/subscription-components-rn 1.0.1

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.
@@ -0,0 +1,257 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ useCallback,
6
+ type ReactNode,
7
+ } from 'react';
8
+ import type {
9
+ SubscriptionProduct,
10
+ SubscriptionStatus,
11
+ SubscriptionContextValue,
12
+ SubscriptionProviderConfig,
13
+ } from './types';
14
+
15
+ const SubscriptionContext = createContext<SubscriptionContextValue | undefined>(
16
+ undefined
17
+ );
18
+
19
+ export interface SubscriptionProviderProps extends SubscriptionProviderConfig {
20
+ children: ReactNode;
21
+ }
22
+
23
+ /**
24
+ * SubscriptionProvider - Context provider for subscription management
25
+ *
26
+ * Provides subscription state and actions to all child components.
27
+ * Handles RevenueCat SDK initialization, product fetching, and purchase flow.
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * <SubscriptionProvider
32
+ * apiKey="your_revenuecat_api_key"
33
+ * onError={(error) => console.error(error)}
34
+ * onPurchaseSuccess={(productId) => analytics.track('purchase', { productId })}
35
+ * >
36
+ * <App />
37
+ * </SubscriptionProvider>
38
+ * ```
39
+ */
40
+ export function SubscriptionProvider({
41
+ apiKey,
42
+ userEmail,
43
+ onError,
44
+ onPurchaseSuccess,
45
+ children,
46
+ }: SubscriptionProviderProps) {
47
+ const [products, setProducts] = useState<SubscriptionProduct[]>([]);
48
+ const [currentSubscription, setCurrentSubscription] =
49
+ useState<SubscriptionStatus | null>(null);
50
+ const [isLoading, setIsLoading] = useState(false);
51
+ const [error, setError] = useState<string | null>(null);
52
+ const [isInitialized, setIsInitialized] = useState(false);
53
+
54
+ const isDevelopment = !apiKey || apiKey === 'your_revenuecat_api_key_here';
55
+
56
+ /**
57
+ * Initialize RevenueCat with optional user ID.
58
+ * If userId is undefined, fetches offerings for anonymous browsing.
59
+ * If userId is provided, also fetches customer subscription info.
60
+ */
61
+ const initialize = useCallback(
62
+ async (userId?: string, email?: string) => {
63
+ if (isInitialized) return;
64
+
65
+ try {
66
+ setIsLoading(true);
67
+ setError(null);
68
+
69
+ if (isDevelopment) {
70
+ console.warn(
71
+ '[SubscriptionProvider] RevenueCat API key not configured'
72
+ );
73
+ setProducts([]);
74
+ setCurrentSubscription(null);
75
+ } else {
76
+ // Placeholder for react-native-purchases integration
77
+ // In actual implementation:
78
+ // import Purchases from 'react-native-purchases';
79
+ // Purchases.configure({ apiKey });
80
+ // if (userId) {
81
+ // await Purchases.logIn(userId);
82
+ // }
83
+ // const offerings = await Purchases.getOfferings();
84
+ // const customerInfo = await Purchases.getCustomerInfo();
85
+ // Convert offerings to SubscriptionProduct[]
86
+ // Convert customerInfo to SubscriptionStatus
87
+ void userId;
88
+ void email;
89
+ void userEmail;
90
+ }
91
+
92
+ setIsInitialized(true);
93
+ } catch (err) {
94
+ const errorMsg =
95
+ err instanceof Error ? err.message : 'Failed to initialize';
96
+ setError(errorMsg);
97
+ setCurrentSubscription(null);
98
+ setProducts([]);
99
+ onError?.(err instanceof Error ? err : new Error(errorMsg));
100
+ } finally {
101
+ setIsLoading(false);
102
+ }
103
+ },
104
+ [isInitialized, isDevelopment, userEmail, onError]
105
+ );
106
+
107
+ /**
108
+ * Purchase a subscription
109
+ * @param productIdentifier - The product/package identifier to purchase
110
+ * @param _subscriptionUserId - Optional user ID (for reference)
111
+ */
112
+ const purchase = useCallback(
113
+ async (
114
+ productIdentifier: string,
115
+ _subscriptionUserId?: string
116
+ ): Promise<boolean> => {
117
+ try {
118
+ setIsLoading(true);
119
+ setError(null);
120
+
121
+ if (isDevelopment) {
122
+ // Simulate purchase in development
123
+ await new Promise<void>(resolve => setTimeout(resolve, 2000));
124
+ const mockSubscription: SubscriptionStatus = {
125
+ isActive: true,
126
+ expirationDate: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
127
+ purchaseDate: new Date(),
128
+ productIdentifier,
129
+ willRenew: true,
130
+ };
131
+ setCurrentSubscription(mockSubscription);
132
+ onPurchaseSuccess?.(productIdentifier);
133
+ return true;
134
+ }
135
+
136
+ // Placeholder for react-native-purchases integration
137
+ // In actual implementation:
138
+ // import Purchases from 'react-native-purchases';
139
+ // const offerings = await Purchases.getOfferings();
140
+ // Find the package, then:
141
+ // const { customerInfo } = await Purchases.purchasePackage(package);
142
+ // const status = parseCustomerInfo(customerInfo);
143
+ // setCurrentSubscription(status.isActive ? status : null);
144
+
145
+ return false;
146
+ } catch (err) {
147
+ const errorMsg = err instanceof Error ? err.message : 'Purchase failed';
148
+ setError(errorMsg);
149
+ onError?.(err instanceof Error ? err : new Error(errorMsg));
150
+ return false;
151
+ } finally {
152
+ setIsLoading(false);
153
+ }
154
+ },
155
+ [isDevelopment, onPurchaseSuccess, onError]
156
+ );
157
+
158
+ /**
159
+ * Restore previous purchases
160
+ * @param _subscriptionUserId - Optional user ID (for reference)
161
+ */
162
+ const restore = useCallback(
163
+ async (_subscriptionUserId?: string): Promise<boolean> => {
164
+ try {
165
+ setIsLoading(true);
166
+ setError(null);
167
+
168
+ if (isDevelopment) {
169
+ await new Promise<void>(resolve => setTimeout(resolve, 1000));
170
+ setError('No previous purchases found');
171
+ return false;
172
+ }
173
+
174
+ // Placeholder for react-native-purchases integration
175
+ // In actual implementation:
176
+ // import Purchases from 'react-native-purchases';
177
+ // const customerInfo = await Purchases.restorePurchases();
178
+ // const status = parseCustomerInfo(customerInfo);
179
+ // setCurrentSubscription(status.isActive ? status : null);
180
+
181
+ return false;
182
+ } catch (err) {
183
+ const errorMsg = err instanceof Error ? err.message : 'Restore failed';
184
+ setError(errorMsg);
185
+ onError?.(err instanceof Error ? err : new Error(errorMsg));
186
+ return false;
187
+ } finally {
188
+ setIsLoading(false);
189
+ }
190
+ },
191
+ [isDevelopment, onError]
192
+ );
193
+
194
+ /**
195
+ * Refresh subscription status
196
+ */
197
+ const refresh = useCallback(async () => {
198
+ if (isDevelopment) return;
199
+
200
+ try {
201
+ setError(null);
202
+ // Placeholder for react-native-purchases integration
203
+ // In actual implementation:
204
+ // import Purchases from 'react-native-purchases';
205
+ // const customerInfo = await Purchases.getCustomerInfo();
206
+ // const offerings = await Purchases.getOfferings();
207
+ } catch (err) {
208
+ const errorMsg = err instanceof Error ? err.message : 'Refresh failed';
209
+ setError(errorMsg);
210
+ onError?.(err instanceof Error ? err : new Error(errorMsg));
211
+ }
212
+ }, [isDevelopment, onError]);
213
+
214
+ /**
215
+ * Clear error state
216
+ */
217
+ const clearError = useCallback(() => {
218
+ setError(null);
219
+ }, []);
220
+
221
+ const value: SubscriptionContextValue = {
222
+ products,
223
+ currentSubscription,
224
+ isLoading,
225
+ error,
226
+ initialize,
227
+ purchase,
228
+ restore,
229
+ refresh,
230
+ clearError,
231
+ };
232
+
233
+ return (
234
+ <SubscriptionContext.Provider value={value}>
235
+ {children}
236
+ </SubscriptionContext.Provider>
237
+ );
238
+ }
239
+
240
+ /**
241
+ * Hook to access subscription context
242
+ *
243
+ * @throws Error if used outside of SubscriptionProvider
244
+ */
245
+ export function useSubscriptionContext(): SubscriptionContextValue {
246
+ const context = useContext(SubscriptionContext);
247
+ if (!context) {
248
+ throw new Error(
249
+ 'useSubscriptionContext must be used within a SubscriptionProvider'
250
+ );
251
+ }
252
+ return context;
253
+ }
254
+
255
+ export { SubscriptionContext };
256
+
257
+ export default SubscriptionProvider;
@@ -0,0 +1,390 @@
1
+ import type { ReactNode } from 'react';
2
+ import { View, Text, Pressable } from 'react-native';
3
+ import type {
4
+ BadgeConfig,
5
+ CtaButtonConfig,
6
+ DiscountBadgeConfig,
7
+ PremiumCalloutConfig,
8
+ SubscriptionTileTrackingData,
9
+ } from './types';
10
+
11
+ export interface SubscriptionTileProps {
12
+ /** Unique identifier for the subscription */
13
+ id: string;
14
+ /** Plan title */
15
+ title: string;
16
+ /** Formatted price string (e.g., "$9.99") */
17
+ price: string;
18
+ /** Period label (e.g., "/month", "/year") - passed by consumer for localization */
19
+ periodLabel?: string;
20
+ /** List of features/benefits */
21
+ features: string[];
22
+ /** Whether this tile is currently selected */
23
+ isSelected: boolean;
24
+ /** Selection callback */
25
+ onSelect: () => void;
26
+ /** Whether this is the user's current plan (shows persistent blue border) */
27
+ isCurrentPlan?: boolean;
28
+
29
+ /** Optional top badge (e.g., "Most Popular", "Free Trial") */
30
+ topBadge?: BadgeConfig;
31
+ /** Optional discount badge (e.g., "Save 40%") */
32
+ discountBadge?: DiscountBadgeConfig;
33
+ /** Optional premium callout section */
34
+ premiumCallout?: PremiumCalloutConfig;
35
+ /** Optional bottom note (e.g., new expiration date) */
36
+ bottomNote?: string;
37
+ /** Optional intro price note */
38
+ introPriceNote?: string;
39
+
40
+ /** Whether this is the best value option (affects styling) */
41
+ isBestValue?: boolean;
42
+ /** CTA button configuration - when provided, renders a button instead of radio indicator */
43
+ ctaButton?: CtaButtonConfig;
44
+ /** Additional NativeWind classes */
45
+ className?: string;
46
+ /** Custom content to render in the content area */
47
+ children?: ReactNode;
48
+ /** Disabled state (prevents interaction but keeps normal appearance) */
49
+ disabled?: boolean;
50
+ /** Whether this tile is enabled/selectable (false = grayed out, no indicator) */
51
+ enabled?: boolean;
52
+
53
+ /** Accessibility label */
54
+ accessibilityLabel?: string;
55
+
56
+ /** Optional tracking callback */
57
+ onTrack?: (data: SubscriptionTileTrackingData) => void;
58
+ /** Optional tracking label */
59
+ trackingLabel?: string;
60
+ /** Optional component name for tracking */
61
+ componentName?: string;
62
+ /** Hide both radio button and CTA button (for free tier tiles) */
63
+ hideSelectionIndicator?: boolean;
64
+ }
65
+
66
+ const BADGE_COLORS: Record<BadgeConfig['color'], string> = {
67
+ purple: 'bg-purple-500',
68
+ green: 'bg-green-500',
69
+ blue: 'bg-blue-500',
70
+ yellow: 'bg-yellow-400',
71
+ red: 'bg-red-500',
72
+ };
73
+
74
+ /**
75
+ * SubscriptionTile - A reusable subscription plan display component
76
+ *
77
+ * Displays a subscription plan with pricing, features, badges, and selection state.
78
+ * All text is passed by the consumer for full localization control.
79
+ *
80
+ * Layout: Uses flexbox with content area (flex-1) and a fixed-height bottom area
81
+ * for button/radio. This ensures no overlap between content and bottom elements.
82
+ */
83
+ export function SubscriptionTile({
84
+ id: _id,
85
+ title,
86
+ price,
87
+ periodLabel,
88
+ features,
89
+ isSelected,
90
+ onSelect,
91
+ isCurrentPlan = false,
92
+ topBadge,
93
+ discountBadge,
94
+ premiumCallout,
95
+ bottomNote,
96
+ introPriceNote,
97
+ isBestValue: _isBestValue = false,
98
+ ctaButton,
99
+ className = '',
100
+ children,
101
+ disabled = false,
102
+ enabled = true,
103
+ accessibilityLabel,
104
+ onTrack,
105
+ trackingLabel,
106
+ componentName = 'SubscriptionTile',
107
+ hideSelectionIndicator = false,
108
+ }: SubscriptionTileProps) {
109
+ // When ctaButton is provided, tile is not selectable (CTA mode)
110
+ const isCtaMode = !!ctaButton;
111
+ // Whether to show any bottom indicator (radio or CTA)
112
+ // Hide indicator when: hideSelectionIndicator, isCurrentPlan (user's current plan), or not enabled
113
+ const showIndicator = !hideSelectionIndicator && !isCurrentPlan && enabled;
114
+ // Whether the tile is interactive (can be pressed/selected)
115
+ const isInteractive = enabled && !isCurrentPlan && !disabled;
116
+
117
+ // Styling logic:
118
+ // - Selected: Blue background with blue border
119
+ // - Current plan (not selected): Blue border to indicate current subscription
120
+ // - Not enabled: Grayed out with opacity-50
121
+ // - Default: Gray background
122
+ const containerClasses = [
123
+ 'relative rounded-2xl p-6',
124
+ isSelected
125
+ ? 'bg-blue-600 border-2 border-blue-600'
126
+ : isCurrentPlan
127
+ ? 'bg-gray-100 dark:bg-gray-800 border-2 border-blue-500 dark:border-blue-400'
128
+ : !enabled
129
+ ? 'bg-gray-100 dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700 opacity-50'
130
+ : 'bg-gray-100 dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700',
131
+ className,
132
+ ]
133
+ .filter(Boolean)
134
+ .join(' ');
135
+
136
+ const handlePress = () => {
137
+ if (isInteractive && !isCtaMode) {
138
+ onTrack?.({ action: 'select', trackingLabel, componentName });
139
+ onSelect();
140
+ }
141
+ };
142
+
143
+ const handleCtaPress = () => {
144
+ onTrack?.({ action: 'cta_click', trackingLabel, componentName });
145
+ ctaButton?.onPress?.();
146
+ };
147
+
148
+ const defaultLabel = `${title} - ${price}${periodLabel || ''}`;
149
+
150
+ return (
151
+ <Pressable
152
+ onPress={handlePress}
153
+ disabled={!isInteractive || isCtaMode}
154
+ accessibilityRole={isCtaMode ? 'summary' : 'radio'}
155
+ accessibilityState={{
156
+ checked: isCtaMode ? undefined : isSelected,
157
+ disabled: !isInteractive,
158
+ }}
159
+ accessibilityLabel={accessibilityLabel || defaultLabel}
160
+ className={containerClasses}
161
+ >
162
+ {/* Top Badge - vertically centered on the top border */}
163
+ {topBadge && (
164
+ <View className='absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10'>
165
+ <View
166
+ className={[
167
+ BADGE_COLORS[topBadge.color],
168
+ 'px-4 py-1.5 rounded-full',
169
+ ].join(' ')}
170
+ >
171
+ <Text className='text-white text-sm font-semibold'>
172
+ {topBadge.text}
173
+ </Text>
174
+ </View>
175
+ </View>
176
+ )}
177
+
178
+ {/* Main content - flex-1 takes available space above the fixed bottom area */}
179
+ <View className='flex-1'>
180
+ {/* Title and Price - add top margin when there's a topBadge */}
181
+ <View
182
+ className={['items-center mb-6', topBadge ? 'mt-2' : '']
183
+ .filter(Boolean)
184
+ .join(' ')}
185
+ >
186
+ <Text
187
+ className={[
188
+ 'text-xl font-bold mb-2',
189
+ isSelected ? 'text-white' : 'text-gray-900 dark:text-gray-100',
190
+ ].join(' ')}
191
+ >
192
+ {title}
193
+ </Text>
194
+ <View className='flex-row items-baseline mb-3'>
195
+ <Text
196
+ className={[
197
+ 'text-4xl font-bold',
198
+ isSelected ? 'text-white' : 'text-gray-900 dark:text-gray-100',
199
+ ].join(' ')}
200
+ >
201
+ {price}
202
+ </Text>
203
+ {periodLabel && (
204
+ <Text
205
+ className={[
206
+ 'text-lg',
207
+ isSelected
208
+ ? 'text-blue-100'
209
+ : 'text-gray-500 dark:text-gray-400',
210
+ ].join(' ')}
211
+ >
212
+ {periodLabel}
213
+ </Text>
214
+ )}
215
+ </View>
216
+
217
+ {/* Discount Badge */}
218
+ {discountBadge && (
219
+ <View
220
+ className={[
221
+ 'px-2 py-1 rounded-full',
222
+ isSelected
223
+ ? 'bg-blue-500'
224
+ : 'bg-green-100 dark:bg-green-900/50',
225
+ ].join(' ')}
226
+ >
227
+ <Text
228
+ className={[
229
+ 'text-sm font-semibold',
230
+ isSelected
231
+ ? 'text-white'
232
+ : 'text-green-700 dark:text-green-300',
233
+ ].join(' ')}
234
+ >
235
+ {discountBadge.text}
236
+ </Text>
237
+ </View>
238
+ )}
239
+ </View>
240
+
241
+ {/* Custom Content Area */}
242
+ {children}
243
+
244
+ {/* Features List - no flex-grow, just takes its natural height */}
245
+ {features.length > 0 && (
246
+ <View className='gap-3 mb-6'>
247
+ {features.map((feature, index) => (
248
+ <View key={index} className='flex-row items-start'>
249
+ <Text
250
+ className={[
251
+ 'mr-3',
252
+ isSelected ? 'text-blue-200' : 'text-green-500',
253
+ ].join(' ')}
254
+ >
255
+
256
+ </Text>
257
+ <Text
258
+ className={[
259
+ 'text-sm flex-1',
260
+ isSelected
261
+ ? 'text-white'
262
+ : 'text-gray-700 dark:text-gray-300',
263
+ ].join(' ')}
264
+ >
265
+ {feature.replace(/^✓\s*/, '')}
266
+ </Text>
267
+ </View>
268
+ ))}
269
+ </View>
270
+ )}
271
+
272
+ {/* Premium Callout */}
273
+ {premiumCallout && (
274
+ <View
275
+ className={[
276
+ 'rounded-lg p-4 mb-4',
277
+ isSelected
278
+ ? 'bg-blue-500/30'
279
+ : 'bg-purple-50 dark:bg-purple-900/20',
280
+ ].join(' ')}
281
+ >
282
+ <Text
283
+ className={[
284
+ 'font-semibold text-sm mb-2',
285
+ isSelected
286
+ ? 'text-white'
287
+ : 'text-purple-600 dark:text-purple-400',
288
+ ].join(' ')}
289
+ >
290
+ {premiumCallout.title}
291
+ </Text>
292
+ {premiumCallout.features.map((feat, idx) => (
293
+ <Text
294
+ key={idx}
295
+ className={[
296
+ 'text-xs',
297
+ isSelected
298
+ ? 'text-blue-100'
299
+ : 'text-gray-600 dark:text-gray-400',
300
+ ].join(' ')}
301
+ >
302
+ • {feat}
303
+ </Text>
304
+ ))}
305
+ </View>
306
+ )}
307
+
308
+ {/* Bottom Note (e.g., new expiration date) */}
309
+ {bottomNote && (
310
+ <Text
311
+ className={[
312
+ 'text-center text-sm font-medium mb-4',
313
+ isSelected ? 'text-blue-100' : 'text-blue-600 dark:text-blue-400',
314
+ ].join(' ')}
315
+ >
316
+ {bottomNote}
317
+ </Text>
318
+ )}
319
+
320
+ {/* Intro Price Banner */}
321
+ {introPriceNote && (
322
+ <View
323
+ className={[
324
+ 'p-3 rounded-lg',
325
+ isSelected
326
+ ? 'bg-blue-500/30'
327
+ : 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800',
328
+ ].join(' ')}
329
+ >
330
+ <Text
331
+ className={[
332
+ 'text-sm font-semibold text-center',
333
+ isSelected
334
+ ? 'text-white'
335
+ : 'text-yellow-700 dark:text-yellow-300',
336
+ ].join(' ')}
337
+ >
338
+ {introPriceNote}
339
+ </Text>
340
+ </View>
341
+ )}
342
+ </View>
343
+
344
+ {/* Fixed-height bottom area - always present to reserve space */}
345
+ <View className='h-14 justify-end items-center'>
346
+ {/* CTA Button */}
347
+ {showIndicator && isCtaMode && (
348
+ <Pressable
349
+ onPress={handleCtaPress}
350
+ disabled={disabled}
351
+ className={[
352
+ 'w-full py-3 rounded-lg items-center',
353
+ isSelected ? 'bg-white' : 'bg-blue-600',
354
+ disabled ? 'opacity-50' : '',
355
+ ]
356
+ .filter(Boolean)
357
+ .join(' ')}
358
+ >
359
+ <Text
360
+ className={[
361
+ 'font-semibold',
362
+ isSelected ? 'text-blue-600' : 'text-white',
363
+ ].join(' ')}
364
+ >
365
+ {ctaButton.label}
366
+ </Text>
367
+ </Pressable>
368
+ )}
369
+
370
+ {/* Radio button indicator */}
371
+ {showIndicator && !isCtaMode && (
372
+ <View
373
+ className={[
374
+ 'w-5 h-5 rounded-full border-2 items-center justify-center',
375
+ isSelected
376
+ ? 'border-white bg-white'
377
+ : 'border-gray-300 dark:border-gray-600',
378
+ ].join(' ')}
379
+ >
380
+ {isSelected && (
381
+ <View className='w-2 h-2 rounded-full bg-blue-600' />
382
+ )}
383
+ </View>
384
+ )}
385
+ </View>
386
+ </Pressable>
387
+ );
388
+ }
389
+
390
+ export default SubscriptionTile;