@umituz/react-native-subscription 1.3.1 → 1.5.0
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/package.json +11 -10
- package/src/domain/entities/paywall/CreditsPackage.ts +16 -0
- package/src/domain/entities/paywall/PaywallTab.ts +11 -0
- package/src/domain/entities/paywall/SubscriptionPlan.ts +27 -0
- package/src/index.ts +47 -35
- package/src/presentation/components/paywall/CreditsPackageCard.tsx +129 -0
- package/src/presentation/components/paywall/CreditsTabContent.tsx +130 -0
- package/src/presentation/components/paywall/PaywallFeatureItem.tsx +66 -0
- package/src/presentation/components/paywall/PaywallFeaturesList.tsx +43 -0
- package/src/presentation/components/paywall/PaywallHeader.tsx +82 -0
- package/src/presentation/components/paywall/PaywallLegalFooter.tsx +54 -0
- package/src/presentation/components/paywall/PaywallModal.tsx +161 -0
- package/src/presentation/components/paywall/PaywallTabBar.tsx +96 -0
- package/src/presentation/components/paywall/SubscriptionModal.tsx +206 -0
- package/src/presentation/components/paywall/SubscriptionPlanCard.tsx +186 -0
- package/src/presentation/components/paywall/SubscriptionTabContent.tsx +142 -0
- package/src/utils/priceUtils.ts +20 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription Plan Card Component
|
|
3
|
+
* Single Responsibility: Display a subscription plan option
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { View, TouchableOpacity, StyleSheet } from "react-native";
|
|
8
|
+
import type { PurchasesPackage } from "react-native-purchases";
|
|
9
|
+
import { AtomicText } from "@umituz/react-native-design-system-atoms";
|
|
10
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
|
|
11
|
+
import { formatPrice } from "@umituz/react-native-subscription";
|
|
12
|
+
import { useLocalization } from "@umituz/react-native-localization";
|
|
13
|
+
|
|
14
|
+
interface SubscriptionPlanCardProps {
|
|
15
|
+
package: PurchasesPackage;
|
|
16
|
+
isSelected: boolean;
|
|
17
|
+
onSelect: () => void;
|
|
18
|
+
isBestValue?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const getPeriodLabel = (period: string | null | undefined): string => {
|
|
22
|
+
if (!period) return "";
|
|
23
|
+
if (period.includes("Y") || period.includes("year")) return "yearly";
|
|
24
|
+
if (period.includes("M") || period.includes("month")) return "monthly";
|
|
25
|
+
if (period.includes("W") || period.includes("week")) return "weekly";
|
|
26
|
+
if (period.includes("D") || period.includes("day")) return "daily";
|
|
27
|
+
return "";
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const isYearlyPeriod = (period: string | null | undefined): boolean => {
|
|
31
|
+
return period?.includes("Y") || period?.includes("year") || false;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const SubscriptionPlanCard: React.FC<SubscriptionPlanCardProps> =
|
|
35
|
+
React.memo(({ package: pkg, isSelected, onSelect, isBestValue = false }) => {
|
|
36
|
+
const tokens = useAppDesignTokens();
|
|
37
|
+
const { t } = useLocalization();
|
|
38
|
+
|
|
39
|
+
const period = pkg.product.subscriptionPeriod;
|
|
40
|
+
const isYearly = isYearlyPeriod(period);
|
|
41
|
+
const periodLabel = getPeriodLabel(period);
|
|
42
|
+
const price = formatPrice(pkg.product.price, pkg.product.currencyCode);
|
|
43
|
+
const monthlyEquivalent = isYearly
|
|
44
|
+
? formatPrice(pkg.product.price / 12, pkg.product.currencyCode)
|
|
45
|
+
: null;
|
|
46
|
+
|
|
47
|
+
const title = pkg.product.title || t(`paywall.${periodLabel}`, { defaultValue: periodLabel });
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<TouchableOpacity
|
|
51
|
+
onPress={onSelect}
|
|
52
|
+
activeOpacity={0.8}
|
|
53
|
+
style={[
|
|
54
|
+
styles.container,
|
|
55
|
+
{
|
|
56
|
+
backgroundColor: isSelected
|
|
57
|
+
? tokens.colors.primaryLight
|
|
58
|
+
: tokens.colors.surface,
|
|
59
|
+
borderColor: isSelected
|
|
60
|
+
? tokens.colors.primary
|
|
61
|
+
: tokens.colors.border,
|
|
62
|
+
borderWidth: isSelected ? 2 : 1,
|
|
63
|
+
},
|
|
64
|
+
]}
|
|
65
|
+
>
|
|
66
|
+
{isBestValue && (
|
|
67
|
+
<View
|
|
68
|
+
style={[styles.badge, { backgroundColor: tokens.colors.primary }]}
|
|
69
|
+
>
|
|
70
|
+
<AtomicText
|
|
71
|
+
type="labelSmall"
|
|
72
|
+
style={{ color: tokens.colors.onPrimary, fontWeight: "600" }}
|
|
73
|
+
>
|
|
74
|
+
{t("paywall.bestValue", { defaultValue: "Best Value" })}
|
|
75
|
+
</AtomicText>
|
|
76
|
+
</View>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
<View style={styles.content}>
|
|
80
|
+
<View style={styles.leftSection}>
|
|
81
|
+
<View
|
|
82
|
+
style={[
|
|
83
|
+
styles.radio,
|
|
84
|
+
{
|
|
85
|
+
borderColor: isSelected
|
|
86
|
+
? tokens.colors.primary
|
|
87
|
+
: tokens.colors.border,
|
|
88
|
+
},
|
|
89
|
+
]}
|
|
90
|
+
>
|
|
91
|
+
{isSelected && (
|
|
92
|
+
<View
|
|
93
|
+
style={[
|
|
94
|
+
styles.radioInner,
|
|
95
|
+
{ backgroundColor: tokens.colors.primary },
|
|
96
|
+
]}
|
|
97
|
+
/>
|
|
98
|
+
)}
|
|
99
|
+
</View>
|
|
100
|
+
<View style={styles.textContainer}>
|
|
101
|
+
<AtomicText
|
|
102
|
+
type="titleMedium"
|
|
103
|
+
style={[styles.title, { color: tokens.colors.textPrimary }]}
|
|
104
|
+
>
|
|
105
|
+
{title}
|
|
106
|
+
</AtomicText>
|
|
107
|
+
{isYearly && (
|
|
108
|
+
<AtomicText
|
|
109
|
+
type="bodySmall"
|
|
110
|
+
style={{ color: tokens.colors.textSecondary }}
|
|
111
|
+
>
|
|
112
|
+
{price}
|
|
113
|
+
</AtomicText>
|
|
114
|
+
)}
|
|
115
|
+
</View>
|
|
116
|
+
</View>
|
|
117
|
+
|
|
118
|
+
<View style={styles.rightSection}>
|
|
119
|
+
<AtomicText
|
|
120
|
+
type="titleMedium"
|
|
121
|
+
style={[styles.price, { color: tokens.colors.textPrimary }]}
|
|
122
|
+
>
|
|
123
|
+
{isYearly && monthlyEquivalent
|
|
124
|
+
? `${monthlyEquivalent}/mo`
|
|
125
|
+
: price}
|
|
126
|
+
</AtomicText>
|
|
127
|
+
</View>
|
|
128
|
+
</View>
|
|
129
|
+
</TouchableOpacity>
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
SubscriptionPlanCard.displayName = "SubscriptionPlanCard";
|
|
134
|
+
|
|
135
|
+
const styles = StyleSheet.create({
|
|
136
|
+
container: {
|
|
137
|
+
borderRadius: 16,
|
|
138
|
+
padding: 18,
|
|
139
|
+
position: "relative",
|
|
140
|
+
},
|
|
141
|
+
badge: {
|
|
142
|
+
position: "absolute",
|
|
143
|
+
top: -10,
|
|
144
|
+
right: 20,
|
|
145
|
+
paddingHorizontal: 12,
|
|
146
|
+
paddingVertical: 4,
|
|
147
|
+
borderRadius: 8,
|
|
148
|
+
},
|
|
149
|
+
content: {
|
|
150
|
+
flexDirection: "row",
|
|
151
|
+
justifyContent: "space-between",
|
|
152
|
+
alignItems: "center",
|
|
153
|
+
},
|
|
154
|
+
leftSection: {
|
|
155
|
+
flexDirection: "row",
|
|
156
|
+
alignItems: "center",
|
|
157
|
+
flex: 1,
|
|
158
|
+
},
|
|
159
|
+
radio: {
|
|
160
|
+
width: 24,
|
|
161
|
+
height: 24,
|
|
162
|
+
borderRadius: 12,
|
|
163
|
+
borderWidth: 2,
|
|
164
|
+
alignItems: "center",
|
|
165
|
+
justifyContent: "center",
|
|
166
|
+
marginRight: 16,
|
|
167
|
+
},
|
|
168
|
+
radioInner: {
|
|
169
|
+
width: 12,
|
|
170
|
+
height: 12,
|
|
171
|
+
borderRadius: 6,
|
|
172
|
+
},
|
|
173
|
+
textContainer: {
|
|
174
|
+
flex: 1,
|
|
175
|
+
},
|
|
176
|
+
title: {
|
|
177
|
+
fontWeight: "600",
|
|
178
|
+
marginBottom: 2,
|
|
179
|
+
},
|
|
180
|
+
rightSection: {
|
|
181
|
+
alignItems: "flex-end",
|
|
182
|
+
},
|
|
183
|
+
price: {
|
|
184
|
+
fontWeight: "700",
|
|
185
|
+
},
|
|
186
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription Tab Content Component
|
|
3
|
+
* Single Responsibility: Display subscription plans list
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useMemo } from "react";
|
|
7
|
+
import { View, StyleSheet, ScrollView } from "react-native";
|
|
8
|
+
import { AtomicButton } from "@umituz/react-native-design-system-atoms";
|
|
9
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
|
|
10
|
+
import { useLocalization } from "@umituz/react-native-localization";
|
|
11
|
+
import type { PurchasesPackage } from "react-native-purchases";
|
|
12
|
+
import { SubscriptionPlanCard } from "./SubscriptionPlanCard";
|
|
13
|
+
import { PaywallFeaturesList } from "./PaywallFeaturesList";
|
|
14
|
+
import { PaywallLegalFooter } from "./PaywallLegalFooter";
|
|
15
|
+
|
|
16
|
+
interface SubscriptionTabContentProps {
|
|
17
|
+
packages: PurchasesPackage[];
|
|
18
|
+
selectedPackage: PurchasesPackage | null;
|
|
19
|
+
onSelectPackage: (pkg: PurchasesPackage) => void;
|
|
20
|
+
onPurchase: () => void;
|
|
21
|
+
features?: Array<{ icon: string; text: string }>;
|
|
22
|
+
isLoading?: boolean;
|
|
23
|
+
purchaseButtonText?: string;
|
|
24
|
+
processingText?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isYearlyPackage = (pkg: PurchasesPackage): boolean => {
|
|
28
|
+
const period = pkg.product.subscriptionPeriod;
|
|
29
|
+
return period?.includes("Y") || period?.includes("year") || false;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const sortPackages = (packages: PurchasesPackage[]): PurchasesPackage[] => {
|
|
33
|
+
return [...packages].sort((a, b) => {
|
|
34
|
+
const aIsYearly = isYearlyPackage(a);
|
|
35
|
+
const bIsYearly = isYearlyPackage(b);
|
|
36
|
+
if (aIsYearly && !bIsYearly) return -1;
|
|
37
|
+
if (!aIsYearly && bIsYearly) return 1;
|
|
38
|
+
return b.product.price - a.product.price;
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const SubscriptionTabContent: React.FC<SubscriptionTabContentProps> =
|
|
43
|
+
React.memo(
|
|
44
|
+
({
|
|
45
|
+
packages,
|
|
46
|
+
selectedPackage,
|
|
47
|
+
onSelectPackage,
|
|
48
|
+
onPurchase,
|
|
49
|
+
features = [],
|
|
50
|
+
isLoading = false,
|
|
51
|
+
purchaseButtonText,
|
|
52
|
+
processingText,
|
|
53
|
+
}) => {
|
|
54
|
+
const tokens = useAppDesignTokens();
|
|
55
|
+
const { t } = useLocalization();
|
|
56
|
+
|
|
57
|
+
const displayPurchaseButtonText = purchaseButtonText ||
|
|
58
|
+
t("paywall.subscribe", { defaultValue: "Subscribe" });
|
|
59
|
+
const displayProcessingText = processingText ||
|
|
60
|
+
t("paywall.processing", { defaultValue: "Processing..." });
|
|
61
|
+
|
|
62
|
+
const sortedPackages = useMemo(() => sortPackages(packages), [packages]);
|
|
63
|
+
|
|
64
|
+
const firstYearlyIndex = useMemo(
|
|
65
|
+
() => sortedPackages.findIndex(isYearlyPackage),
|
|
66
|
+
[sortedPackages],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<View style={styles.container}>
|
|
71
|
+
<ScrollView
|
|
72
|
+
style={styles.scrollView}
|
|
73
|
+
contentContainerStyle={styles.scrollContent}
|
|
74
|
+
showsVerticalScrollIndicator={false}
|
|
75
|
+
>
|
|
76
|
+
<View style={styles.plansContainer}>
|
|
77
|
+
{sortedPackages.map((pkg, index) => (
|
|
78
|
+
<SubscriptionPlanCard
|
|
79
|
+
key={pkg.product.identifier}
|
|
80
|
+
package={pkg}
|
|
81
|
+
isSelected={
|
|
82
|
+
selectedPackage?.product.identifier ===
|
|
83
|
+
pkg.product.identifier
|
|
84
|
+
}
|
|
85
|
+
onSelect={() => onSelectPackage(pkg)}
|
|
86
|
+
isBestValue={index === firstYearlyIndex}
|
|
87
|
+
/>
|
|
88
|
+
))}
|
|
89
|
+
</View>
|
|
90
|
+
|
|
91
|
+
{features.length > 0 && (
|
|
92
|
+
<View
|
|
93
|
+
style={[
|
|
94
|
+
styles.featuresSection,
|
|
95
|
+
{ backgroundColor: tokens.colors.surfaceSecondary },
|
|
96
|
+
]}
|
|
97
|
+
>
|
|
98
|
+
<PaywallFeaturesList features={features} gap={12} />
|
|
99
|
+
</View>
|
|
100
|
+
)}
|
|
101
|
+
</ScrollView>
|
|
102
|
+
|
|
103
|
+
<View style={styles.footer}>
|
|
104
|
+
<AtomicButton
|
|
105
|
+
title={isLoading ? displayProcessingText : displayPurchaseButtonText}
|
|
106
|
+
onPress={onPurchase}
|
|
107
|
+
disabled={!selectedPackage || isLoading}
|
|
108
|
+
/>
|
|
109
|
+
</View>
|
|
110
|
+
|
|
111
|
+
<PaywallLegalFooter />
|
|
112
|
+
</View>
|
|
113
|
+
);
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
SubscriptionTabContent.displayName = "SubscriptionTabContent";
|
|
118
|
+
|
|
119
|
+
const styles = StyleSheet.create({
|
|
120
|
+
container: {
|
|
121
|
+
flex: 1,
|
|
122
|
+
},
|
|
123
|
+
scrollView: {
|
|
124
|
+
flex: 1,
|
|
125
|
+
},
|
|
126
|
+
scrollContent: {
|
|
127
|
+
paddingHorizontal: 24,
|
|
128
|
+
paddingBottom: 16,
|
|
129
|
+
},
|
|
130
|
+
plansContainer: {
|
|
131
|
+
gap: 12,
|
|
132
|
+
marginBottom: 20,
|
|
133
|
+
},
|
|
134
|
+
featuresSection: {
|
|
135
|
+
borderRadius: 16,
|
|
136
|
+
padding: 16,
|
|
137
|
+
},
|
|
138
|
+
footer: {
|
|
139
|
+
padding: 24,
|
|
140
|
+
paddingTop: 16,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Price Formatting Utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Format price for display
|
|
7
|
+
* @param price - Price value
|
|
8
|
+
* @param currencyCode - Currency code (e.g., 'USD', 'EUR')
|
|
9
|
+
* @returns Formatted price string
|
|
10
|
+
*/
|
|
11
|
+
export function formatPrice(price: number, currencyCode: string): string {
|
|
12
|
+
try {
|
|
13
|
+
return new Intl.NumberFormat('en-US', {
|
|
14
|
+
style: 'currency',
|
|
15
|
+
currency: currencyCode,
|
|
16
|
+
}).format(price);
|
|
17
|
+
} catch {
|
|
18
|
+
return `${currencyCode} ${price.toFixed(2)}`;
|
|
19
|
+
}
|
|
20
|
+
}
|