@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,221 @@
1
+ import { View, Text, Pressable } from 'react-native';
2
+
3
+ export interface SegmentedControlOption<T extends string = string> {
4
+ /** Value for this option */
5
+ value: T;
6
+ /** Display label */
7
+ label: string;
8
+ /** Optional badge text (e.g., "Save 20%") */
9
+ badge?: string;
10
+ /** Whether this option is disabled */
11
+ disabled?: boolean;
12
+ }
13
+
14
+ export interface SegmentedControlProps<T extends string = string> {
15
+ /** Available options */
16
+ options: SegmentedControlOption<T>[];
17
+ /** Currently selected value */
18
+ value: T;
19
+ /** Selection change handler */
20
+ onChange: (value: T) => void;
21
+ /** Additional NativeWind classes for the container */
22
+ className?: string;
23
+ /** Whether the control is disabled */
24
+ disabled?: boolean;
25
+ /** Size variant */
26
+ size?: 'sm' | 'md' | 'lg';
27
+ /** Full width mode */
28
+ fullWidth?: boolean;
29
+ /** Accessibility label */
30
+ accessibilityLabel?: string;
31
+ }
32
+
33
+ /**
34
+ * Size class mapping
35
+ */
36
+ const sizeClasses = {
37
+ sm: {
38
+ container: 'p-1 rounded-lg',
39
+ segment: 'px-3 py-1.5 rounded-md',
40
+ text: 'text-xs',
41
+ badge: 'text-xs px-1.5 py-0.5',
42
+ },
43
+ md: {
44
+ container: 'p-1 rounded-lg',
45
+ segment: 'px-4 py-2 rounded-md',
46
+ text: 'text-sm',
47
+ badge: 'text-xs px-2 py-0.5',
48
+ },
49
+ lg: {
50
+ container: 'p-1 rounded-lg',
51
+ segment: 'px-6 py-3 rounded-lg',
52
+ text: 'text-base',
53
+ badge: 'text-sm px-2 py-1',
54
+ },
55
+ };
56
+
57
+ /**
58
+ * SegmentedControl - A toggle control for switching between options
59
+ *
60
+ * Commonly used for billing period selection (Monthly/Yearly).
61
+ * All labels are passed by the consumer for full localization control.
62
+ *
63
+ * @example
64
+ * ```tsx
65
+ * <SegmentedControl
66
+ * options={[
67
+ * { value: 'monthly', label: 'Monthly' },
68
+ * { value: 'yearly', label: 'Yearly', badge: 'Save 20%' },
69
+ * ]}
70
+ * value={billingPeriod}
71
+ * onChange={setBillingPeriod}
72
+ * />
73
+ * ```
74
+ */
75
+ export function SegmentedControl<T extends string = string>({
76
+ options,
77
+ value,
78
+ onChange,
79
+ className = '',
80
+ disabled = false,
81
+ size = 'md',
82
+ fullWidth = true,
83
+ accessibilityLabel,
84
+ }: SegmentedControlProps<T>) {
85
+ const sizes = sizeClasses[size];
86
+
87
+ const containerClasses = [
88
+ 'flex-row bg-gray-100 dark:bg-gray-800',
89
+ sizes.container,
90
+ fullWidth ? 'w-full' : '',
91
+ disabled ? 'opacity-50' : '',
92
+ className,
93
+ ]
94
+ .filter(Boolean)
95
+ .join(' ');
96
+
97
+ return (
98
+ <View
99
+ className={containerClasses}
100
+ accessibilityRole='tablist'
101
+ accessibilityLabel={accessibilityLabel}
102
+ >
103
+ {options.map(option => {
104
+ const isSelected = value === option.value;
105
+ const isDisabled = disabled || option.disabled;
106
+
107
+ const segmentClasses = [
108
+ sizes.segment,
109
+ 'flex-1 items-center justify-center flex-row gap-2',
110
+ isSelected ? 'bg-white dark:bg-gray-700 shadow-sm' : 'bg-transparent',
111
+ isDisabled ? 'opacity-50' : '',
112
+ ]
113
+ .filter(Boolean)
114
+ .join(' ');
115
+
116
+ const textClasses = [
117
+ sizes.text,
118
+ 'font-medium',
119
+ isSelected
120
+ ? 'text-gray-900 dark:text-white'
121
+ : 'text-gray-600 dark:text-gray-400',
122
+ ].join(' ');
123
+
124
+ return (
125
+ <Pressable
126
+ key={option.value}
127
+ onPress={() => !isDisabled && onChange(option.value)}
128
+ disabled={isDisabled}
129
+ accessibilityRole='tab'
130
+ accessibilityState={{
131
+ selected: isSelected,
132
+ disabled: isDisabled,
133
+ }}
134
+ accessibilityLabel={
135
+ option.label + (option.badge ? ', ' + option.badge : '')
136
+ }
137
+ className={segmentClasses}
138
+ >
139
+ <Text className={textClasses}>{option.label}</Text>
140
+
141
+ {/* Badge */}
142
+ {option.badge && (
143
+ <View
144
+ className={[
145
+ sizes.badge,
146
+ 'rounded-full',
147
+ isSelected
148
+ ? 'bg-green-100 dark:bg-green-900'
149
+ : 'bg-gray-200 dark:bg-gray-700',
150
+ ].join(' ')}
151
+ >
152
+ <Text
153
+ className={[
154
+ 'text-xs font-semibold',
155
+ isSelected
156
+ ? 'text-green-700 dark:text-green-300'
157
+ : 'text-gray-600 dark:text-gray-400',
158
+ ].join(' ')}
159
+ >
160
+ {option.badge}
161
+ </Text>
162
+ </View>
163
+ )}
164
+ </Pressable>
165
+ );
166
+ })}
167
+ </View>
168
+ );
169
+ }
170
+
171
+ /**
172
+ * Pre-configured period selector for Monthly/Yearly toggle
173
+ */
174
+ export interface PeriodSelectorProps {
175
+ /** Current period */
176
+ period: 'monthly' | 'yearly';
177
+ /** Called when period changes */
178
+ onPeriodChange: (period: 'monthly' | 'yearly') => void;
179
+ /** Monthly label */
180
+ monthlyLabel?: string;
181
+ /** Yearly label */
182
+ yearlyLabel?: string;
183
+ /** Yearly savings badge */
184
+ yearlySavings?: string;
185
+ /** Size variant */
186
+ size?: 'sm' | 'md' | 'lg';
187
+ /** Custom class */
188
+ className?: string;
189
+ /** Disabled state */
190
+ disabled?: boolean;
191
+ }
192
+
193
+ export function PeriodSelector({
194
+ period,
195
+ onPeriodChange,
196
+ monthlyLabel = 'Monthly',
197
+ yearlyLabel = 'Yearly',
198
+ yearlySavings,
199
+ size = 'md',
200
+ className = '',
201
+ disabled = false,
202
+ }: PeriodSelectorProps) {
203
+ const options: SegmentedControlOption<'monthly' | 'yearly'>[] = [
204
+ { value: 'monthly', label: monthlyLabel },
205
+ { value: 'yearly', label: yearlyLabel, badge: yearlySavings },
206
+ ];
207
+
208
+ return (
209
+ <SegmentedControl
210
+ options={options}
211
+ value={period}
212
+ onChange={onPeriodChange}
213
+ size={size}
214
+ className={className}
215
+ disabled={disabled}
216
+ accessibilityLabel='Billing period selector'
217
+ />
218
+ );
219
+ }
220
+
221
+ export default SegmentedControl;
@@ -0,0 +1,379 @@
1
+ import type { ReactNode } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ ScrollView,
6
+ Pressable,
7
+ ActivityIndicator,
8
+ } from 'react-native';
9
+ import { SubscriptionTile } from './SubscriptionTile';
10
+ import type {
11
+ FreeTileConfig,
12
+ SubscriptionLayoutTrackingData,
13
+ SubscriptionStatusConfig,
14
+ ActionButtonConfig,
15
+ } from './types';
16
+
17
+ /**
18
+ * Layout variant:
19
+ * - 'selection': User selects a tile, then presses a shared CTA button (default)
20
+ * - 'cta': Each tile has its own CTA button, no shared action buttons
21
+ */
22
+ export type SubscriptionLayoutVariant = 'selection' | 'cta';
23
+
24
+ export interface SubscriptionLayoutProps {
25
+ /** Section title */
26
+ title: string;
27
+ /** Subscription tiles to render */
28
+ children: ReactNode;
29
+ /** Error message to display */
30
+ error?: string | null;
31
+
32
+ /**
33
+ * Layout variant
34
+ * - 'selection': User selects a tile, then presses primary action button
35
+ * - 'cta': Each tile has its own CTA button (use ctaButton prop on tiles)
36
+ * @default 'selection'
37
+ */
38
+ variant?: SubscriptionLayoutVariant;
39
+
40
+ /** Current subscription status configuration */
41
+ currentStatus?: SubscriptionStatusConfig;
42
+
43
+ /** Primary action button (e.g., "Subscribe Now") - only shown in 'selection' variant */
44
+ primaryAction?: ActionButtonConfig;
45
+
46
+ /** Secondary action button (e.g., "Restore Purchase") - only shown in 'selection' variant */
47
+ secondaryAction?: ActionButtonConfig;
48
+
49
+ /** Additional NativeWind classes */
50
+ className?: string;
51
+
52
+ /** Custom header content */
53
+ headerContent?: ReactNode;
54
+
55
+ /** Content to render above the product tiles (e.g., billing period selector) */
56
+ aboveProducts?: ReactNode;
57
+
58
+ /** Custom footer content (rendered above action buttons) */
59
+ footerContent?: ReactNode;
60
+
61
+ /** Label for "Current Status" section - for localization */
62
+ currentStatusLabel?: string;
63
+
64
+ /**
65
+ * Configuration for the free tile - only used when variant is 'cta'
66
+ * When provided, a "Free" subscription tile will be shown at the start of the list
67
+ */
68
+ freeTileConfig?: FreeTileConfig;
69
+
70
+ /** Optional tracking callback */
71
+ onTrack?: (data: SubscriptionLayoutTrackingData) => void;
72
+ /** Optional tracking label */
73
+ trackingLabel?: string;
74
+ /** Optional component name for tracking */
75
+ componentName?: string;
76
+ }
77
+
78
+ /**
79
+ * SubscriptionLayout - Container component for subscription selection UI
80
+ *
81
+ * Provides a consistent layout with:
82
+ * - Optional current status display
83
+ * - Title heading
84
+ * - Scrollable list of subscription tiles
85
+ * - Error message display
86
+ * - Primary and optional secondary action buttons
87
+ *
88
+ * All labels are passed by the consumer for full localization control.
89
+ */
90
+ export function SubscriptionLayout({
91
+ title,
92
+ children,
93
+ error,
94
+ variant = 'selection',
95
+ currentStatus,
96
+ primaryAction,
97
+ secondaryAction,
98
+ className = '',
99
+ headerContent,
100
+ aboveProducts,
101
+ footerContent,
102
+ currentStatusLabel = 'Current Status',
103
+ freeTileConfig,
104
+ onTrack,
105
+ trackingLabel,
106
+ componentName = 'SubscriptionLayout',
107
+ }: SubscriptionLayoutProps) {
108
+ const showActionButtons = variant === 'selection' && primaryAction;
109
+ // Free tile is only valid in 'cta' variant
110
+ const shouldShowFreeTile = variant === 'cta' && freeTileConfig;
111
+
112
+ const handlePrimaryPress = () => {
113
+ onTrack?.({ action: 'primary_action', trackingLabel, componentName });
114
+ primaryAction?.onPress();
115
+ };
116
+
117
+ const handleSecondaryPress = () => {
118
+ onTrack?.({ action: 'secondary_action', trackingLabel, componentName });
119
+ secondaryAction?.onPress();
120
+ };
121
+
122
+ return (
123
+ <ScrollView
124
+ className='flex-1'
125
+ contentContainerClassName='p-4'
126
+ showsVerticalScrollIndicator={false}
127
+ >
128
+ <View className={className}>
129
+ {/* Custom Header Content */}
130
+ {headerContent}
131
+
132
+ {/* Current Status Section */}
133
+ {currentStatus && (
134
+ <View className='mb-6'>
135
+ <Text className='text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4'>
136
+ {currentStatusLabel}
137
+ </Text>
138
+
139
+ {currentStatus.isActive ? (
140
+ <View className='bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4'>
141
+ <View className='flex-row items-center mb-2'>
142
+ <View className='w-3 h-3 bg-green-500 rounded-full mr-3' />
143
+ <Text className='font-semibold text-green-800 dark:text-green-300'>
144
+ {currentStatus.activeContent?.title ||
145
+ 'Active Subscription'}
146
+ </Text>
147
+ </View>
148
+ {currentStatus.activeContent?.fields &&
149
+ currentStatus.activeContent.fields.length > 0 && (
150
+ <View className='mt-4 gap-4'>
151
+ {currentStatus.activeContent.fields.map(
152
+ (field, index) => (
153
+ <View key={index}>
154
+ <Text className='text-sm text-green-600 dark:text-green-400'>
155
+ {field.label}
156
+ </Text>
157
+ <Text className='font-semibold text-green-800 dark:text-green-300'>
158
+ {field.value}
159
+ </Text>
160
+ </View>
161
+ )
162
+ )}
163
+ </View>
164
+ )}
165
+ </View>
166
+ ) : (
167
+ <View className='bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4'>
168
+ <View className='flex-row items-center mb-2'>
169
+ <View className='w-3 h-3 bg-yellow-500 rounded-full mr-3' />
170
+ <Text className='font-semibold text-yellow-800 dark:text-yellow-300'>
171
+ {currentStatus.inactiveContent?.title ||
172
+ 'No Active Subscription'}
173
+ </Text>
174
+ </View>
175
+ {currentStatus.inactiveContent?.message && (
176
+ <Text className='text-yellow-700 dark:text-yellow-400'>
177
+ {currentStatus.inactiveContent.message}
178
+ </Text>
179
+ )}
180
+ </View>
181
+ )}
182
+ </View>
183
+ )}
184
+
185
+ {/* Section Title */}
186
+ <Text className='text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4'>
187
+ {title}
188
+ </Text>
189
+
190
+ {/* Above Products Content (e.g., billing period selector) */}
191
+ {aboveProducts}
192
+
193
+ {/* Subscription Tiles */}
194
+ <View className='gap-4'>
195
+ {/* Free Tile - only shown in 'cta' variant when enabled */}
196
+ {shouldShowFreeTile && (
197
+ <SubscriptionTile
198
+ id='free'
199
+ title={freeTileConfig.title}
200
+ price={freeTileConfig.price}
201
+ periodLabel={freeTileConfig.periodLabel}
202
+ features={freeTileConfig.features}
203
+ isSelected={false}
204
+ onSelect={() => {}}
205
+ topBadge={freeTileConfig.topBadge}
206
+ ctaButton={freeTileConfig.ctaButton}
207
+ />
208
+ )}
209
+ {children}
210
+ </View>
211
+
212
+ {/* Custom Footer Content */}
213
+ {footerContent}
214
+
215
+ {/* Error Message */}
216
+ {error && (
217
+ <View className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-6'>
218
+ <Text className='text-red-600 dark:text-red-400'>{error}</Text>
219
+ </View>
220
+ )}
221
+
222
+ {/* Action Buttons - only shown in 'selection' variant */}
223
+ {showActionButtons && (
224
+ <View className='gap-3 mt-6'>
225
+ {secondaryAction && (
226
+ <Pressable
227
+ onPress={handleSecondaryPress}
228
+ disabled={secondaryAction.disabled || secondaryAction.loading}
229
+ className={[
230
+ 'py-3 rounded-lg border border-gray-300 dark:border-gray-600 items-center',
231
+ secondaryAction.disabled || secondaryAction.loading
232
+ ? 'opacity-50'
233
+ : '',
234
+ ]
235
+ .filter(Boolean)
236
+ .join(' ')}
237
+ >
238
+ {secondaryAction.loading ? (
239
+ <ActivityIndicator size='small' />
240
+ ) : (
241
+ <Text className='font-semibold text-gray-900 dark:text-gray-100'>
242
+ {secondaryAction.label}
243
+ </Text>
244
+ )}
245
+ </Pressable>
246
+ )}
247
+
248
+ <Pressable
249
+ onPress={handlePrimaryPress}
250
+ disabled={primaryAction.disabled || primaryAction.loading}
251
+ className={[
252
+ 'py-3 rounded-lg bg-blue-600 items-center',
253
+ primaryAction.disabled || primaryAction.loading
254
+ ? 'opacity-50'
255
+ : '',
256
+ ]
257
+ .filter(Boolean)
258
+ .join(' ')}
259
+ >
260
+ {primaryAction.loading ? (
261
+ <ActivityIndicator size='small' color='white' />
262
+ ) : (
263
+ <Text className='font-semibold text-white'>
264
+ {primaryAction.label}
265
+ </Text>
266
+ )}
267
+ </Pressable>
268
+ </View>
269
+ )}
270
+ </View>
271
+ </ScrollView>
272
+ );
273
+ }
274
+
275
+ /**
276
+ * Section divider for subscription layouts
277
+ */
278
+ export interface SubscriptionDividerProps {
279
+ /** Optional label text */
280
+ label?: string;
281
+ /** Custom class */
282
+ className?: string;
283
+ }
284
+
285
+ export function SubscriptionDivider({
286
+ label,
287
+ className = '',
288
+ }: SubscriptionDividerProps) {
289
+ if (label) {
290
+ return (
291
+ <View className={'flex-row items-center gap-4 my-4 ' + className}>
292
+ <View className='flex-1 h-px bg-gray-200 dark:bg-gray-700' />
293
+ <Text className='text-sm text-gray-500 dark:text-gray-400'>
294
+ {label}
295
+ </Text>
296
+ <View className='flex-1 h-px bg-gray-200 dark:bg-gray-700' />
297
+ </View>
298
+ );
299
+ }
300
+
301
+ return (
302
+ <View className={'h-px bg-gray-200 dark:bg-gray-700 my-4 ' + className} />
303
+ );
304
+ }
305
+
306
+ /**
307
+ * Footer with terms and restore button
308
+ */
309
+ export interface SubscriptionFooterProps {
310
+ /** Terms text */
311
+ termsText?: string;
312
+ /** Privacy link text */
313
+ privacyText?: string;
314
+ /** Restore purchases text */
315
+ restoreText?: string;
316
+ /** Called when restore is pressed */
317
+ onRestore?: () => void;
318
+ /** Called when terms is pressed */
319
+ onTermsPress?: () => void;
320
+ /** Called when privacy is pressed */
321
+ onPrivacyPress?: () => void;
322
+ /** Custom class */
323
+ className?: string;
324
+ }
325
+
326
+ export function SubscriptionFooter({
327
+ termsText = 'Terms of Service',
328
+ privacyText = 'Privacy Policy',
329
+ restoreText = 'Restore Purchases',
330
+ onRestore,
331
+ onTermsPress,
332
+ onPrivacyPress,
333
+ className = '',
334
+ }: SubscriptionFooterProps) {
335
+ return (
336
+ <View className={'items-center gap-3 ' + className}>
337
+ {/* Restore Button */}
338
+ {onRestore && (
339
+ <Text
340
+ className='text-sm text-blue-500 dark:text-blue-400 underline'
341
+ onPress={onRestore}
342
+ accessibilityRole='button'
343
+ >
344
+ {restoreText}
345
+ </Text>
346
+ )}
347
+
348
+ {/* Legal Links */}
349
+ <View className='flex-row items-center gap-4'>
350
+ {onTermsPress && (
351
+ <Text
352
+ className='text-xs text-gray-500 dark:text-gray-400 underline'
353
+ onPress={onTermsPress}
354
+ accessibilityRole='link'
355
+ >
356
+ {termsText}
357
+ </Text>
358
+ )}
359
+ {onPrivacyPress && (
360
+ <Text
361
+ className='text-xs text-gray-500 dark:text-gray-400 underline'
362
+ onPress={onPrivacyPress}
363
+ accessibilityRole='link'
364
+ >
365
+ {privacyText}
366
+ </Text>
367
+ )}
368
+ </View>
369
+
370
+ {/* Disclaimer */}
371
+ <Text className='text-xs text-gray-400 dark:text-gray-500 text-center px-4'>
372
+ Subscriptions will automatically renew unless canceled at least 24 hours
373
+ before the end of the current period.
374
+ </Text>
375
+ </View>
376
+ );
377
+ }
378
+
379
+ export default SubscriptionLayout;