@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.
- package/dist/SegmentedControl.d.ts +71 -0
- package/dist/SegmentedControl.d.ts.map +1 -0
- package/dist/SubscriptionLayout.d.ts +95 -0
- package/dist/SubscriptionLayout.d.ts.map +1 -0
- package/dist/SubscriptionProvider.d.ts +33 -0
- package/dist/SubscriptionProvider.d.ts.map +1 -0
- package/dist/SubscriptionTile.d.ts +64 -0
- package/dist/SubscriptionTile.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1629 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1629 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types.d.ts +199 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +64 -0
- package/src/SegmentedControl.tsx +221 -0
- package/src/SubscriptionLayout.tsx +379 -0
- package/src/SubscriptionProvider.tsx +257 -0
- package/src/SubscriptionTile.tsx +390 -0
- package/src/__tests__/SegmentedControl.test.tsx +182 -0
- package/src/__tests__/SubscriptionLayout.test.tsx +312 -0
- package/src/__tests__/SubscriptionProvider.test.tsx +180 -0
- package/src/__tests__/SubscriptionTile.test.tsx +248 -0
- package/src/index.ts +53 -0
- package/src/nativewind.d.ts +24 -0
- package/src/types.ts +214 -0
|
@@ -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;
|