@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,248 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react-native';
3
+ import { SubscriptionTile } from '../SubscriptionTile';
4
+
5
+ const baseProps = {
6
+ id: 'pro',
7
+ title: 'Pro Plan',
8
+ price: '$9.99',
9
+ periodLabel: '/month',
10
+ features: ['Unlimited access', 'Priority support'],
11
+ isSelected: false,
12
+ onSelect: jest.fn(),
13
+ };
14
+
15
+ describe('SubscriptionTile', () => {
16
+ beforeEach(() => {
17
+ jest.clearAllMocks();
18
+ });
19
+
20
+ it('renders title, price, period label, and features', () => {
21
+ render(<SubscriptionTile {...baseProps} />);
22
+
23
+ expect(screen.getByText('Pro Plan')).toBeTruthy();
24
+ expect(screen.getByText('$9.99')).toBeTruthy();
25
+ expect(screen.getByText('/month')).toBeTruthy();
26
+ expect(screen.getByText('Unlimited access')).toBeTruthy();
27
+ expect(screen.getByText('Priority support')).toBeTruthy();
28
+ });
29
+
30
+ it('calls onSelect when pressed in normal mode', () => {
31
+ const onSelect = jest.fn();
32
+ render(<SubscriptionTile {...baseProps} onSelect={onSelect} />);
33
+
34
+ fireEvent.press(screen.getByRole('radio'));
35
+ expect(onSelect).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ it('does not call onSelect when disabled', () => {
39
+ const onSelect = jest.fn();
40
+ render(
41
+ <SubscriptionTile {...baseProps} onSelect={onSelect} disabled />
42
+ );
43
+
44
+ fireEvent.press(screen.getByRole('radio'));
45
+ expect(onSelect).not.toHaveBeenCalled();
46
+ });
47
+
48
+ it('does not call onSelect when not enabled', () => {
49
+ const onSelect = jest.fn();
50
+ render(
51
+ <SubscriptionTile {...baseProps} onSelect={onSelect} enabled={false} />
52
+ );
53
+
54
+ fireEvent.press(screen.getByRole('radio'));
55
+ expect(onSelect).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it('renders with radio accessibility role in selection mode', () => {
59
+ render(<SubscriptionTile {...baseProps} />);
60
+ expect(screen.getByRole('radio')).toBeTruthy();
61
+ });
62
+
63
+ it('sets checked state matching isSelected', () => {
64
+ const { rerender } = render(
65
+ <SubscriptionTile {...baseProps} isSelected={false} />
66
+ );
67
+ expect(
68
+ screen.getByRole('radio').props.accessibilityState.checked
69
+ ).toBe(false);
70
+
71
+ rerender(<SubscriptionTile {...baseProps} isSelected={true} />);
72
+ expect(
73
+ screen.getByRole('radio').props.accessibilityState.checked
74
+ ).toBe(true);
75
+ });
76
+
77
+ it('renders top badge when provided', () => {
78
+ render(
79
+ <SubscriptionTile
80
+ {...baseProps}
81
+ topBadge={{ text: 'Most Popular', color: 'purple' }}
82
+ />
83
+ );
84
+
85
+ expect(screen.getByText('Most Popular')).toBeTruthy();
86
+ });
87
+
88
+ it('renders discount badge when provided', () => {
89
+ render(
90
+ <SubscriptionTile
91
+ {...baseProps}
92
+ discountBadge={{ text: 'Save 40%' }}
93
+ />
94
+ );
95
+
96
+ expect(screen.getByText('Save 40%')).toBeTruthy();
97
+ });
98
+
99
+ it('renders bottom note when provided', () => {
100
+ render(
101
+ <SubscriptionTile
102
+ {...baseProps}
103
+ bottomNote="Renews on Jan 1, 2027"
104
+ />
105
+ );
106
+
107
+ expect(screen.getByText('Renews on Jan 1, 2027')).toBeTruthy();
108
+ });
109
+
110
+ it('renders intro price note when provided', () => {
111
+ render(
112
+ <SubscriptionTile
113
+ {...baseProps}
114
+ introPriceNote="First month free!"
115
+ />
116
+ );
117
+
118
+ expect(screen.getByText('First month free!')).toBeTruthy();
119
+ });
120
+
121
+ it('renders premium callout with title and features', () => {
122
+ render(
123
+ <SubscriptionTile
124
+ {...baseProps}
125
+ premiumCallout={{
126
+ title: 'Premium Features',
127
+ features: ['AI Assistant', 'Analytics'],
128
+ }}
129
+ />
130
+ );
131
+
132
+ expect(screen.getByText('Premium Features')).toBeTruthy();
133
+ expect(screen.getByText(/AI Assistant/)).toBeTruthy();
134
+ expect(screen.getByText(/Analytics/)).toBeTruthy();
135
+ });
136
+
137
+ it('renders CTA button in cta mode with summary role', () => {
138
+ const onCtaPress = jest.fn();
139
+ render(
140
+ <SubscriptionTile
141
+ {...baseProps}
142
+ ctaButton={{ label: 'Subscribe Now', onPress: onCtaPress }}
143
+ />
144
+ );
145
+
146
+ // In CTA mode, role should be 'summary' not 'radio'
147
+ expect(screen.getByRole('summary')).toBeTruthy();
148
+ expect(screen.getByText('Subscribe Now')).toBeTruthy();
149
+ });
150
+
151
+ it('calls CTA onPress when CTA button is pressed', () => {
152
+ const onCtaPress = jest.fn();
153
+ render(
154
+ <SubscriptionTile
155
+ {...baseProps}
156
+ ctaButton={{ label: 'Subscribe Now', onPress: onCtaPress }}
157
+ />
158
+ );
159
+
160
+ fireEvent.press(screen.getByText('Subscribe Now'));
161
+ expect(onCtaPress).toHaveBeenCalledTimes(1);
162
+ });
163
+
164
+ it('does not call onSelect when in CTA mode (tile press is disabled)', () => {
165
+ const onSelect = jest.fn();
166
+ render(
167
+ <SubscriptionTile
168
+ {...baseProps}
169
+ onSelect={onSelect}
170
+ ctaButton={{ label: 'Buy', onPress: jest.fn() }}
171
+ />
172
+ );
173
+
174
+ fireEvent.press(screen.getByRole('summary'));
175
+ expect(onSelect).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it('calls onTrack with select action when tile is pressed', () => {
179
+ const onTrack = jest.fn();
180
+ const onSelect = jest.fn();
181
+ render(
182
+ <SubscriptionTile
183
+ {...baseProps}
184
+ onSelect={onSelect}
185
+ onTrack={onTrack}
186
+ trackingLabel="pro_plan"
187
+ />
188
+ );
189
+
190
+ fireEvent.press(screen.getByRole('radio'));
191
+ expect(onTrack).toHaveBeenCalledWith({
192
+ action: 'select',
193
+ trackingLabel: 'pro_plan',
194
+ componentName: 'SubscriptionTile',
195
+ });
196
+ });
197
+
198
+ it('calls onTrack with cta_click action when CTA is pressed', () => {
199
+ const onTrack = jest.fn();
200
+ render(
201
+ <SubscriptionTile
202
+ {...baseProps}
203
+ ctaButton={{ label: 'Buy', onPress: jest.fn() }}
204
+ onTrack={onTrack}
205
+ trackingLabel="pro_cta"
206
+ />
207
+ );
208
+
209
+ fireEvent.press(screen.getByText('Buy'));
210
+ expect(onTrack).toHaveBeenCalledWith({
211
+ action: 'cta_click',
212
+ trackingLabel: 'pro_cta',
213
+ componentName: 'SubscriptionTile',
214
+ });
215
+ });
216
+
217
+ it('does not show indicator when isCurrentPlan is true', () => {
218
+ const onSelect = jest.fn();
219
+ render(
220
+ <SubscriptionTile {...baseProps} onSelect={onSelect} isCurrentPlan />
221
+ );
222
+
223
+ // Should be non-interactive when isCurrentPlan
224
+ fireEvent.press(screen.getByRole('radio'));
225
+ expect(onSelect).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it('uses default accessibility label with title, price, and period', () => {
229
+ render(<SubscriptionTile {...baseProps} />);
230
+
231
+ const tile = screen.getByRole('radio');
232
+ expect(tile.props.accessibilityLabel).toBe('Pro Plan - $9.99/month');
233
+ });
234
+
235
+ it('uses custom accessibility label when provided', () => {
236
+ render(
237
+ <SubscriptionTile
238
+ {...baseProps}
239
+ accessibilityLabel="Pro plan nine ninety nine per month"
240
+ />
241
+ );
242
+
243
+ const tile = screen.getByRole('radio');
244
+ expect(tile.props.accessibilityLabel).toBe(
245
+ 'Pro plan nine ninety nine per month'
246
+ );
247
+ });
248
+ });
package/src/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @sudobility/subscription-components-rn
3
+ *
4
+ * Subscription UI components for React Native with RevenueCat integration.
5
+ * All components support full localization - text labels are passed by the consumer.
6
+ *
7
+ * Aligned with @sudobility/subscription-components (web).
8
+ */
9
+
10
+ // Components
11
+ export {
12
+ SubscriptionTile,
13
+ type SubscriptionTileProps,
14
+ } from './SubscriptionTile';
15
+ export {
16
+ SubscriptionLayout,
17
+ SubscriptionDivider,
18
+ SubscriptionFooter,
19
+ type SubscriptionLayoutProps,
20
+ type SubscriptionLayoutVariant,
21
+ type SubscriptionDividerProps,
22
+ type SubscriptionFooterProps,
23
+ } from './SubscriptionLayout';
24
+ export {
25
+ SegmentedControl,
26
+ PeriodSelector,
27
+ type SegmentedControlProps,
28
+ type SegmentedControlOption,
29
+ type PeriodSelectorProps,
30
+ } from './SegmentedControl';
31
+ export {
32
+ SubscriptionProvider,
33
+ useSubscriptionContext,
34
+ SubscriptionContext,
35
+ type SubscriptionProviderProps,
36
+ } from './SubscriptionProvider';
37
+
38
+ // Types
39
+ export type {
40
+ SubscriptionProduct,
41
+ SubscriptionStatus,
42
+ SubscriptionContextValue,
43
+ SubscriptionProviderConfig,
44
+ BadgeConfig,
45
+ CtaButtonConfig,
46
+ DiscountBadgeConfig,
47
+ PremiumCalloutConfig,
48
+ FreeTileConfig,
49
+ SubscriptionStatusConfig,
50
+ ActionButtonConfig,
51
+ SubscriptionTileTrackingData,
52
+ SubscriptionLayoutTrackingData,
53
+ } from './types';
@@ -0,0 +1,24 @@
1
+ // Type declarations for NativeWind className prop
2
+ import 'react-native';
3
+
4
+ declare module 'react-native' {
5
+ interface ViewProps {
6
+ className?: string;
7
+ }
8
+ interface TextProps {
9
+ className?: string;
10
+ }
11
+ interface ImageProps {
12
+ className?: string;
13
+ }
14
+ interface ScrollViewProps {
15
+ className?: string;
16
+ contentContainerClassName?: string;
17
+ }
18
+ interface PressableProps {
19
+ className?: string | ((state: { pressed: boolean }) => string);
20
+ }
21
+ interface TouchableOpacityProps {
22
+ className?: string;
23
+ }
24
+ }
package/src/types.ts ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Type definitions for subscription components
3
+ * Aligned with @sudobility/subscription-components (web)
4
+ */
5
+
6
+ /**
7
+ * Product information from RevenueCat or custom backend
8
+ */
9
+ export interface SubscriptionProduct {
10
+ /** Unique identifier for the product/package */
11
+ identifier: string;
12
+ /** Underlying product ID (e.g., from app store) */
13
+ productId?: string;
14
+ /** Numeric price value */
15
+ price: string;
16
+ /** Formatted price string (e.g., "$9.99") */
17
+ priceString: string;
18
+ /** Product title */
19
+ title: string;
20
+ /** Product description */
21
+ description?: string;
22
+ /** ISO 8601 duration (e.g., "P1M", "P1Y") */
23
+ period?: string;
24
+ /** Formatted introductory price */
25
+ introPrice?: string;
26
+ /** Raw intro price amount */
27
+ introPriceAmount?: string;
28
+ /** Intro price period (ISO 8601) */
29
+ introPricePeriod?: string;
30
+ /** Number of billing cycles for intro price */
31
+ introPriceCycles?: number;
32
+ /** Free trial period (ISO 8601, e.g., "P7D") */
33
+ freeTrialPeriod?: string;
34
+ /** Entitlement identifier this product grants (from offering metadata) */
35
+ entitlement?: string;
36
+ }
37
+
38
+ /**
39
+ * Active subscription status
40
+ */
41
+ export interface SubscriptionStatus {
42
+ /** Whether the user has an active subscription */
43
+ isActive: boolean;
44
+ /** Expiration date of the subscription */
45
+ expirationDate?: Date;
46
+ /** Date when subscription was purchased */
47
+ purchaseDate?: Date;
48
+ /** Product identifier of the current subscription */
49
+ productIdentifier?: string;
50
+ /** Whether subscription will auto-renew */
51
+ willRenew?: boolean;
52
+ /** Whether this is a sandbox/test subscription */
53
+ isSandbox?: boolean;
54
+ /** Date when unsubscription was detected */
55
+ unsubscribeDetectedAt?: Date;
56
+ /** Date when billing issue was detected */
57
+ billingIssueDetectedAt?: Date;
58
+ /** Active entitlement identifiers */
59
+ activeEntitlements?: string[];
60
+ /** Management URL for subscription (platform store management) */
61
+ managementUrl?: string;
62
+ }
63
+
64
+ /**
65
+ * Badge display configuration
66
+ */
67
+ export interface BadgeConfig {
68
+ /** Badge text */
69
+ text: string;
70
+ /** Badge color variant */
71
+ color: 'purple' | 'green' | 'blue' | 'yellow' | 'red';
72
+ }
73
+
74
+ /**
75
+ * Discount badge configuration
76
+ */
77
+ export interface DiscountBadgeConfig {
78
+ /** Discount text (e.g., "Save 40%") */
79
+ text: string;
80
+ /** Whether this is the best value option */
81
+ isBestValue?: boolean;
82
+ }
83
+
84
+ /**
85
+ * Premium callout section configuration
86
+ */
87
+ export interface PremiumCalloutConfig {
88
+ /** Callout title */
89
+ title: string;
90
+ /** List of premium features */
91
+ features: string[];
92
+ }
93
+
94
+ /**
95
+ * CTA button configuration for tile
96
+ */
97
+ export interface CtaButtonConfig {
98
+ /** Button label */
99
+ label: string;
100
+ /** Press handler */
101
+ onPress?: () => void;
102
+ }
103
+
104
+ /** Tracking data for SubscriptionTile actions */
105
+ export interface SubscriptionTileTrackingData {
106
+ action: 'select' | 'cta_click';
107
+ trackingLabel?: string;
108
+ componentName?: string;
109
+ }
110
+
111
+ /** Tracking data for SubscriptionLayout actions */
112
+ export interface SubscriptionLayoutTrackingData {
113
+ action: 'primary_action' | 'secondary_action';
114
+ trackingLabel?: string;
115
+ componentName?: string;
116
+ }
117
+
118
+ /**
119
+ * Free tile configuration for CTA variant layout
120
+ */
121
+ export interface FreeTileConfig {
122
+ /** Tile title (e.g., "Free") */
123
+ title: string;
124
+ /** Price display (e.g., "$0") */
125
+ price: string;
126
+ /** Period label (e.g., "/month") */
127
+ periodLabel?: string;
128
+ /** List of features included in free tier */
129
+ features: string[];
130
+ /** CTA button configuration */
131
+ ctaButton: CtaButtonConfig;
132
+ /** Optional top badge */
133
+ topBadge?: BadgeConfig;
134
+ }
135
+
136
+ /**
137
+ * Current subscription status display configuration
138
+ */
139
+ export interface SubscriptionStatusConfig {
140
+ /** Whether user has an active subscription */
141
+ isActive: boolean;
142
+ /** Content to display when subscription is active */
143
+ activeContent?: {
144
+ /** Status title (e.g., "Active Subscription") */
145
+ title: string;
146
+ /** Status fields to display */
147
+ fields?: Array<{
148
+ label: string;
149
+ value: string;
150
+ }>;
151
+ };
152
+ /** Content to display when no active subscription */
153
+ inactiveContent?: {
154
+ /** Status title (e.g., "No Active Subscription") */
155
+ title: string;
156
+ /** Description message */
157
+ message: string;
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Action button configuration
163
+ */
164
+ export interface ActionButtonConfig {
165
+ /** Button label */
166
+ label: string;
167
+ /** Press handler */
168
+ onPress: () => void;
169
+ /** Whether button is disabled */
170
+ disabled?: boolean;
171
+ /** Whether button is in loading state */
172
+ loading?: boolean;
173
+ }
174
+
175
+ /**
176
+ * Subscription context value
177
+ */
178
+ export interface SubscriptionContextValue {
179
+ /** Available products */
180
+ products: SubscriptionProduct[];
181
+ /** Current subscription status */
182
+ currentSubscription: SubscriptionStatus | null;
183
+ /** Whether data is loading */
184
+ isLoading: boolean;
185
+ /** Error message if any */
186
+ error: string | null;
187
+ /** Initialize the subscription service. If userId is undefined, clears the current user. */
188
+ initialize: (userId?: string, email?: string) => Promise<void>;
189
+ /** Purchase a subscription. subscriptionUserId identifies which user/entity the subscription is for. */
190
+ purchase: (
191
+ productIdentifier: string,
192
+ subscriptionUserId?: string
193
+ ) => Promise<boolean>;
194
+ /** Restore previous purchases. subscriptionUserId identifies which user/entity to restore for. */
195
+ restore: (subscriptionUserId?: string) => Promise<boolean>;
196
+ /** Refresh subscription status */
197
+ refresh: () => Promise<void>;
198
+ /** Clear error state */
199
+ clearError: () => void;
200
+ }
201
+
202
+ /**
203
+ * Provider configuration
204
+ */
205
+ export interface SubscriptionProviderConfig {
206
+ /** RevenueCat API key */
207
+ apiKey: string;
208
+ /** Optional user email for RevenueCat */
209
+ userEmail?: string;
210
+ /** Error callback */
211
+ onError?: (error: Error) => void;
212
+ /** Success callback after purchase */
213
+ onPurchaseSuccess?: (productId: string) => void;
214
+ }