@umituz/react-native-subscription 2.2.7 → 2.2.10
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 +1 -1
- package/src/presentation/components/paywall/CreditsPackageCard.tsx +1 -1
- package/src/presentation/components/paywall/CreditsTabContent.tsx +2 -2
- package/src/presentation/components/paywall/PaywallModal.tsx +27 -9
- package/src/presentation/components/paywall/PaywallTabBar.tsx +1 -1
- package/src/presentation/components/paywall/SubscriptionFooter.tsx +90 -0
- package/src/presentation/components/paywall/SubscriptionModal.tsx +99 -110
- package/src/presentation/components/paywall/SubscriptionPackageList.tsx +90 -0
- package/src/presentation/components/paywall/SubscriptionPlanCard.tsx +1 -1
- package/src/presentation/components/paywall/SubscriptionTabContent.tsx +44 -44
- package/src/presentation/hooks/usePaywall.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.10",
|
|
4
4
|
"description": "Complete subscription management with RevenueCat, paywall UI, and credits system for React Native apps",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -7,7 +7,7 @@ import React from "react";
|
|
|
7
7
|
import { View, StyleSheet, TouchableOpacity } from "react-native";
|
|
8
8
|
import { AtomicText } from "@umituz/react-native-design-system-atoms";
|
|
9
9
|
import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
|
|
10
|
-
import type { CreditsPackage } from "
|
|
10
|
+
import type { CreditsPackage } from "../../../domain/entities/paywall/CreditsPackage";
|
|
11
11
|
|
|
12
12
|
interface CreditsPackageCardProps {
|
|
13
13
|
package: CreditsPackage;
|
|
@@ -10,7 +10,7 @@ import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
|
|
|
10
10
|
import { useLocalization } from "@umituz/react-native-localization";
|
|
11
11
|
import { CreditsPackageCard } from "./CreditsPackageCard";
|
|
12
12
|
import { PaywallLegalFooter } from "./PaywallLegalFooter";
|
|
13
|
-
import type { CreditsPackage } from "
|
|
13
|
+
import type { CreditsPackage } from "../../../domain/entities/paywall/CreditsPackage";
|
|
14
14
|
|
|
15
15
|
interface CreditsTabContentProps {
|
|
16
16
|
packages: CreditsPackage[];
|
|
@@ -42,7 +42,7 @@ export const CreditsTabContent: React.FC<CreditsTabContentProps> = React.memo(
|
|
|
42
42
|
const { t } = useLocalization();
|
|
43
43
|
|
|
44
44
|
const needsCredits = requiredCredits && requiredCredits > currentCredits;
|
|
45
|
-
|
|
45
|
+
|
|
46
46
|
const displayPurchaseButtonText = purchaseButtonText ||
|
|
47
47
|
t("paywall.purchase", { defaultValue: "Purchase" });
|
|
48
48
|
const displayProcessingText = processingText ||
|
|
@@ -9,13 +9,13 @@ import { SafeAreaView } from "react-native-safe-area-context";
|
|
|
9
9
|
import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
|
|
10
10
|
import { useLocalization } from "@umituz/react-native-localization";
|
|
11
11
|
import type { PurchasesPackage } from "react-native-purchases";
|
|
12
|
-
import { usePaywall } from "
|
|
12
|
+
import { usePaywall } from "../../hooks/usePaywall";
|
|
13
13
|
import { PaywallHeader } from "./PaywallHeader";
|
|
14
14
|
import { PaywallTabBar } from "./PaywallTabBar";
|
|
15
15
|
import { CreditsTabContent } from "./CreditsTabContent";
|
|
16
16
|
import { SubscriptionTabContent } from "./SubscriptionTabContent";
|
|
17
|
-
import type { PaywallTabType } from "
|
|
18
|
-
import type { CreditsPackage } from "
|
|
17
|
+
import type { PaywallTabType } from "../../../domain/entities/paywall/PaywallTab";
|
|
18
|
+
import type { CreditsPackage } from "../../../domain/entities/paywall/CreditsPackage";
|
|
19
19
|
|
|
20
20
|
interface PaywallModalProps {
|
|
21
21
|
visible: boolean;
|
|
@@ -27,10 +27,16 @@ interface PaywallModalProps {
|
|
|
27
27
|
requiredCredits?: number;
|
|
28
28
|
onCreditsPurchase: (packageId: string) => Promise<void>;
|
|
29
29
|
onSubscriptionPurchase: (pkg: PurchasesPackage) => Promise<void>;
|
|
30
|
+
onRestore?: () => Promise<void>;
|
|
30
31
|
subscriptionFeatures?: Array<{ icon: string; text: string }>;
|
|
31
32
|
isLoading?: boolean;
|
|
32
33
|
title?: string;
|
|
33
34
|
subtitle?: string;
|
|
35
|
+
privacyUrl?: string;
|
|
36
|
+
termsUrl?: string;
|
|
37
|
+
privacyText?: string;
|
|
38
|
+
termsOfServiceText?: string;
|
|
39
|
+
restoreButtonText?: string;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
export const PaywallModal: React.FC<PaywallModalProps> = React.memo(
|
|
@@ -44,10 +50,16 @@ export const PaywallModal: React.FC<PaywallModalProps> = React.memo(
|
|
|
44
50
|
requiredCredits,
|
|
45
51
|
onCreditsPurchase,
|
|
46
52
|
onSubscriptionPurchase,
|
|
53
|
+
onRestore,
|
|
47
54
|
subscriptionFeatures = [],
|
|
48
55
|
isLoading = false,
|
|
49
56
|
title,
|
|
50
57
|
subtitle,
|
|
58
|
+
privacyUrl,
|
|
59
|
+
termsUrl,
|
|
60
|
+
privacyText,
|
|
61
|
+
termsOfServiceText,
|
|
62
|
+
restoreButtonText,
|
|
51
63
|
}) => {
|
|
52
64
|
const tokens = useAppDesignTokens();
|
|
53
65
|
const { t } = useLocalization();
|
|
@@ -101,12 +113,12 @@ export const PaywallModal: React.FC<PaywallModalProps> = React.memo(
|
|
|
101
113
|
onClose={onClose}
|
|
102
114
|
/>
|
|
103
115
|
|
|
104
|
-
<PaywallTabBar
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
<PaywallTabBar
|
|
117
|
+
activeTab={activeTab}
|
|
118
|
+
onTabChange={handleTabChange}
|
|
119
|
+
creditsLabel={t("paywall.tabs.credits", { defaultValue: "Credits" })}
|
|
120
|
+
subscriptionLabel={t("paywall.tabs.subscription", { defaultValue: "Subscription" })}
|
|
121
|
+
/>
|
|
110
122
|
|
|
111
123
|
{activeTab === "credits" ? (
|
|
112
124
|
<CreditsTabContent
|
|
@@ -128,6 +140,12 @@ export const PaywallModal: React.FC<PaywallModalProps> = React.memo(
|
|
|
128
140
|
features={subscriptionFeatures}
|
|
129
141
|
isLoading={isLoading}
|
|
130
142
|
purchaseButtonText={t("paywall.subscribe", { defaultValue: "Subscribe" })}
|
|
143
|
+
onRestore={onRestore}
|
|
144
|
+
privacyUrl={privacyUrl}
|
|
145
|
+
termsUrl={termsUrl}
|
|
146
|
+
privacyText={privacyText}
|
|
147
|
+
termsOfServiceText={termsOfServiceText}
|
|
148
|
+
restoreButtonText={restoreButtonText}
|
|
131
149
|
/>
|
|
132
150
|
)}
|
|
133
151
|
</SafeAreaView>
|
|
@@ -7,7 +7,7 @@ import React from "react";
|
|
|
7
7
|
import { View, TouchableOpacity, StyleSheet } from "react-native";
|
|
8
8
|
import { AtomicText } from "@umituz/react-native-design-system-atoms";
|
|
9
9
|
import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
|
|
10
|
-
import type { PaywallTabType } from "
|
|
10
|
+
import type { PaywallTabType } from "../../../domain/entities/paywall/PaywallTab";
|
|
11
11
|
|
|
12
12
|
interface PaywallTabBarProps {
|
|
13
13
|
activeTab: PaywallTabType;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, StyleSheet, TouchableOpacity } from "react-native";
|
|
3
|
+
import { AtomicButton, AtomicText } from "@umituz/react-native-design-system-atoms";
|
|
4
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
|
|
5
|
+
import { PaywallLegalFooter } from "./PaywallLegalFooter";
|
|
6
|
+
|
|
7
|
+
interface SubscriptionFooterProps {
|
|
8
|
+
isProcessing: boolean;
|
|
9
|
+
isLoading: boolean;
|
|
10
|
+
processingText: string;
|
|
11
|
+
purchaseButtonText: string;
|
|
12
|
+
hasPackages: boolean;
|
|
13
|
+
selectedPkg: any; // Using any to avoid circular deps if needed, but preferably strict
|
|
14
|
+
restoreButtonText: string;
|
|
15
|
+
showRestoreButton: boolean;
|
|
16
|
+
privacyUrl?: string;
|
|
17
|
+
termsUrl?: string;
|
|
18
|
+
privacyText?: string;
|
|
19
|
+
termsOfServiceText?: string;
|
|
20
|
+
onPurchase: () => void;
|
|
21
|
+
onRestore: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const SubscriptionFooter: React.FC<SubscriptionFooterProps> = React.memo(
|
|
25
|
+
({
|
|
26
|
+
isProcessing,
|
|
27
|
+
isLoading,
|
|
28
|
+
processingText,
|
|
29
|
+
purchaseButtonText,
|
|
30
|
+
hasPackages,
|
|
31
|
+
selectedPkg,
|
|
32
|
+
restoreButtonText,
|
|
33
|
+
showRestoreButton,
|
|
34
|
+
privacyUrl,
|
|
35
|
+
termsUrl,
|
|
36
|
+
privacyText,
|
|
37
|
+
termsOfServiceText,
|
|
38
|
+
onPurchase,
|
|
39
|
+
onRestore,
|
|
40
|
+
}) => {
|
|
41
|
+
const tokens = useAppDesignTokens();
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<View style={styles.container}>
|
|
45
|
+
<View style={styles.actions}>
|
|
46
|
+
{hasPackages && (
|
|
47
|
+
<AtomicButton
|
|
48
|
+
title={isProcessing ? processingText : purchaseButtonText}
|
|
49
|
+
onPress={onPurchase}
|
|
50
|
+
disabled={!selectedPkg || isProcessing || isLoading}
|
|
51
|
+
/>
|
|
52
|
+
)}
|
|
53
|
+
{showRestoreButton && (
|
|
54
|
+
<TouchableOpacity
|
|
55
|
+
style={styles.restoreButton}
|
|
56
|
+
onPress={onRestore}
|
|
57
|
+
disabled={isProcessing || isLoading}
|
|
58
|
+
>
|
|
59
|
+
<AtomicText type="bodySmall" style={{ color: tokens.colors.textSecondary }}>
|
|
60
|
+
{restoreButtonText}
|
|
61
|
+
</AtomicText>
|
|
62
|
+
</TouchableOpacity>
|
|
63
|
+
)}
|
|
64
|
+
</View>
|
|
65
|
+
|
|
66
|
+
<PaywallLegalFooter
|
|
67
|
+
privacyUrl={privacyUrl}
|
|
68
|
+
termsUrl={termsUrl}
|
|
69
|
+
privacyText={privacyText}
|
|
70
|
+
termsOfServiceText={termsOfServiceText}
|
|
71
|
+
/>
|
|
72
|
+
</View>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
SubscriptionFooter.displayName = "SubscriptionFooter";
|
|
78
|
+
|
|
79
|
+
const styles = StyleSheet.create({
|
|
80
|
+
container: {},
|
|
81
|
+
actions: {
|
|
82
|
+
paddingHorizontal: 24,
|
|
83
|
+
paddingVertical: 16,
|
|
84
|
+
gap: 12
|
|
85
|
+
},
|
|
86
|
+
restoreButton: {
|
|
87
|
+
alignItems: "center",
|
|
88
|
+
paddingVertical: 8
|
|
89
|
+
},
|
|
90
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Subscription Modal Component
|
|
3
|
-
*
|
|
3
|
+
* Orchestrates subscription flow
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import React, { useState, useCallback } from "react";
|
|
@@ -10,15 +10,14 @@ import {
|
|
|
10
10
|
StyleSheet,
|
|
11
11
|
TouchableOpacity,
|
|
12
12
|
ScrollView,
|
|
13
|
-
ActivityIndicator,
|
|
14
13
|
} from "react-native";
|
|
15
14
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|
16
15
|
import type { PurchasesPackage } from "react-native-purchases";
|
|
17
|
-
import { AtomicText
|
|
16
|
+
import { AtomicText } from "@umituz/react-native-design-system-atoms";
|
|
18
17
|
import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
|
|
19
|
-
import { SubscriptionPlanCard } from "./SubscriptionPlanCard";
|
|
20
18
|
import { PaywallFeaturesList } from "./PaywallFeaturesList";
|
|
21
|
-
import {
|
|
19
|
+
import { SubscriptionPackageList } from "./SubscriptionPackageList";
|
|
20
|
+
import { SubscriptionFooter } from "./SubscriptionFooter";
|
|
22
21
|
|
|
23
22
|
export interface SubscriptionModalProps {
|
|
24
23
|
visible: boolean;
|
|
@@ -40,6 +39,8 @@ export interface SubscriptionModalProps {
|
|
|
40
39
|
privacyText?: string;
|
|
41
40
|
termsOfServiceText?: string;
|
|
42
41
|
showRestoreButton?: boolean;
|
|
42
|
+
variant?: "bottom-sheet" | "fullscreen";
|
|
43
|
+
BackgroundComponent?: React.ComponentType<any>;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export const SubscriptionModal: React.FC<SubscriptionModalProps> = React.memo(
|
|
@@ -63,6 +64,8 @@ export const SubscriptionModal: React.FC<SubscriptionModalProps> = React.memo(
|
|
|
63
64
|
privacyText,
|
|
64
65
|
termsOfServiceText,
|
|
65
66
|
showRestoreButton = true,
|
|
67
|
+
variant = "bottom-sheet",
|
|
68
|
+
BackgroundComponent,
|
|
66
69
|
}) => {
|
|
67
70
|
const tokens = useAppDesignTokens();
|
|
68
71
|
const [selectedPkg, setSelectedPkg] = useState<PurchasesPackage | null>(null);
|
|
@@ -92,106 +95,92 @@ export const SubscriptionModal: React.FC<SubscriptionModalProps> = React.memo(
|
|
|
92
95
|
|
|
93
96
|
if (!visible) return null;
|
|
94
97
|
|
|
95
|
-
const
|
|
96
|
-
|
|
98
|
+
const isFullScreen = variant === "fullscreen";
|
|
99
|
+
|
|
100
|
+
const Content = (
|
|
101
|
+
<SafeAreaView
|
|
102
|
+
edges={["top", "bottom"]}
|
|
103
|
+
style={isFullScreen ? styles.safeAreaFullScreen : styles.safeAreaBottomSheet}
|
|
104
|
+
>
|
|
105
|
+
<View style={styles.header}>
|
|
106
|
+
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
|
107
|
+
<AtomicText style={[styles.closeIcon, { color: tokens.colors.textSecondary }]}>
|
|
108
|
+
×
|
|
109
|
+
</AtomicText>
|
|
110
|
+
</TouchableOpacity>
|
|
111
|
+
<AtomicText
|
|
112
|
+
type="headlineMedium"
|
|
113
|
+
style={[styles.title, { color: tokens.colors.textPrimary }]}
|
|
114
|
+
>
|
|
115
|
+
{title}
|
|
116
|
+
</AtomicText>
|
|
117
|
+
{subtitle && (
|
|
118
|
+
<AtomicText
|
|
119
|
+
type="bodyMedium"
|
|
120
|
+
style={[styles.subtitle, { color: tokens.colors.textSecondary }]}
|
|
121
|
+
>
|
|
122
|
+
{subtitle}
|
|
123
|
+
</AtomicText>
|
|
124
|
+
)}
|
|
125
|
+
</View>
|
|
126
|
+
|
|
127
|
+
<ScrollView
|
|
128
|
+
style={isFullScreen ? styles.scrollViewFullScreen : styles.scrollViewBottomSheet}
|
|
129
|
+
contentContainerStyle={styles.scrollContent}
|
|
130
|
+
>
|
|
131
|
+
<SubscriptionPackageList
|
|
132
|
+
packages={packages}
|
|
133
|
+
isLoading={isLoading}
|
|
134
|
+
selectedPkg={selectedPkg}
|
|
135
|
+
onSelect={setSelectedPkg}
|
|
136
|
+
loadingText={loadingText}
|
|
137
|
+
emptyText={emptyText}
|
|
138
|
+
/>
|
|
139
|
+
{features.length > 0 && (
|
|
140
|
+
<View style={[styles.featuresSection, { backgroundColor: tokens.colors.surfaceSecondary }]}>
|
|
141
|
+
<PaywallFeaturesList features={features} gap={12} />
|
|
142
|
+
</View>
|
|
143
|
+
)}
|
|
144
|
+
</ScrollView>
|
|
145
|
+
|
|
146
|
+
<SubscriptionFooter
|
|
147
|
+
isProcessing={isProcessing}
|
|
148
|
+
isLoading={isLoading}
|
|
149
|
+
processingText={processingText}
|
|
150
|
+
purchaseButtonText={purchaseButtonText}
|
|
151
|
+
hasPackages={packages.length > 0}
|
|
152
|
+
selectedPkg={selectedPkg}
|
|
153
|
+
restoreButtonText={restoreButtonText}
|
|
154
|
+
showRestoreButton={showRestoreButton}
|
|
155
|
+
privacyUrl={privacyUrl}
|
|
156
|
+
termsUrl={termsUrl}
|
|
157
|
+
privacyText={privacyText}
|
|
158
|
+
termsOfServiceText={termsOfServiceText}
|
|
159
|
+
onPurchase={handlePurchase}
|
|
160
|
+
onRestore={handleRestore}
|
|
161
|
+
/>
|
|
162
|
+
</SafeAreaView>
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (variant === "fullscreen") {
|
|
166
|
+
const Wrapper = BackgroundComponent || View;
|
|
167
|
+
const wrapperStyle = !BackgroundComponent ? { flex: 1, backgroundColor: tokens.colors.backgroundPrimary } : { flex: 1 };
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<Modal visible={visible} transparent={false} animationType="slide" onRequestClose={onClose}>
|
|
171
|
+
<Wrapper style={wrapperStyle}>
|
|
172
|
+
{Content}
|
|
173
|
+
</Wrapper>
|
|
174
|
+
</Modal>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
97
177
|
|
|
98
178
|
return (
|
|
99
179
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
|
100
180
|
<View style={styles.overlay}>
|
|
101
181
|
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
|
|
102
|
-
<View style={[styles.
|
|
103
|
-
|
|
104
|
-
<View style={styles.header}>
|
|
105
|
-
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
|
106
|
-
<AtomicText style={[styles.closeIcon, { color: tokens.colors.textSecondary }]}>
|
|
107
|
-
×
|
|
108
|
-
</AtomicText>
|
|
109
|
-
</TouchableOpacity>
|
|
110
|
-
<AtomicText
|
|
111
|
-
type="headlineMedium"
|
|
112
|
-
style={[styles.title, { color: tokens.colors.textPrimary }]}
|
|
113
|
-
>
|
|
114
|
-
{title}
|
|
115
|
-
</AtomicText>
|
|
116
|
-
{subtitle ? (
|
|
117
|
-
<AtomicText
|
|
118
|
-
type="bodyMedium"
|
|
119
|
-
style={[styles.subtitle, { color: tokens.colors.textSecondary }]}
|
|
120
|
-
>
|
|
121
|
-
{subtitle}
|
|
122
|
-
</AtomicText>
|
|
123
|
-
) : null}
|
|
124
|
-
</View>
|
|
125
|
-
|
|
126
|
-
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
|
|
127
|
-
{showLoading ? (
|
|
128
|
-
<View style={styles.centerContent}>
|
|
129
|
-
<ActivityIndicator size="large" color={tokens.colors.primary} />
|
|
130
|
-
<AtomicText
|
|
131
|
-
type="bodyMedium"
|
|
132
|
-
style={[styles.loadingText, { color: tokens.colors.textSecondary }]}
|
|
133
|
-
>
|
|
134
|
-
{loadingText}
|
|
135
|
-
</AtomicText>
|
|
136
|
-
</View>
|
|
137
|
-
) : !hasPackages ? (
|
|
138
|
-
<View style={styles.centerContent}>
|
|
139
|
-
<AtomicText
|
|
140
|
-
type="bodyMedium"
|
|
141
|
-
style={[styles.emptyText, { color: tokens.colors.textSecondary }]}
|
|
142
|
-
>
|
|
143
|
-
{emptyText}
|
|
144
|
-
</AtomicText>
|
|
145
|
-
</View>
|
|
146
|
-
) : (
|
|
147
|
-
<View style={styles.packagesContainer}>
|
|
148
|
-
{packages.map((pkg, index) => (
|
|
149
|
-
<SubscriptionPlanCard
|
|
150
|
-
key={pkg.product.identifier}
|
|
151
|
-
package={pkg}
|
|
152
|
-
isSelected={selectedPkg?.product.identifier === pkg.product.identifier}
|
|
153
|
-
onSelect={() => setSelectedPkg(pkg)}
|
|
154
|
-
isBestValue={index === 0}
|
|
155
|
-
/>
|
|
156
|
-
))}
|
|
157
|
-
</View>
|
|
158
|
-
)}
|
|
159
|
-
|
|
160
|
-
{features.length > 0 && (
|
|
161
|
-
<View style={[styles.featuresSection, { backgroundColor: tokens.colors.surfaceSecondary }]}>
|
|
162
|
-
<PaywallFeaturesList features={features} gap={12} />
|
|
163
|
-
</View>
|
|
164
|
-
)}
|
|
165
|
-
</ScrollView>
|
|
166
|
-
|
|
167
|
-
<View style={styles.footer}>
|
|
168
|
-
{hasPackages && (
|
|
169
|
-
<AtomicButton
|
|
170
|
-
title={isProcessing ? processingText : purchaseButtonText}
|
|
171
|
-
onPress={handlePurchase}
|
|
172
|
-
disabled={!selectedPkg || isProcessing || isLoading}
|
|
173
|
-
/>
|
|
174
|
-
)}
|
|
175
|
-
{showRestoreButton && (
|
|
176
|
-
<TouchableOpacity
|
|
177
|
-
style={styles.restoreButton}
|
|
178
|
-
onPress={handleRestore}
|
|
179
|
-
disabled={isProcessing || isLoading}
|
|
180
|
-
>
|
|
181
|
-
<AtomicText type="bodySmall" style={{ color: tokens.colors.textSecondary }}>
|
|
182
|
-
{restoreButtonText}
|
|
183
|
-
</AtomicText>
|
|
184
|
-
</TouchableOpacity>
|
|
185
|
-
)}
|
|
186
|
-
</View>
|
|
187
|
-
|
|
188
|
-
<PaywallLegalFooter
|
|
189
|
-
privacyUrl={privacyUrl}
|
|
190
|
-
termsUrl={termsUrl}
|
|
191
|
-
privacyText={privacyText}
|
|
192
|
-
termsOfServiceText={termsOfServiceText}
|
|
193
|
-
/>
|
|
194
|
-
</SafeAreaView>
|
|
182
|
+
<View style={[styles.bottomSheetContent, { backgroundColor: tokens.colors.surface }]}>
|
|
183
|
+
{Content}
|
|
195
184
|
</View>
|
|
196
185
|
</View>
|
|
197
186
|
</Modal>
|
|
@@ -204,20 +193,20 @@ SubscriptionModal.displayName = "SubscriptionModal";
|
|
|
204
193
|
const styles = StyleSheet.create({
|
|
205
194
|
overlay: { flex: 1, justifyContent: "flex-end" },
|
|
206
195
|
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
|
|
207
|
-
|
|
208
|
-
|
|
196
|
+
bottomSheetContent: { borderTopLeftRadius: 24, borderTopRightRadius: 24, maxHeight: "90%" },
|
|
197
|
+
|
|
198
|
+
safeAreaFullScreen: { flex: 1, paddingTop: 16 },
|
|
199
|
+
safeAreaBottomSheet: { paddingTop: 16 },
|
|
200
|
+
|
|
209
201
|
header: { alignItems: "center", paddingHorizontal: 24, paddingBottom: 20 },
|
|
210
202
|
closeButton: { position: "absolute", top: 0, right: 16, padding: 8, zIndex: 1 },
|
|
211
203
|
closeIcon: { fontSize: 28, fontWeight: "300" },
|
|
212
204
|
title: { marginBottom: 8, textAlign: "center" },
|
|
213
205
|
subtitle: { textAlign: "center" },
|
|
214
|
-
|
|
206
|
+
|
|
207
|
+
scrollViewFullScreen: { flex: 1 },
|
|
208
|
+
scrollViewBottomSheet: { maxHeight: 400 },
|
|
209
|
+
|
|
215
210
|
scrollContent: { paddingHorizontal: 24, paddingBottom: 16 },
|
|
216
|
-
|
|
217
|
-
loadingText: { marginTop: 16 },
|
|
218
|
-
emptyText: { textAlign: "center" },
|
|
219
|
-
packagesContainer: { gap: 12, marginBottom: 20 },
|
|
220
|
-
featuresSection: { borderRadius: 16, padding: 16 },
|
|
221
|
-
footer: { paddingHorizontal: 24, paddingVertical: 16, gap: 12 },
|
|
222
|
-
restoreButton: { alignItems: "center", paddingVertical: 8 },
|
|
211
|
+
featuresSection: { borderRadius: 16, padding: 16, marginTop: 20 },
|
|
223
212
|
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, StyleSheet, ActivityIndicator } from "react-native";
|
|
3
|
+
import { AtomicText } from "@umituz/react-native-design-system-atoms";
|
|
4
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
|
|
5
|
+
import type { PurchasesPackage } from "react-native-purchases";
|
|
6
|
+
import { SubscriptionPlanCard } from "./SubscriptionPlanCard";
|
|
7
|
+
|
|
8
|
+
interface SubscriptionPackageListProps {
|
|
9
|
+
isLoading: boolean;
|
|
10
|
+
packages: PurchasesPackage[];
|
|
11
|
+
selectedPkg: PurchasesPackage | null;
|
|
12
|
+
loadingText: string;
|
|
13
|
+
emptyText: string;
|
|
14
|
+
onSelect: (pkg: PurchasesPackage) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const SubscriptionPackageList: React.FC<SubscriptionPackageListProps> = React.memo(
|
|
18
|
+
({
|
|
19
|
+
isLoading,
|
|
20
|
+
packages,
|
|
21
|
+
selectedPkg,
|
|
22
|
+
loadingText,
|
|
23
|
+
emptyText,
|
|
24
|
+
onSelect,
|
|
25
|
+
}) => {
|
|
26
|
+
const tokens = useAppDesignTokens();
|
|
27
|
+
const hasPackages = packages.length > 0;
|
|
28
|
+
const showLoading = isLoading && !hasPackages;
|
|
29
|
+
|
|
30
|
+
if (showLoading) {
|
|
31
|
+
return (
|
|
32
|
+
<View style={styles.centerContent}>
|
|
33
|
+
<ActivityIndicator size="large" color={tokens.colors.primary} />
|
|
34
|
+
<AtomicText
|
|
35
|
+
type="bodyMedium"
|
|
36
|
+
style={[styles.loadingText, { color: tokens.colors.textSecondary }]}
|
|
37
|
+
>
|
|
38
|
+
{loadingText}
|
|
39
|
+
</AtomicText>
|
|
40
|
+
</View>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!hasPackages) {
|
|
45
|
+
return (
|
|
46
|
+
<View style={styles.centerContent}>
|
|
47
|
+
<AtomicText
|
|
48
|
+
type="bodyMedium"
|
|
49
|
+
style={[styles.emptyText, { color: tokens.colors.textSecondary }]}
|
|
50
|
+
>
|
|
51
|
+
{emptyText}
|
|
52
|
+
</AtomicText>
|
|
53
|
+
</View>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<View style={styles.packagesContainer}>
|
|
59
|
+
{packages.map((pkg, index) => (
|
|
60
|
+
<SubscriptionPlanCard
|
|
61
|
+
key={pkg.product.identifier}
|
|
62
|
+
package={pkg}
|
|
63
|
+
isSelected={selectedPkg?.product.identifier === pkg.product.identifier}
|
|
64
|
+
onSelect={() => onSelect(pkg)}
|
|
65
|
+
isBestValue={index === 0}
|
|
66
|
+
/>
|
|
67
|
+
))}
|
|
68
|
+
</View>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
SubscriptionPackageList.displayName = "SubscriptionPackageList";
|
|
74
|
+
|
|
75
|
+
const styles = StyleSheet.create({
|
|
76
|
+
centerContent: {
|
|
77
|
+
alignItems: "center",
|
|
78
|
+
paddingVertical: 40
|
|
79
|
+
},
|
|
80
|
+
loadingText: {
|
|
81
|
+
marginTop: 16
|
|
82
|
+
},
|
|
83
|
+
emptyText: {
|
|
84
|
+
textAlign: "center"
|
|
85
|
+
},
|
|
86
|
+
packagesContainer: {
|
|
87
|
+
gap: 12,
|
|
88
|
+
marginBottom: 20
|
|
89
|
+
},
|
|
90
|
+
});
|
|
@@ -8,7 +8,7 @@ import { View, TouchableOpacity, StyleSheet } from "react-native";
|
|
|
8
8
|
import type { PurchasesPackage } from "react-native-purchases";
|
|
9
9
|
import { AtomicText } from "@umituz/react-native-design-system-atoms";
|
|
10
10
|
import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
|
|
11
|
-
import { formatPrice } from "
|
|
11
|
+
import { formatPrice } from "../../../utils/priceUtils";
|
|
12
12
|
import { useLocalization } from "@umituz/react-native-localization";
|
|
13
13
|
|
|
14
14
|
interface SubscriptionPlanCardProps {
|
|
@@ -5,23 +5,28 @@
|
|
|
5
5
|
|
|
6
6
|
import React, { useMemo } from "react";
|
|
7
7
|
import { View, StyleSheet, ScrollView } from "react-native";
|
|
8
|
-
import { AtomicButton } from "@umituz/react-native-design-system-atoms";
|
|
9
8
|
import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
|
|
10
9
|
import { useLocalization } from "@umituz/react-native-localization";
|
|
11
10
|
import type { PurchasesPackage } from "react-native-purchases";
|
|
12
|
-
import { SubscriptionPlanCard } from "./SubscriptionPlanCard";
|
|
13
11
|
import { PaywallFeaturesList } from "./PaywallFeaturesList";
|
|
14
|
-
import {
|
|
12
|
+
import { SubscriptionPackageList } from "./SubscriptionPackageList";
|
|
13
|
+
import { SubscriptionFooter } from "./SubscriptionFooter";
|
|
15
14
|
|
|
16
15
|
interface SubscriptionTabContentProps {
|
|
17
16
|
packages: PurchasesPackage[];
|
|
18
17
|
selectedPackage: PurchasesPackage | null;
|
|
19
18
|
onSelectPackage: (pkg: PurchasesPackage) => void;
|
|
20
19
|
onPurchase: () => void;
|
|
20
|
+
onRestore?: () => void;
|
|
21
21
|
features?: Array<{ icon: string; text: string }>;
|
|
22
22
|
isLoading?: boolean;
|
|
23
23
|
purchaseButtonText?: string;
|
|
24
24
|
processingText?: string;
|
|
25
|
+
restoreButtonText?: string;
|
|
26
|
+
privacyUrl?: string;
|
|
27
|
+
termsUrl?: string;
|
|
28
|
+
privacyText?: string;
|
|
29
|
+
termsOfServiceText?: string;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
const isYearlyPackage = (pkg: PurchasesPackage): boolean => {
|
|
@@ -46,26 +51,27 @@ export const SubscriptionTabContent: React.FC<SubscriptionTabContentProps> =
|
|
|
46
51
|
selectedPackage,
|
|
47
52
|
onSelectPackage,
|
|
48
53
|
onPurchase,
|
|
54
|
+
onRestore,
|
|
49
55
|
features = [],
|
|
50
56
|
isLoading = false,
|
|
51
57
|
purchaseButtonText,
|
|
52
58
|
processingText,
|
|
59
|
+
restoreButtonText = "Restore Purchases",
|
|
60
|
+
privacyUrl,
|
|
61
|
+
termsUrl,
|
|
62
|
+
privacyText,
|
|
63
|
+
termsOfServiceText,
|
|
53
64
|
}) => {
|
|
54
65
|
const tokens = useAppDesignTokens();
|
|
55
66
|
const { t } = useLocalization();
|
|
56
67
|
|
|
57
|
-
const displayPurchaseButtonText =
|
|
58
|
-
t("paywall.subscribe", { defaultValue: "Subscribe" });
|
|
59
|
-
const displayProcessingText =
|
|
60
|
-
t("paywall.processing", { defaultValue: "Processing..." });
|
|
68
|
+
const displayPurchaseButtonText =
|
|
69
|
+
purchaseButtonText || t("paywall.subscribe", { defaultValue: "Subscribe" });
|
|
70
|
+
const displayProcessingText =
|
|
71
|
+
processingText || t("paywall.processing", { defaultValue: "Processing..." });
|
|
61
72
|
|
|
62
73
|
const sortedPackages = useMemo(() => sortPackages(packages), [packages]);
|
|
63
74
|
|
|
64
|
-
const firstYearlyIndex = useMemo(
|
|
65
|
-
() => sortedPackages.findIndex(isYearlyPackage),
|
|
66
|
-
[sortedPackages],
|
|
67
|
-
);
|
|
68
|
-
|
|
69
75
|
return (
|
|
70
76
|
<View style={styles.container}>
|
|
71
77
|
<ScrollView
|
|
@@ -73,20 +79,14 @@ export const SubscriptionTabContent: React.FC<SubscriptionTabContentProps> =
|
|
|
73
79
|
contentContainerStyle={styles.scrollContent}
|
|
74
80
|
showsVerticalScrollIndicator={false}
|
|
75
81
|
>
|
|
76
|
-
<
|
|
77
|
-
{sortedPackages
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
onSelect={() => onSelectPackage(pkg)}
|
|
86
|
-
isBestValue={index === firstYearlyIndex}
|
|
87
|
-
/>
|
|
88
|
-
))}
|
|
89
|
-
</View>
|
|
82
|
+
<SubscriptionPackageList
|
|
83
|
+
packages={sortedPackages}
|
|
84
|
+
isLoading={isLoading}
|
|
85
|
+
selectedPkg={selectedPackage}
|
|
86
|
+
onSelect={onSelectPackage}
|
|
87
|
+
loadingText={t("paywall.loading", { defaultValue: "Loading..." })}
|
|
88
|
+
emptyText={t("paywall.empty", { defaultValue: "No packages" })}
|
|
89
|
+
/>
|
|
90
90
|
|
|
91
91
|
{features.length > 0 && (
|
|
92
92
|
<View
|
|
@@ -100,18 +100,25 @@ export const SubscriptionTabContent: React.FC<SubscriptionTabContentProps> =
|
|
|
100
100
|
)}
|
|
101
101
|
</ScrollView>
|
|
102
102
|
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
103
|
+
<SubscriptionFooter
|
|
104
|
+
isProcessing={false} // Tab content usually delegated processing to external state, currently no isProcessing prop passed. Assuming false or controlled by isLoading.
|
|
105
|
+
isLoading={isLoading}
|
|
106
|
+
processingText={displayProcessingText}
|
|
107
|
+
purchaseButtonText={displayPurchaseButtonText}
|
|
108
|
+
hasPackages={packages.length > 0}
|
|
109
|
+
selectedPkg={selectedPackage}
|
|
110
|
+
restoreButtonText={restoreButtonText}
|
|
111
|
+
showRestoreButton={!!onRestore}
|
|
112
|
+
onPurchase={onPurchase}
|
|
113
|
+
onRestore={onRestore || (() => { })}
|
|
114
|
+
privacyUrl={privacyUrl}
|
|
115
|
+
termsUrl={termsUrl}
|
|
116
|
+
privacyText={privacyText}
|
|
117
|
+
termsOfServiceText={termsOfServiceText}
|
|
118
|
+
/>
|
|
112
119
|
</View>
|
|
113
120
|
);
|
|
114
|
-
}
|
|
121
|
+
}
|
|
115
122
|
);
|
|
116
123
|
|
|
117
124
|
SubscriptionTabContent.displayName = "SubscriptionTabContent";
|
|
@@ -127,16 +134,9 @@ const styles = StyleSheet.create({
|
|
|
127
134
|
paddingHorizontal: 24,
|
|
128
135
|
paddingBottom: 16,
|
|
129
136
|
},
|
|
130
|
-
plansContainer: {
|
|
131
|
-
gap: 12,
|
|
132
|
-
marginBottom: 20,
|
|
133
|
-
},
|
|
134
137
|
featuresSection: {
|
|
135
138
|
borderRadius: 16,
|
|
136
139
|
padding: 16,
|
|
137
|
-
|
|
138
|
-
footer: {
|
|
139
|
-
padding: 24,
|
|
140
|
-
paddingTop: 16,
|
|
140
|
+
marginTop: 20,
|
|
141
141
|
},
|
|
142
142
|
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import type { PurchasesPackage } from "react-native-purchases";
|
|
3
|
+
import type { PaywallTabType } from "../../domain/entities/paywall/PaywallTab";
|
|
4
|
+
|
|
5
|
+
interface UsePaywallProps {
|
|
6
|
+
initialTab?: PaywallTabType;
|
|
7
|
+
onCreditsPurchase: (packageId: string) => Promise<void>;
|
|
8
|
+
onSubscriptionPurchase: (pkg: PurchasesPackage) => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const usePaywall = ({
|
|
12
|
+
initialTab = "credits",
|
|
13
|
+
onCreditsPurchase,
|
|
14
|
+
onSubscriptionPurchase,
|
|
15
|
+
}: UsePaywallProps) => {
|
|
16
|
+
const [activeTab, setActiveTab] = useState<PaywallTabType>(initialTab);
|
|
17
|
+
const [selectedCreditsPackageId, setSelectedCreditsPackageId] = useState<string | null>(null);
|
|
18
|
+
const [selectedSubscriptionPkg, setSelectedSubscriptionPkg] = useState<PurchasesPackage | null>(null);
|
|
19
|
+
|
|
20
|
+
const handleTabChange = useCallback((tab: PaywallTabType) => {
|
|
21
|
+
setActiveTab(tab);
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
const handleCreditsPackageSelect = useCallback((packageId: string) => {
|
|
25
|
+
setSelectedCreditsPackageId(packageId);
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
const handleSubscriptionPackageSelect = useCallback((pkg: PurchasesPackage) => {
|
|
29
|
+
setSelectedSubscriptionPkg(pkg);
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
const handleCreditsPurchase = useCallback(async () => {
|
|
33
|
+
if (selectedCreditsPackageId) {
|
|
34
|
+
await onCreditsPurchase(selectedCreditsPackageId);
|
|
35
|
+
}
|
|
36
|
+
}, [selectedCreditsPackageId, onCreditsPurchase]);
|
|
37
|
+
|
|
38
|
+
const handleSubscriptionPurchase = useCallback(async () => {
|
|
39
|
+
if (selectedSubscriptionPkg) {
|
|
40
|
+
await onSubscriptionPurchase(selectedSubscriptionPkg);
|
|
41
|
+
}
|
|
42
|
+
}, [selectedSubscriptionPkg, onSubscriptionPurchase]);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
activeTab,
|
|
46
|
+
selectedCreditsPackageId,
|
|
47
|
+
selectedSubscriptionPkg,
|
|
48
|
+
handleTabChange,
|
|
49
|
+
handleCreditsPackageSelect,
|
|
50
|
+
handleSubscriptionPackageSelect,
|
|
51
|
+
handleCreditsPurchase,
|
|
52
|
+
handleSubscriptionPurchase,
|
|
53
|
+
};
|
|
54
|
+
};
|