@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,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;
|