@umituz/react-native-subscription 1.5.1 → 1.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Subscription management and paywall UI for React Native apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -36,7 +36,9 @@
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/react": "~19.1.0",
39
- "typescript": "~5.9.2"
39
+ "typescript": "~5.9.2",
40
+ "react-native": "~0.76.0",
41
+ "@umituz/react-native-design-system-theme": "latest"
40
42
  },
41
43
  "publishConfig": {
42
44
  "access": "public"
package/src/index.ts CHANGED
@@ -53,6 +53,11 @@ export {
53
53
  export { useSubscription } from "./presentation/hooks/useSubscription";
54
54
  export type { UseSubscriptionResult } from "./presentation/hooks/useSubscription";
55
55
 
56
+ export {
57
+ useSubscriptionDetails,
58
+ type SubscriptionDetails,
59
+ } from "./presentation/hooks/useSubscriptionDetails";
60
+
56
61
  export {
57
62
  usePremiumGate,
58
63
  type UsePremiumGateParams,
@@ -86,6 +91,23 @@ export { PaywallFeaturesList } from "./presentation/components/paywall/PaywallFe
86
91
  export { PaywallFeatureItem } from "./presentation/components/paywall/PaywallFeatureItem";
87
92
  export { PaywallLegalFooter } from "./presentation/components/paywall/PaywallLegalFooter";
88
93
 
94
+ // =============================================================================
95
+ // PRESENTATION LAYER - Premium Details Components
96
+ // =============================================================================
97
+
98
+ export {
99
+ PremiumDetailsCard,
100
+ type PremiumDetailsCardProps,
101
+ type PremiumDetailsTranslations,
102
+ type CreditInfo,
103
+ } from "./presentation/components/details/PremiumDetailsCard";
104
+
105
+ export {
106
+ PremiumStatusBadge,
107
+ type PremiumStatusBadgeProps,
108
+ type SubscriptionStatusType,
109
+ } from "./presentation/components/details/PremiumStatusBadge";
110
+
89
111
  // =============================================================================
90
112
  // UTILS - Date & Price
91
113
  // =============================================================================
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Premium Details Card
3
+ * Generic component for displaying subscription details
4
+ * Accepts credits via props for app-specific customization
5
+ */
6
+
7
+ import React from "react";
8
+ import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
9
+ import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
10
+ import {
11
+ PremiumStatusBadge,
12
+ type SubscriptionStatusType,
13
+ } from "./PremiumStatusBadge";
14
+
15
+ export interface CreditInfo {
16
+ id: string;
17
+ label: string;
18
+ current: number;
19
+ total: number;
20
+ }
21
+
22
+ export interface PremiumDetailsTranslations {
23
+ title: string;
24
+ statusLabel: string;
25
+ expiresLabel: string;
26
+ purchasedLabel: string;
27
+ creditsTitle?: string;
28
+ remainingLabel?: string;
29
+ manageButton?: string;
30
+ upgradeButton?: string;
31
+ lifetimeLabel?: string;
32
+ statusActive?: string;
33
+ statusExpired?: string;
34
+ statusFree?: string;
35
+ }
36
+
37
+ export interface PremiumDetailsCardProps {
38
+ statusType: SubscriptionStatusType;
39
+ isPremium: boolean;
40
+ expirationDate?: string | null;
41
+ purchaseDate?: string | null;
42
+ isLifetime?: boolean;
43
+ daysRemaining?: number | null;
44
+ credits?: CreditInfo[];
45
+ translations: PremiumDetailsTranslations;
46
+ onManageSubscription?: () => void;
47
+ onUpgrade?: () => void;
48
+ }
49
+
50
+ export const PremiumDetailsCard: React.FC<PremiumDetailsCardProps> = ({
51
+ statusType,
52
+ isPremium,
53
+ expirationDate,
54
+ purchaseDate,
55
+ isLifetime = false,
56
+ daysRemaining,
57
+ credits,
58
+ translations,
59
+ onManageSubscription,
60
+ onUpgrade,
61
+ }) => {
62
+ const tokens = useAppDesignTokens();
63
+ const showCredits = credits && credits.length > 0;
64
+
65
+ return (
66
+ <View style={[styles.card, { backgroundColor: tokens.colors.surface }]}>
67
+ <View style={styles.header}>
68
+ <Text style={[styles.title, { color: tokens.colors.text }]}>
69
+ {translations.title}
70
+ </Text>
71
+ <PremiumStatusBadge
72
+ status={statusType}
73
+ activeLabel={translations.statusActive}
74
+ expiredLabel={translations.statusExpired}
75
+ noneLabel={translations.statusFree}
76
+ />
77
+ </View>
78
+
79
+ {isPremium && (
80
+ <View style={styles.detailsSection}>
81
+ {isLifetime ? (
82
+ <DetailRow
83
+ label={translations.statusLabel}
84
+ value={translations.lifetimeLabel || "Lifetime"}
85
+ tokens={tokens}
86
+ />
87
+ ) : (
88
+ <>
89
+ {expirationDate && (
90
+ <DetailRow
91
+ label={translations.expiresLabel}
92
+ value={expirationDate}
93
+ highlight={
94
+ daysRemaining !== null &&
95
+ daysRemaining !== undefined &&
96
+ daysRemaining <= 7
97
+ }
98
+ tokens={tokens}
99
+ />
100
+ )}
101
+ {purchaseDate && (
102
+ <DetailRow
103
+ label={translations.purchasedLabel}
104
+ value={purchaseDate}
105
+ tokens={tokens}
106
+ />
107
+ )}
108
+ </>
109
+ )}
110
+ </View>
111
+ )}
112
+
113
+ {showCredits && (
114
+ <View
115
+ style={[styles.creditsSection, { borderTopColor: tokens.colors.border }]}
116
+ >
117
+ {translations.creditsTitle && (
118
+ <Text style={[styles.sectionTitle, { color: tokens.colors.text }]}>
119
+ {translations.creditsTitle}
120
+ </Text>
121
+ )}
122
+ {credits.map((credit) => (
123
+ <CreditRow
124
+ key={credit.id}
125
+ label={credit.label}
126
+ current={credit.current}
127
+ total={credit.total}
128
+ remainingLabel={translations.remainingLabel}
129
+ tokens={tokens}
130
+ />
131
+ ))}
132
+ </View>
133
+ )}
134
+
135
+ <View style={styles.actionsSection}>
136
+ {isPremium && onManageSubscription && translations.manageButton && (
137
+ <TouchableOpacity
138
+ style={[
139
+ styles.secondaryButton,
140
+ { backgroundColor: tokens.colors.surfaceSecondary },
141
+ ]}
142
+ onPress={onManageSubscription}
143
+ >
144
+ <Text style={[styles.secondaryButtonText, { color: tokens.colors.text }]}>
145
+ {translations.manageButton}
146
+ </Text>
147
+ </TouchableOpacity>
148
+ )}
149
+ {!isPremium && onUpgrade && translations.upgradeButton && (
150
+ <TouchableOpacity
151
+ style={[styles.primaryButton, { backgroundColor: tokens.colors.primary }]}
152
+ onPress={onUpgrade}
153
+ >
154
+ <Text
155
+ style={[styles.primaryButtonText, { color: tokens.colors.onPrimary }]}
156
+ >
157
+ {translations.upgradeButton}
158
+ </Text>
159
+ </TouchableOpacity>
160
+ )}
161
+ </View>
162
+ </View>
163
+ );
164
+ };
165
+
166
+ interface DetailRowProps {
167
+ label: string;
168
+ value: string;
169
+ highlight?: boolean;
170
+ tokens: ReturnType<typeof useAppDesignTokens>;
171
+ }
172
+
173
+ const DetailRow: React.FC<DetailRowProps> = ({
174
+ label,
175
+ value,
176
+ highlight = false,
177
+ tokens,
178
+ }) => (
179
+ <View style={styles.detailRow}>
180
+ <Text style={[styles.detailLabel, { color: tokens.colors.textSecondary }]}>
181
+ {label}
182
+ </Text>
183
+ <Text
184
+ style={[
185
+ styles.detailValue,
186
+ { color: highlight ? tokens.colors.warning : tokens.colors.text },
187
+ ]}
188
+ >
189
+ {value}
190
+ </Text>
191
+ </View>
192
+ );
193
+
194
+ interface CreditRowProps {
195
+ label: string;
196
+ current: number;
197
+ total: number;
198
+ remainingLabel?: string;
199
+ tokens: ReturnType<typeof useAppDesignTokens>;
200
+ }
201
+
202
+ const CreditRow: React.FC<CreditRowProps> = ({
203
+ label,
204
+ current,
205
+ total,
206
+ remainingLabel = "remaining",
207
+ tokens,
208
+ }) => {
209
+ const percentage = total > 0 ? (current / total) * 100 : 0;
210
+ const isLow = percentage <= 20;
211
+
212
+ return (
213
+ <View style={styles.creditRow}>
214
+ <View style={styles.creditHeader}>
215
+ <Text style={[styles.creditLabel, { color: tokens.colors.text }]}>
216
+ {label}
217
+ </Text>
218
+ <Text
219
+ style={[
220
+ styles.creditCount,
221
+ { color: isLow ? tokens.colors.warning : tokens.colors.textSecondary },
222
+ ]}
223
+ >
224
+ {current} / {total} {remainingLabel}
225
+ </Text>
226
+ </View>
227
+ <View
228
+ style={[styles.progressBar, { backgroundColor: tokens.colors.surfaceSecondary }]}
229
+ >
230
+ <View
231
+ style={[
232
+ styles.progressFill,
233
+ {
234
+ width: `${percentage}%`,
235
+ backgroundColor: isLow ? tokens.colors.warning : tokens.colors.primary,
236
+ },
237
+ ]}
238
+ />
239
+ </View>
240
+ </View>
241
+ );
242
+ };
243
+
244
+ const styles = StyleSheet.create({
245
+ card: {
246
+ borderRadius: 12,
247
+ padding: 16,
248
+ gap: 12,
249
+ },
250
+ header: {
251
+ flexDirection: "row",
252
+ justifyContent: "space-between",
253
+ alignItems: "center",
254
+ },
255
+ title: {
256
+ fontSize: 18,
257
+ fontWeight: "600",
258
+ },
259
+ detailsSection: {
260
+ gap: 8,
261
+ },
262
+ sectionTitle: {
263
+ fontSize: 14,
264
+ fontWeight: "600",
265
+ marginBottom: 4,
266
+ },
267
+ detailRow: {
268
+ flexDirection: "row",
269
+ justifyContent: "space-between",
270
+ alignItems: "center",
271
+ },
272
+ detailLabel: {
273
+ fontSize: 14,
274
+ },
275
+ detailValue: {
276
+ fontSize: 14,
277
+ fontWeight: "500",
278
+ },
279
+ creditsSection: {
280
+ gap: 8,
281
+ paddingTop: 12,
282
+ borderTopWidth: 1,
283
+ },
284
+ creditRow: {
285
+ gap: 4,
286
+ },
287
+ creditHeader: {
288
+ flexDirection: "row",
289
+ justifyContent: "space-between",
290
+ alignItems: "center",
291
+ },
292
+ creditLabel: {
293
+ fontSize: 13,
294
+ },
295
+ creditCount: {
296
+ fontSize: 12,
297
+ },
298
+ progressBar: {
299
+ height: 6,
300
+ borderRadius: 3,
301
+ overflow: "hidden",
302
+ },
303
+ progressFill: {
304
+ height: "100%",
305
+ borderRadius: 3,
306
+ },
307
+ actionsSection: {
308
+ gap: 8,
309
+ },
310
+ primaryButton: {
311
+ paddingVertical: 12,
312
+ borderRadius: 8,
313
+ alignItems: "center",
314
+ },
315
+ primaryButtonText: {
316
+ fontSize: 14,
317
+ fontWeight: "600",
318
+ },
319
+ secondaryButton: {
320
+ paddingVertical: 12,
321
+ borderRadius: 8,
322
+ alignItems: "center",
323
+ },
324
+ secondaryButtonText: {
325
+ fontSize: 14,
326
+ fontWeight: "500",
327
+ },
328
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Premium Status Badge
3
+ * Displays subscription status as a colored badge
4
+ */
5
+
6
+ import React from "react";
7
+ import { View, Text, StyleSheet } from "react-native";
8
+ import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
9
+
10
+ export type SubscriptionStatusType = "active" | "expired" | "none";
11
+
12
+ export interface PremiumStatusBadgeProps {
13
+ status: SubscriptionStatusType;
14
+ activeLabel?: string;
15
+ expiredLabel?: string;
16
+ noneLabel?: string;
17
+ }
18
+
19
+ /**
20
+ * Badge component showing subscription status
21
+ */
22
+ export const PremiumStatusBadge: React.FC<PremiumStatusBadgeProps> = ({
23
+ status,
24
+ activeLabel = "Active",
25
+ expiredLabel = "Expired",
26
+ noneLabel = "Free",
27
+ }) => {
28
+ const tokens = useAppDesignTokens();
29
+
30
+ const labels: Record<SubscriptionStatusType, string> = {
31
+ active: activeLabel,
32
+ expired: expiredLabel,
33
+ none: noneLabel,
34
+ };
35
+
36
+ const colors: Record<SubscriptionStatusType, string> = {
37
+ active: tokens.colors.success,
38
+ expired: tokens.colors.error,
39
+ none: tokens.colors.textTertiary,
40
+ };
41
+
42
+ const backgroundColor = colors[status];
43
+ const label = labels[status];
44
+
45
+ return (
46
+ <View style={[styles.badge, { backgroundColor }]}>
47
+ <Text style={[styles.badgeText, { color: tokens.colors.onPrimary }]}>
48
+ {label}
49
+ </Text>
50
+ </View>
51
+ );
52
+ };
53
+
54
+ const styles = StyleSheet.create({
55
+ badge: {
56
+ paddingHorizontal: 8,
57
+ paddingVertical: 4,
58
+ borderRadius: 4,
59
+ },
60
+ badgeText: {
61
+ fontSize: 12,
62
+ fontWeight: "600",
63
+ },
64
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * useSubscriptionDetails Hook
3
+ * Provides formatted subscription details for display
4
+ */
5
+
6
+ import { useMemo } from "react";
7
+ import type { SubscriptionStatus } from "../../domain/entities/SubscriptionStatus";
8
+ import {
9
+ getDaysUntilExpiration,
10
+ isSubscriptionExpired,
11
+ } from "../../utils/dateValidationUtils";
12
+
13
+ export interface SubscriptionDetails {
14
+ /** Raw subscription status */
15
+ status: SubscriptionStatus | null;
16
+ /** Whether user has active premium */
17
+ isPremium: boolean;
18
+ /** Whether subscription is expired */
19
+ isExpired: boolean;
20
+ /** Whether this is a lifetime subscription */
21
+ isLifetime: boolean;
22
+ /** Days remaining until expiration (null for lifetime) */
23
+ daysRemaining: number | null;
24
+ /** Formatted expiration date (null if lifetime) */
25
+ formattedExpirationDate: string | null;
26
+ /** Formatted purchase date */
27
+ formattedPurchaseDate: string | null;
28
+ /** Status text key for localization */
29
+ statusKey: "active" | "expired" | "none";
30
+ }
31
+
32
+ interface UseSubscriptionDetailsParams {
33
+ status: SubscriptionStatus | null;
34
+ locale?: string;
35
+ }
36
+
37
+ /**
38
+ * Format date to localized string
39
+ */
40
+ function formatDate(dateString: string | null, locale: string): string | null {
41
+ if (!dateString) return null;
42
+
43
+ try {
44
+ const date = new Date(dateString);
45
+ return date.toLocaleDateString(locale, {
46
+ year: "numeric",
47
+ month: "long",
48
+ day: "numeric",
49
+ });
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Hook for formatted subscription details
57
+ */
58
+ export function useSubscriptionDetails(
59
+ params: UseSubscriptionDetailsParams,
60
+ ): SubscriptionDetails {
61
+ const { status, locale = "en-US" } = params;
62
+
63
+ return useMemo(() => {
64
+ if (!status) {
65
+ return {
66
+ status: null,
67
+ isPremium: false,
68
+ isExpired: false,
69
+ isLifetime: false,
70
+ daysRemaining: null,
71
+ formattedExpirationDate: null,
72
+ formattedPurchaseDate: null,
73
+ statusKey: "none",
74
+ };
75
+ }
76
+
77
+ const isExpired = isSubscriptionExpired(status);
78
+ const isLifetime = status.isPremium && !status.expiresAt;
79
+ const daysRemaining = getDaysUntilExpiration(status);
80
+ const isPremium = status.isPremium && !isExpired;
81
+
82
+ let statusKey: "active" | "expired" | "none" = "none";
83
+ if (status.isPremium) {
84
+ statusKey = isExpired ? "expired" : "active";
85
+ }
86
+
87
+ return {
88
+ status,
89
+ isPremium,
90
+ isExpired,
91
+ isLifetime,
92
+ daysRemaining,
93
+ formattedExpirationDate: formatDate(status.expiresAt, locale),
94
+ formattedPurchaseDate: formatDate(status.purchasedAt, locale),
95
+ statusKey,
96
+ };
97
+ }, [status, locale]);
98
+ }