@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.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Paywall Header Component
3
+ * Single Responsibility: Display paywall header with close button
4
+ */
5
+
6
+ import React from "react";
7
+ import { View, TouchableOpacity, StyleSheet } from "react-native";
8
+ import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system-atoms";
9
+ import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
10
+
11
+ interface PaywallHeaderProps {
12
+ title: string;
13
+ subtitle?: string;
14
+ onClose: () => void;
15
+ }
16
+
17
+ export const PaywallHeader: React.FC<PaywallHeaderProps> = React.memo(
18
+ ({ title, subtitle, onClose }) => {
19
+ const tokens = useAppDesignTokens();
20
+
21
+ return (
22
+ <View style={styles.container}>
23
+ <View style={styles.titleContainer}>
24
+ <AtomicText
25
+ type="headlineLarge"
26
+ style={[styles.title, { color: tokens.colors.textPrimary }]}
27
+ >
28
+ {title}
29
+ </AtomicText>
30
+ {subtitle && (
31
+ <AtomicText
32
+ type="bodyMedium"
33
+ style={[styles.subtitle, { color: tokens.colors.textSecondary }]}
34
+ >
35
+ {subtitle}
36
+ </AtomicText>
37
+ )}
38
+ </View>
39
+ <TouchableOpacity
40
+ onPress={onClose}
41
+ style={[
42
+ styles.closeButton,
43
+ { backgroundColor: tokens.colors.surfaceSecondary },
44
+ ]}
45
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
46
+ >
47
+ <AtomicIcon name="X" size="md" color="secondary" />
48
+ </TouchableOpacity>
49
+ </View>
50
+ );
51
+ },
52
+ );
53
+
54
+ PaywallHeader.displayName = "PaywallHeader";
55
+
56
+ const styles = StyleSheet.create({
57
+ container: {
58
+ flexDirection: "row",
59
+ justifyContent: "space-between",
60
+ alignItems: "flex-start",
61
+ paddingHorizontal: 24,
62
+ paddingTop: 8,
63
+ paddingBottom: 16,
64
+ },
65
+ titleContainer: {
66
+ flex: 1,
67
+ marginRight: 16,
68
+ },
69
+ title: {
70
+ fontWeight: "700",
71
+ },
72
+ subtitle: {
73
+ marginTop: 4,
74
+ },
75
+ closeButton: {
76
+ width: 32,
77
+ height: 32,
78
+ borderRadius: 16,
79
+ justifyContent: "center",
80
+ alignItems: "center",
81
+ },
82
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Paywall Legal Footer Component
3
+ * Display legal links and terms for App Store compliance
4
+ */
5
+
6
+ import React from "react";
7
+ import { View, StyleSheet } from "react-native";
8
+ import { AtomicText } from "@umituz/react-native-design-system-atoms";
9
+ import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
10
+ import { LegalLinks } from "@umituz/react-native-legal";
11
+
12
+ interface PaywallLegalFooterProps {
13
+ termsText?: string;
14
+ }
15
+
16
+ const DEFAULT_TERMS =
17
+ "Payment will be charged to your account. Subscription automatically renews unless cancelled.";
18
+
19
+ export const PaywallLegalFooter: React.FC<PaywallLegalFooterProps> = React.memo(
20
+ ({ termsText = DEFAULT_TERMS }) => {
21
+ const tokens = useAppDesignTokens();
22
+
23
+ return (
24
+ <View style={styles.container}>
25
+ <AtomicText
26
+ type="labelSmall"
27
+ style={[styles.termsText, { color: tokens.colors.textTertiary }]}
28
+ >
29
+ {termsText}
30
+ </AtomicText>
31
+ <LegalLinks style={styles.legalLinks} />
32
+ </View>
33
+ );
34
+ }
35
+ );
36
+
37
+ PaywallLegalFooter.displayName = "PaywallLegalFooter";
38
+
39
+ const styles = StyleSheet.create({
40
+ container: {
41
+ alignItems: "center",
42
+ paddingHorizontal: 24,
43
+ paddingBottom: 16,
44
+ },
45
+ termsText: {
46
+ textAlign: "center",
47
+ fontSize: 11,
48
+ lineHeight: 16,
49
+ marginBottom: 8,
50
+ },
51
+ legalLinks: {
52
+ marginTop: 4,
53
+ },
54
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Paywall Modal Component
3
+ * Single Responsibility: Display paywall with Credits and Subscription tabs
4
+ */
5
+
6
+ import React, { useEffect } from "react";
7
+ import { View, Modal, StyleSheet, TouchableOpacity } from "react-native";
8
+ import { SafeAreaView } from "react-native-safe-area-context";
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 { usePaywall } from "../hooks/usePaywall";
13
+ import { PaywallHeader } from "./PaywallHeader";
14
+ import { PaywallTabBar } from "./PaywallTabBar";
15
+ import { CreditsTabContent } from "./CreditsTabContent";
16
+ import { SubscriptionTabContent } from "./SubscriptionTabContent";
17
+ import type { PaywallTabType } from "../../domain/entities/PaywallTab";
18
+ import type { CreditsPackage } from "../../domain/entities/CreditsPackage";
19
+
20
+ interface PaywallModalProps {
21
+ visible: boolean;
22
+ onClose: () => void;
23
+ initialTab?: PaywallTabType;
24
+ creditsPackages: CreditsPackage[];
25
+ subscriptionPackages: PurchasesPackage[];
26
+ currentCredits: number;
27
+ requiredCredits?: number;
28
+ onCreditsPurchase: (packageId: string) => Promise<void>;
29
+ onSubscriptionPurchase: (pkg: PurchasesPackage) => Promise<void>;
30
+ subscriptionFeatures?: Array<{ icon: string; text: string }>;
31
+ isLoading?: boolean;
32
+ title?: string;
33
+ subtitle?: string;
34
+ }
35
+
36
+ export const PaywallModal: React.FC<PaywallModalProps> = React.memo(
37
+ ({
38
+ visible,
39
+ onClose,
40
+ initialTab = "credits",
41
+ creditsPackages,
42
+ subscriptionPackages,
43
+ currentCredits,
44
+ requiredCredits,
45
+ onCreditsPurchase,
46
+ onSubscriptionPurchase,
47
+ subscriptionFeatures = [],
48
+ isLoading = false,
49
+ title,
50
+ subtitle,
51
+ }) => {
52
+ const tokens = useAppDesignTokens();
53
+ const { t } = useLocalization();
54
+
55
+ const {
56
+ activeTab,
57
+ selectedCreditsPackageId,
58
+ selectedSubscriptionPkg,
59
+ handleTabChange,
60
+ handleCreditsPackageSelect,
61
+ handleSubscriptionPackageSelect,
62
+ handleCreditsPurchase,
63
+ handleSubscriptionPurchase,
64
+ } = usePaywall({
65
+ initialTab,
66
+ onCreditsPurchase,
67
+ onSubscriptionPurchase,
68
+ });
69
+
70
+ const displayTitle = title || t("paywall.title", { defaultValue: "Get Premium" });
71
+ const displaySubtitle = subtitle || t("paywall.subtitle", { defaultValue: "" });
72
+
73
+ useEffect(() => {
74
+ if (__DEV__) {
75
+ console.log("[PaywallModal] Visibility changed:", visible);
76
+ }
77
+ }, [visible]);
78
+
79
+ if (!visible) return null;
80
+
81
+ return (
82
+ <Modal
83
+ visible={visible}
84
+ transparent
85
+ animationType="slide"
86
+ onRequestClose={onClose}
87
+ >
88
+ <View style={styles.overlay}>
89
+ <TouchableOpacity
90
+ style={styles.backdrop}
91
+ activeOpacity={1}
92
+ onPress={onClose}
93
+ />
94
+ <View
95
+ style={[styles.content, { backgroundColor: tokens.colors.surface }]}
96
+ >
97
+ <SafeAreaView edges={["top"]} style={styles.safeArea}>
98
+ <PaywallHeader
99
+ title={displayTitle}
100
+ subtitle={displaySubtitle}
101
+ onClose={onClose}
102
+ />
103
+
104
+ <PaywallTabBar
105
+ activeTab={activeTab}
106
+ onTabChange={handleTabChange}
107
+ creditsLabel={t("paywall.tabs.credits", { defaultValue: "Credits" })}
108
+ subscriptionLabel={t("paywall.tabs.subscription", { defaultValue: "Subscription" })}
109
+ />
110
+
111
+ {activeTab === "credits" ? (
112
+ <CreditsTabContent
113
+ packages={creditsPackages}
114
+ selectedPackageId={selectedCreditsPackageId}
115
+ onSelectPackage={handleCreditsPackageSelect}
116
+ onPurchase={handleCreditsPurchase}
117
+ currentCredits={currentCredits}
118
+ requiredCredits={requiredCredits}
119
+ isLoading={isLoading}
120
+ purchaseButtonText={t("paywall.purchase", { defaultValue: "Purchase" })}
121
+ />
122
+ ) : (
123
+ <SubscriptionTabContent
124
+ packages={subscriptionPackages}
125
+ selectedPackage={selectedSubscriptionPkg}
126
+ onSelectPackage={handleSubscriptionPackageSelect}
127
+ onPurchase={handleSubscriptionPurchase}
128
+ features={subscriptionFeatures}
129
+ isLoading={isLoading}
130
+ purchaseButtonText={t("paywall.subscribe", { defaultValue: "Subscribe" })}
131
+ />
132
+ )}
133
+ </SafeAreaView>
134
+ </View>
135
+ </View>
136
+ </Modal>
137
+ );
138
+ },
139
+ );
140
+
141
+ PaywallModal.displayName = "PaywallModal";
142
+
143
+ const styles = StyleSheet.create({
144
+ overlay: {
145
+ flex: 1,
146
+ justifyContent: "flex-end",
147
+ },
148
+ backdrop: {
149
+ ...StyleSheet.absoluteFillObject,
150
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
151
+ },
152
+ content: {
153
+ borderTopLeftRadius: 24,
154
+ borderTopRightRadius: 24,
155
+ maxHeight: "90%",
156
+ flex: 1,
157
+ },
158
+ safeArea: {
159
+ flex: 1,
160
+ },
161
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Paywall Tab Bar Component
3
+ * Single Responsibility: Display and handle tab selection
4
+ */
5
+
6
+ import React from "react";
7
+ import { View, TouchableOpacity, StyleSheet } from "react-native";
8
+ import { AtomicText } from "@umituz/react-native-design-system-atoms";
9
+ import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
10
+ import type { PaywallTabType } from "../../domain/entities/PaywallTab";
11
+
12
+ interface PaywallTabBarProps {
13
+ activeTab: PaywallTabType;
14
+ onTabChange: (tab: PaywallTabType) => void;
15
+ creditsLabel?: string;
16
+ subscriptionLabel?: string;
17
+ }
18
+
19
+ export const PaywallTabBar: React.FC<PaywallTabBarProps> = React.memo(
20
+ ({
21
+ activeTab,
22
+ onTabChange,
23
+ creditsLabel = "Credits",
24
+ subscriptionLabel = "Subscription",
25
+ }) => {
26
+ const tokens = useAppDesignTokens();
27
+
28
+ const renderTab = (tab: PaywallTabType, label: string) => {
29
+ const isActive = activeTab === tab;
30
+
31
+ return (
32
+ <TouchableOpacity
33
+ key={tab}
34
+ style={[
35
+ styles.tab,
36
+ {
37
+ backgroundColor: isActive
38
+ ? tokens.colors.primary
39
+ : tokens.colors.surfaceSecondary,
40
+ },
41
+ ]}
42
+ onPress={() => onTabChange(tab)}
43
+ activeOpacity={0.8}
44
+ >
45
+ <AtomicText
46
+ type="labelLarge"
47
+ style={[
48
+ styles.tabText,
49
+ {
50
+ color: isActive
51
+ ? tokens.colors.onPrimary
52
+ : tokens.colors.textSecondary,
53
+ },
54
+ ]}
55
+ >
56
+ {label}
57
+ </AtomicText>
58
+ </TouchableOpacity>
59
+ );
60
+ };
61
+
62
+ return (
63
+ <View
64
+ style={[
65
+ styles.container,
66
+ { backgroundColor: tokens.colors.surfaceSecondary },
67
+ ]}
68
+ >
69
+ {renderTab("credits", creditsLabel)}
70
+ {renderTab("subscription", subscriptionLabel)}
71
+ </View>
72
+ );
73
+ },
74
+ );
75
+
76
+ PaywallTabBar.displayName = "PaywallTabBar";
77
+
78
+ const styles = StyleSheet.create({
79
+ container: {
80
+ flexDirection: "row",
81
+ borderRadius: 12,
82
+ padding: 4,
83
+ marginHorizontal: 24,
84
+ marginBottom: 16,
85
+ },
86
+ tab: {
87
+ flex: 1,
88
+ paddingVertical: 12,
89
+ borderRadius: 8,
90
+ alignItems: "center",
91
+ justifyContent: "center",
92
+ },
93
+ tabText: {
94
+ fontWeight: "600",
95
+ },
96
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Subscription Modal Component
3
+ * Modal for displaying subscription packages
4
+ */
5
+
6
+ import React, { useState, useCallback } from "react";
7
+ import {
8
+ View,
9
+ Modal,
10
+ StyleSheet,
11
+ TouchableOpacity,
12
+ ScrollView,
13
+ ActivityIndicator,
14
+ } from "react-native";
15
+ import { SafeAreaView } from "react-native-safe-area-context";
16
+ import type { PurchasesPackage } from "react-native-purchases";
17
+ import { AtomicText, AtomicButton } from "@umituz/react-native-design-system-atoms";
18
+ import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
19
+ import { SubscriptionPlanCard } from "./SubscriptionPlanCard";
20
+ import { PaywallFeaturesList } from "./PaywallFeaturesList";
21
+ import { PaywallLegalFooter } from "./PaywallLegalFooter";
22
+
23
+ export interface SubscriptionModalProps {
24
+ visible: boolean;
25
+ onClose: () => void;
26
+ packages: PurchasesPackage[];
27
+ onPurchase: (pkg: PurchasesPackage) => Promise<boolean>;
28
+ onRestore: () => Promise<boolean>;
29
+ title?: string;
30
+ subtitle?: string;
31
+ features?: Array<{ icon: string; text: string }>;
32
+ isLoading?: boolean;
33
+ purchaseButtonText?: string;
34
+ restoreButtonText?: string;
35
+ loadingText?: string;
36
+ emptyText?: string;
37
+ processingText?: string;
38
+ }
39
+
40
+ export const SubscriptionModal: React.FC<SubscriptionModalProps> = React.memo(
41
+ ({
42
+ visible,
43
+ onClose,
44
+ packages,
45
+ onPurchase,
46
+ onRestore,
47
+ title = "Go Premium",
48
+ subtitle,
49
+ features = [],
50
+ isLoading = false,
51
+ purchaseButtonText = "Subscribe",
52
+ restoreButtonText = "Restore Purchases",
53
+ loadingText = "Loading packages...",
54
+ emptyText = "No packages available",
55
+ processingText = "Processing...",
56
+ }) => {
57
+ const tokens = useAppDesignTokens();
58
+ const [selectedPkg, setSelectedPkg] = useState<PurchasesPackage | null>(null);
59
+ const [isProcessing, setIsProcessing] = useState(false);
60
+
61
+ const handlePurchase = useCallback(async () => {
62
+ if (!selectedPkg || isProcessing) return;
63
+ setIsProcessing(true);
64
+ try {
65
+ const success = await onPurchase(selectedPkg);
66
+ if (success) onClose();
67
+ } finally {
68
+ setIsProcessing(false);
69
+ }
70
+ }, [selectedPkg, isProcessing, onPurchase, onClose]);
71
+
72
+ const handleRestore = useCallback(async () => {
73
+ if (isProcessing) return;
74
+ setIsProcessing(true);
75
+ try {
76
+ const success = await onRestore();
77
+ if (success) onClose();
78
+ } finally {
79
+ setIsProcessing(false);
80
+ }
81
+ }, [isProcessing, onRestore, onClose]);
82
+
83
+ if (!visible) return null;
84
+
85
+ const hasPackages = packages.length > 0;
86
+ const showLoading = isLoading && !hasPackages;
87
+
88
+ return (
89
+ <Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
90
+ <View style={styles.overlay}>
91
+ <TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
92
+ <View style={[styles.content, { backgroundColor: tokens.colors.surface }]}>
93
+ <SafeAreaView edges={["bottom"]} style={styles.safeArea}>
94
+ <View style={styles.header}>
95
+ <TouchableOpacity style={styles.closeButton} onPress={onClose}>
96
+ <AtomicText style={[styles.closeIcon, { color: tokens.colors.textSecondary }]}>
97
+ ×
98
+ </AtomicText>
99
+ </TouchableOpacity>
100
+ <AtomicText
101
+ type="headlineMedium"
102
+ style={[styles.title, { color: tokens.colors.textPrimary }]}
103
+ >
104
+ {title}
105
+ </AtomicText>
106
+ {subtitle ? (
107
+ <AtomicText
108
+ type="bodyMedium"
109
+ style={[styles.subtitle, { color: tokens.colors.textSecondary }]}
110
+ >
111
+ {subtitle}
112
+ </AtomicText>
113
+ ) : null}
114
+ </View>
115
+
116
+ <ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
117
+ {showLoading ? (
118
+ <View style={styles.centerContent}>
119
+ <ActivityIndicator size="large" color={tokens.colors.primary} />
120
+ <AtomicText
121
+ type="bodyMedium"
122
+ style={[styles.loadingText, { color: tokens.colors.textSecondary }]}
123
+ >
124
+ {loadingText}
125
+ </AtomicText>
126
+ </View>
127
+ ) : !hasPackages ? (
128
+ <View style={styles.centerContent}>
129
+ <AtomicText
130
+ type="bodyMedium"
131
+ style={[styles.emptyText, { color: tokens.colors.textSecondary }]}
132
+ >
133
+ {emptyText}
134
+ </AtomicText>
135
+ </View>
136
+ ) : (
137
+ <View style={styles.packagesContainer}>
138
+ {packages.map((pkg, index) => (
139
+ <SubscriptionPlanCard
140
+ key={pkg.product.identifier}
141
+ package={pkg}
142
+ isSelected={selectedPkg?.product.identifier === pkg.product.identifier}
143
+ onSelect={() => setSelectedPkg(pkg)}
144
+ isBestValue={index === 0}
145
+ />
146
+ ))}
147
+ </View>
148
+ )}
149
+
150
+ {features.length > 0 && (
151
+ <View style={[styles.featuresSection, { backgroundColor: tokens.colors.surfaceSecondary }]}>
152
+ <PaywallFeaturesList features={features} gap={12} />
153
+ </View>
154
+ )}
155
+ </ScrollView>
156
+
157
+ <View style={styles.footer}>
158
+ {hasPackages && (
159
+ <AtomicButton
160
+ title={isProcessing ? processingText : purchaseButtonText}
161
+ onPress={handlePurchase}
162
+ disabled={!selectedPkg || isProcessing || isLoading}
163
+ />
164
+ )}
165
+ <TouchableOpacity
166
+ style={styles.restoreButton}
167
+ onPress={handleRestore}
168
+ disabled={isProcessing || isLoading}
169
+ >
170
+ <AtomicText type="bodySmall" style={{ color: tokens.colors.textSecondary }}>
171
+ {restoreButtonText}
172
+ </AtomicText>
173
+ </TouchableOpacity>
174
+ </View>
175
+
176
+ <PaywallLegalFooter />
177
+ </SafeAreaView>
178
+ </View>
179
+ </View>
180
+ </Modal>
181
+ );
182
+ }
183
+ );
184
+
185
+ SubscriptionModal.displayName = "SubscriptionModal";
186
+
187
+ const styles = StyleSheet.create({
188
+ overlay: { flex: 1, justifyContent: "flex-end" },
189
+ backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
190
+ content: { borderTopLeftRadius: 24, borderTopRightRadius: 24, maxHeight: "90%" },
191
+ safeArea: { paddingTop: 16 },
192
+ header: { alignItems: "center", paddingHorizontal: 24, paddingBottom: 20 },
193
+ closeButton: { position: "absolute", top: 0, right: 16, padding: 8, zIndex: 1 },
194
+ closeIcon: { fontSize: 28, fontWeight: "300" },
195
+ title: { marginBottom: 8, textAlign: "center" },
196
+ subtitle: { textAlign: "center" },
197
+ scrollView: { maxHeight: 400 },
198
+ scrollContent: { paddingHorizontal: 24, paddingBottom: 16 },
199
+ centerContent: { alignItems: "center", paddingVertical: 40 },
200
+ loadingText: { marginTop: 16 },
201
+ emptyText: { textAlign: "center" },
202
+ packagesContainer: { gap: 12, marginBottom: 20 },
203
+ featuresSection: { borderRadius: 16, padding: 16 },
204
+ footer: { paddingHorizontal: 24, paddingVertical: 16, gap: 12 },
205
+ restoreButton: { alignItems: "center", paddingVertical: 8 },
206
+ });