@umituz/web-payment 0.0.0-stage → 1.0.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.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -2
  3. package/dist/billing-adapter.interface-CDkeeowZ.d.mts +94 -0
  4. package/dist/billing-adapter.interface-m7yE50I0.d.ts +94 -0
  5. package/dist/billing-cycle.enum--hJRYlgP.d.mts +10 -0
  6. package/dist/billing-cycle.enum--hJRYlgP.d.ts +10 -0
  7. package/dist/billing-env-D4YjvA3M.d.mts +8 -0
  8. package/dist/billing-env-D4YjvA3M.d.ts +8 -0
  9. package/dist/checkout-metadata-D7Wp_DAD.d.mts +27 -0
  10. package/dist/checkout-metadata-D7Wp_DAD.d.ts +27 -0
  11. package/dist/chunk-2KV7JFYF.mjs +61 -0
  12. package/dist/chunk-2NH5DOLT.mjs +101 -0
  13. package/dist/chunk-3AXPZOCV.mjs +106 -0
  14. package/dist/chunk-4N3BASZ6.mjs +51 -0
  15. package/dist/chunk-NHKK3ENU.mjs +1128 -0
  16. package/dist/chunk-PIWNUAKW.mjs +30 -0
  17. package/dist/chunk-QFB7AOSQ.mjs +33 -0
  18. package/dist/chunk-RQTMBR74.mjs +30 -0
  19. package/dist/chunk-U45FS4FF.mjs +110 -0
  20. package/dist/chunk-X6R76FZT.mjs +0 -0
  21. package/dist/chunk-XQ2HJTTA.mjs +252 -0
  22. package/dist/core.d.mts +17 -0
  23. package/dist/core.d.ts +17 -0
  24. package/dist/core.js +425 -0
  25. package/dist/core.mjs +66 -0
  26. package/dist/http.d.mts +12 -0
  27. package/dist/http.d.ts +12 -0
  28. package/dist/http.js +115 -0
  29. package/dist/http.mjs +88 -0
  30. package/dist/index.d.mts +17 -0
  31. package/dist/index.d.ts +17 -0
  32. package/dist/index.js +1929 -0
  33. package/dist/index.mjs +153 -0
  34. package/dist/memory.d.mts +14 -0
  35. package/dist/memory.d.ts +14 -0
  36. package/dist/memory.js +130 -0
  37. package/dist/memory.mjs +6 -0
  38. package/dist/portal.d.mts +445 -0
  39. package/dist/portal.d.ts +445 -0
  40. package/dist/portal.js +2927 -0
  41. package/dist/portal.mjs +2737 -0
  42. package/dist/product.mapper-CnY1ISOp.d.ts +97 -0
  43. package/dist/product.mapper-noVl-sel.d.mts +97 -0
  44. package/dist/products.d.mts +64 -0
  45. package/dist/products.d.ts +64 -0
  46. package/dist/products.js +370 -0
  47. package/dist/products.mjs +21 -0
  48. package/dist/react.d.mts +182 -0
  49. package/dist/react.d.ts +182 -0
  50. package/dist/react.js +1874 -0
  51. package/dist/react.mjs +105 -0
  52. package/dist/server.d.mts +177 -0
  53. package/dist/server.d.ts +177 -0
  54. package/dist/server.js +622 -0
  55. package/dist/server.mjs +417 -0
  56. package/dist/visual-tokens-BCLVR85G.d.mts +94 -0
  57. package/dist/visual-tokens-BCLVR85G.d.ts +94 -0
  58. package/package.json +122 -4
  59. package/src/core.ts +81 -0
  60. package/src/domain/entities/cancellation.entity.ts +28 -0
  61. package/src/domain/entities/checkout.entity.ts +21 -0
  62. package/src/domain/entities/index.ts +10 -0
  63. package/src/domain/entities/order.entity.ts +15 -0
  64. package/src/domain/entities/product.entity.ts +16 -0
  65. package/src/domain/entities/subscription.entity.ts +24 -0
  66. package/src/domain/entities/sync.entity.ts +4 -0
  67. package/src/domain/enums/amount-type.enum.ts +10 -0
  68. package/src/domain/enums/billing-cycle.enum.ts +30 -0
  69. package/src/domain/enums/index.ts +5 -0
  70. package/src/domain/enums/payment-provider.enum.ts +14 -0
  71. package/src/domain/enums/subscription-status.enum.ts +32 -0
  72. package/src/domain/index.ts +5 -0
  73. package/src/domain/interfaces/billing-adapter.interface.ts +18 -0
  74. package/src/domain/interfaces/index.ts +1 -0
  75. package/src/domain/mappers/product.mapper.ts +176 -0
  76. package/src/domain/utils/minor-units.ts +20 -0
  77. package/src/domain/utils/percent-discount.ts +17 -0
  78. package/src/http.ts +3 -0
  79. package/src/index.ts +12 -0
  80. package/src/infrastructure/browser/hosted-checkout.ts +26 -0
  81. package/src/infrastructure/config/billing-env.ts +31 -0
  82. package/src/infrastructure/constants/billing.constants.ts +10 -0
  83. package/src/infrastructure/http/http-client.ts +83 -0
  84. package/src/infrastructure/pagination/page-iterator.ts +43 -0
  85. package/src/infrastructure/query/query-builder.ts +26 -0
  86. package/src/infrastructure/services/billing.client.ts +300 -0
  87. package/src/infrastructure/services/create-billing-client.ts +23 -0
  88. package/src/infrastructure/services/http/create-http-adapter.ts +123 -0
  89. package/src/infrastructure/services/memory/create-memory-adapter.ts +126 -0
  90. package/src/infrastructure/services/product.repository.ts +198 -0
  91. package/src/infrastructure/utils/checkout-metadata.ts +55 -0
  92. package/src/infrastructure/utils/date-renderer.ts +53 -0
  93. package/src/infrastructure/utils/money-formatter.ts +39 -0
  94. package/src/infrastructure/utils/normalization.util.ts +22 -0
  95. package/src/infrastructure/utils/pagination-clamp.ts +11 -0
  96. package/src/infrastructure/utils/receipt.util.ts +6 -0
  97. package/src/infrastructure/utils/security.util.ts +23 -0
  98. package/src/infrastructure/utils/type-guards.util.ts +23 -0
  99. package/src/infrastructure/utils/validation.util.ts +10 -0
  100. package/src/infrastructure/webhook/parse-event.ts +135 -0
  101. package/src/infrastructure/webhook/verify-webhook.ts +124 -0
  102. package/src/memory.ts +3 -0
  103. package/src/portal.ts +2 -0
  104. package/src/presentation/components/BillingHistoryList.tsx +172 -0
  105. package/src/presentation/components/BillingProvider.tsx +179 -0
  106. package/src/presentation/components/CancelSubscriptionButton.tsx +155 -0
  107. package/src/presentation/components/CheckoutButton.tsx +76 -0
  108. package/src/presentation/components/CustomerPortalLink.tsx +71 -0
  109. package/src/presentation/components/PricingTable.tsx +225 -0
  110. package/src/presentation/components/SubscriptionStatusBadge.tsx +102 -0
  111. package/src/presentation/context/billing-context.ts +30 -0
  112. package/src/presentation/hooks/useBilling.ts +3 -0
  113. package/src/presentation/hooks/useCancelSubscription.ts +47 -0
  114. package/src/presentation/hooks/useCheckoutButton.ts +49 -0
  115. package/src/presentation/hooks/useProducts.ts +76 -0
  116. package/src/presentation/navigation/navigation.strategy.ts +16 -0
  117. package/src/presentation/portal/TurnkeyBillingPage.tsx +144 -0
  118. package/src/presentation/portal/components/BillingLayout.tsx +163 -0
  119. package/src/presentation/portal/components/BillingPage.tsx +22 -0
  120. package/src/presentation/portal/components/BillingPortal.tsx +248 -0
  121. package/src/presentation/portal/components/InvoiceCard.tsx +275 -0
  122. package/src/presentation/portal/components/OverviewTab.tsx +137 -0
  123. package/src/presentation/portal/components/PaymentMethodsList.tsx +268 -0
  124. package/src/presentation/portal/components/PlanComparison.tsx +405 -0
  125. package/src/presentation/portal/components/PlanTab.tsx +197 -0
  126. package/src/presentation/portal/components/PortalButton.tsx +89 -0
  127. package/src/presentation/portal/components/PortalSpinner.tsx +30 -0
  128. package/src/presentation/portal/components/UsageCard.tsx +158 -0
  129. package/src/presentation/portal/components/index.ts +11 -0
  130. package/src/presentation/portal/constants.ts +44 -0
  131. package/src/presentation/portal/create-portal-client.ts +202 -0
  132. package/src/presentation/portal/hooks/index.ts +3 -0
  133. package/src/presentation/portal/hooks/useBilling.ts +240 -0
  134. package/src/presentation/portal/index.ts +97 -0
  135. package/src/presentation/portal/labels.tsx +218 -0
  136. package/src/presentation/portal/styles.ts +70 -0
  137. package/src/presentation/portal/types.ts +213 -0
  138. package/src/presentation/portal/utils/billing.ts +193 -0
  139. package/src/presentation/portal/utils/index.ts +27 -0
  140. package/src/presentation/portal/utils/plans.ts +71 -0
  141. package/src/presentation/theme/visual-tokens.ts +103 -0
  142. package/src/presentation/utils/cancellation-reason-catalog.ts +18 -0
  143. package/src/presentation/utils/checkout-button-visual.ts +50 -0
  144. package/src/presentation/utils/inline-error-visual.ts +21 -0
  145. package/src/presentation/utils/product-pricing.ts +33 -0
  146. package/src/presentation/utils/status-label.ts +23 -0
  147. package/src/presentation/utils/status-tone.ts +25 -0
  148. package/src/products.ts +20 -0
  149. package/src/react.tsx +63 -0
  150. package/src/server.ts +79 -0
@@ -0,0 +1,155 @@
1
+
2
+ import { useState, type ReactNode } from 'react';
3
+ import { useCancelSubscription } from '../hooks/useCancelSubscription';
4
+ import type { CancellationReason } from '../../domain/entities/cancellation.entity';
5
+ import {
6
+ CANCELLATION_REASON_CATALOG,
7
+ type CancellationReasonOption,
8
+ } from '../utils/cancellation-reason-catalog';
9
+ import { InlineErrorVisual } from '../utils/inline-error-visual';
10
+ import { VisualTokens } from '../theme/visual-tokens';
11
+
12
+ export interface CancelSubscriptionButtonProps {
13
+ label?: string;
14
+ renderPrompt?: (confirm: (reason?: CancellationReason) => void) => ReactNode;
15
+ onSuccess?: () => void;
16
+ reasonCatalog?: ReadonlyArray<CancellationReasonOption>;
17
+ labels?: CancelSubscriptionButtonLabels;
18
+ }
19
+
20
+ export interface CancelSubscriptionButtonLabels {
21
+ action?: string;
22
+ confirm?: string;
23
+ keep?: string;
24
+ canceling?: string;
25
+ promptTitle?: string;
26
+ noReason?: string;
27
+ }
28
+
29
+ const DEFAULT_LABELS: Required<CancelSubscriptionButtonLabels> = Object.freeze({
30
+ action: 'Cancel subscription',
31
+ confirm: 'Confirm cancel',
32
+ keep: 'Keep subscription',
33
+ canceling: 'Canceling…',
34
+ promptTitle: 'Why are you canceling? (optional)',
35
+ noReason: 'Prefer not to say',
36
+ });
37
+
38
+ export function CancelSubscriptionButton({
39
+ label,
40
+ renderPrompt,
41
+ onSuccess,
42
+ reasonCatalog = CANCELLATION_REASON_CATALOG,
43
+ labels,
44
+ }: CancelSubscriptionButtonProps) {
45
+ const { cancel, loading, error } = useCancelSubscription({
46
+ onSuccess: () => onSuccess?.(),
47
+ });
48
+ const [promptOpen, setPromptOpen] = useState(false);
49
+ const [reason, setReason] = useState<CancellationReason | ''>('');
50
+
51
+ const text = { ...DEFAULT_LABELS, ...labels };
52
+ const triggerLabel = label ?? text.action;
53
+
54
+ const onConfirm = async (selected?: CancellationReason) => {
55
+ setPromptOpen(false);
56
+ const result = await cancel(selected);
57
+ if (result?.success) {
58
+ setReason('');
59
+ }
60
+ };
61
+
62
+ if (!promptOpen) {
63
+ return (
64
+ <span style={{ display: 'inline-flex', flexDirection: 'column', gap: VisualTokens.spacing.xs }}>
65
+ <button
66
+ type="button"
67
+ onClick={() => setPromptOpen(true)}
68
+ style={{
69
+ background: 'transparent',
70
+ color: VisualTokens.color.brandDangerText,
71
+ border: `1px solid ${VisualTokens.color.borderWarning}`,
72
+ borderRadius: VisualTokens.radius.md,
73
+ padding: `${VisualTokens.spacing.md}px ${VisualTokens.spacing.lg}px`,
74
+ cursor: 'pointer',
75
+ fontWeight: VisualTokens.fontWeight.strong,
76
+ }}
77
+ >
78
+ {triggerLabel}
79
+ </button>
80
+ {InlineErrorVisual.element(error?.message)}
81
+ </span>
82
+ );
83
+ }
84
+
85
+ if (renderPrompt) {
86
+ return <>{renderPrompt((selected) => void onConfirm(selected))}</>;
87
+ }
88
+
89
+ return (
90
+ <div
91
+ role="dialog"
92
+ aria-label="Confirm cancellation"
93
+ style={{
94
+ border: `1px solid ${VisualTokens.color.borderWarning}`,
95
+ background: VisualTokens.color.surfaceDangerMuted,
96
+ borderRadius: VisualTokens.radius.md,
97
+ padding: VisualTokens.spacing.lg,
98
+ display: 'flex',
99
+ flexDirection: 'column',
100
+ gap: VisualTokens.spacing.md,
101
+ }}
102
+ >
103
+ <label style={{ fontSize: VisualTokens.fontSize.body, fontWeight: VisualTokens.fontWeight.strong }}>
104
+ {text.promptTitle}
105
+ <select
106
+ value={reason}
107
+ onChange={(e) => setReason(e.target.value as CancellationReason | '')}
108
+ style={{
109
+ display: 'block',
110
+ marginTop: VisualTokens.spacing.xs,
111
+ width: '100%',
112
+ padding: VisualTokens.spacing.sm,
113
+ }}
114
+ >
115
+ <option value="">{text.noReason}</option>
116
+ {reasonCatalog.map((opt) => (
117
+ <option key={opt.value} value={opt.value}>
118
+ {opt.label}
119
+ </option>
120
+ ))}
121
+ </select>
122
+ </label>
123
+ <div style={{ display: 'flex', gap: VisualTokens.spacing.md }}>
124
+ <button
125
+ type="button"
126
+ onClick={() => void onConfirm(reason || undefined)}
127
+ disabled={loading}
128
+ style={{
129
+ background: VisualTokens.color.brandDanger,
130
+ color: VisualTokens.color.textOnPrimary,
131
+ border: 'none',
132
+ padding: `${VisualTokens.spacing.sm}px ${VisualTokens.spacing.lg}px`,
133
+ borderRadius: VisualTokens.radius.sm,
134
+ }}
135
+ >
136
+ {loading ? text.canceling : text.confirm}
137
+ </button>
138
+ <button
139
+ type="button"
140
+ onClick={() => setPromptOpen(false)}
141
+ disabled={loading}
142
+ style={{
143
+ background: 'transparent',
144
+ border: `1px solid ${VisualTokens.color.borderStrong}`,
145
+ padding: `${VisualTokens.spacing.sm}px ${VisualTokens.spacing.lg}px`,
146
+ borderRadius: VisualTokens.radius.sm,
147
+ }}
148
+ >
149
+ {text.keep}
150
+ </button>
151
+ </div>
152
+ {InlineErrorVisual.element(error?.message)}
153
+ </div>
154
+ );
155
+ }
@@ -0,0 +1,76 @@
1
+
2
+ import { useMemo } from 'react';
3
+ import { useCheckoutButton } from '../hooks/useCheckoutButton';
4
+ import type { CheckoutParams } from '../../domain/entities/checkout.entity';
5
+ import { CheckoutButtonVisual } from '../utils/checkout-button-visual';
6
+ import { InlineErrorVisual } from '../utils/inline-error-visual';
7
+
8
+ export type CheckoutButtonVariant = 'primary' | 'secondary' | 'danger';
9
+
10
+ export interface CheckoutButtonLabels {
11
+ loading?: string;
12
+ }
13
+
14
+ export interface CheckoutButtonProps {
15
+ productId: string;
16
+ billingCycle?: CheckoutParams['billingCycle'];
17
+ successUrl?: string;
18
+ planKey?: string;
19
+ label: string;
20
+ variant?: CheckoutButtonVariant;
21
+ disabled?: boolean;
22
+ onError?: (error: Error) => void;
23
+ className?: string;
24
+ labels?: CheckoutButtonLabels;
25
+ }
26
+
27
+ const DEFAULT_LABELS: Required<CheckoutButtonLabels> = Object.freeze({
28
+ loading: 'Redirecting…',
29
+ });
30
+
31
+ export function CheckoutButton({
32
+ productId,
33
+ billingCycle,
34
+ successUrl,
35
+ planKey,
36
+ label,
37
+ variant = 'primary',
38
+ disabled = false,
39
+ onError,
40
+ className,
41
+ labels,
42
+ }: CheckoutButtonProps) {
43
+ const { start, loading, error } = useCheckoutButton({
44
+ productId,
45
+ billingCycle,
46
+ successUrl,
47
+ planKey,
48
+ onError,
49
+ });
50
+
51
+ const style = useMemo(
52
+ () => CheckoutButtonVisual.resolve({ variant, disabled, loading }),
53
+ [variant, disabled, loading],
54
+ );
55
+
56
+ const text = useMemo(
57
+ () => ({ ...DEFAULT_LABELS, ...labels }),
58
+ [labels],
59
+ );
60
+
61
+ return (
62
+ <span style={{ display: 'inline-flex', flexDirection: 'column', gap: 4 }}>
63
+ <button
64
+ type="button"
65
+ style={style}
66
+ className={className}
67
+ onClick={() => void start()}
68
+ disabled={disabled || loading}
69
+ aria-busy={loading}
70
+ >
71
+ {loading ? text.loading : label}
72
+ </button>
73
+ {InlineErrorVisual.element(error?.message)}
74
+ </span>
75
+ );
76
+ }
@@ -0,0 +1,71 @@
1
+
2
+ import { useState } from 'react';
3
+ import { useBilling } from '../context/billing-context';
4
+ import { InlineErrorVisual } from '../utils/inline-error-visual';
5
+ import { VisualTokens } from '../theme/visual-tokens';
6
+
7
+ export interface CustomerPortalLinkProps {
8
+ label?: string;
9
+ target?: 'redirect' | 'open';
10
+ className?: string;
11
+ labels?: CustomerPortalLinkLabels;
12
+ }
13
+
14
+ export interface CustomerPortalLinkLabels {
15
+ loading?: string;
16
+ }
17
+
18
+ const DEFAULT_LABEL = 'Manage subscription';
19
+ const DEFAULT_LOADING_LABEL = 'Opening…';
20
+
21
+ export function CustomerPortalLink({
22
+ label = DEFAULT_LABEL,
23
+ target = 'open',
24
+ className,
25
+ labels,
26
+ }: CustomerPortalLinkProps) {
27
+ const { getPortalUrl } = useBilling();
28
+ const [loading, setLoading] = useState(false);
29
+ const [error, setError] = useState<string | null>(null);
30
+
31
+ const loadingLabel = labels?.loading ?? DEFAULT_LOADING_LABEL;
32
+
33
+ const open = async () => {
34
+ setLoading(true);
35
+ setError(null);
36
+ try {
37
+ const url = await getPortalUrl();
38
+ if (target === 'open') {
39
+ window.open(url, '_blank', 'noopener,noreferrer');
40
+ } else {
41
+ window.location.href = url;
42
+ }
43
+ } catch (err) {
44
+ setError(err instanceof Error ? err.message : 'Failed to open portal');
45
+ } finally {
46
+ setLoading(false);
47
+ }
48
+ };
49
+
50
+ return (
51
+ <span style={{ display: 'inline-flex', flexDirection: 'column', gap: VisualTokens.spacing.xs }}>
52
+ <button
53
+ type="button"
54
+ onClick={() => void open()}
55
+ disabled={loading}
56
+ className={className}
57
+ style={{
58
+ background: 'transparent',
59
+ color: VisualTokens.color.brandPrimary,
60
+ border: 'none',
61
+ padding: 0,
62
+ textDecoration: 'underline',
63
+ cursor: loading ? 'wait' : 'pointer',
64
+ }}
65
+ >
66
+ {loading ? loadingLabel : label}
67
+ </button>
68
+ {InlineErrorVisual.element(error)}
69
+ </span>
70
+ );
71
+ }
@@ -0,0 +1,225 @@
1
+
2
+ import { useMemo } from 'react';
3
+ import { useProducts } from '../hooks/useProducts';
4
+ import type { Product, BillingCycle } from '../../domain/entities';
5
+ import { CheckoutButton } from './CheckoutButton';
6
+ import { ProductPricing } from '../utils/product-pricing';
7
+ import { MoneyFormatter } from '../../infrastructure/utils/money-formatter';
8
+ import { VisualTokens } from '../theme/visual-tokens';
9
+
10
+ export interface PricingTableProps {
11
+ products?: Product[];
12
+ apiToken?: string;
13
+ organizationId?: string;
14
+ isRecurring?: boolean;
15
+ ctaLabel?: string;
16
+ onError?: (error: Error) => void;
17
+ filterInterval?: BillingCycle | null;
18
+ locale?: string;
19
+ labels?: PricingTableLabels;
20
+ }
21
+
22
+ export interface PricingTableLabels {
23
+ loading?: string;
24
+ empty?: string;
25
+ errorPrefix?: string;
26
+ retry?: string;
27
+ }
28
+
29
+ const DEFAULT_LABELS: Required<PricingTableLabels> = Object.freeze({
30
+ loading: 'Loading pricing…',
31
+ empty: 'No products available.',
32
+ errorPrefix: 'Failed to load pricing:',
33
+ retry: 'Retry',
34
+ });
35
+
36
+ export function PricingTable({
37
+ products: productsOverride,
38
+ apiToken,
39
+ organizationId,
40
+ isRecurring = true,
41
+ ctaLabel = 'Subscribe',
42
+ onError,
43
+ filterInterval = 'monthly',
44
+ locale,
45
+ labels,
46
+ }: PricingTableProps) {
47
+ const enabled = productsOverride === undefined && apiToken !== undefined;
48
+ const { products: fetchedProducts, loading, error, refetch } = useProducts({
49
+ apiToken: apiToken ?? '',
50
+ organizationId,
51
+ isRecurring,
52
+ enabled,
53
+ });
54
+
55
+ const products = productsOverride ?? fetchedProducts;
56
+
57
+ const moneyFormatter = useMemo(() => new MoneyFormatter(locale), [locale]);
58
+ const text = useMemo(
59
+ () => ({ ...DEFAULT_LABELS, ...labels }),
60
+ [labels],
61
+ );
62
+
63
+ const visible = useMemo(() => {
64
+ if (filterInterval === null) return products;
65
+ return products.filter((p) => p.recurringInterval === filterInterval);
66
+ }, [products, filterInterval]);
67
+
68
+ if (enabled && loading) {
69
+ return (
70
+ <div
71
+ role="status"
72
+ aria-live="polite"
73
+ style={{
74
+ padding: VisualTokens.spacing.section,
75
+ textAlign: 'center',
76
+ }}
77
+ >
78
+ {text.loading}
79
+ </div>
80
+ );
81
+ }
82
+
83
+ if (error) {
84
+ return (
85
+ <div
86
+ role="alert"
87
+ style={{
88
+ padding: VisualTokens.spacing.xl,
89
+ background: VisualTokens.color.surfaceDanger,
90
+ borderRadius: VisualTokens.radius.md,
91
+ }}
92
+ >
93
+ <p style={{ margin: 0, color: VisualTokens.color.brandDangerText }}>
94
+ {text.errorPrefix} {error.message}
95
+ </p>
96
+ <button
97
+ type="button"
98
+ onClick={() => void refetch()}
99
+ style={{ marginTop: VisualTokens.spacing.md, padding: '6px 12px' }}
100
+ >
101
+ {text.retry}
102
+ </button>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ if (visible.length === 0) {
108
+ return (
109
+ <div
110
+ style={{
111
+ padding: VisualTokens.spacing.section,
112
+ textAlign: 'center',
113
+ color: VisualTokens.color.textMuted,
114
+ }}
115
+ >
116
+ {text.empty}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ return (
122
+ <div
123
+ style={{
124
+ display: 'grid',
125
+ gridTemplateColumns: `repeat(auto-fit, minmax(${VisualTokens.layout.cardMinWidth}px, 1fr))`,
126
+ gap: VisualTokens.spacing.xl,
127
+ }}
128
+ >
129
+ {visible.map((product) => (
130
+ <ProductCard
131
+ key={product.id}
132
+ product={product}
133
+ moneyFormatter={moneyFormatter}
134
+ ctaLabel={ctaLabel}
135
+ onError={onError}
136
+ />
137
+ ))}
138
+ </div>
139
+ );
140
+ }
141
+
142
+ interface ProductCardProps {
143
+ product: Product;
144
+ moneyFormatter: MoneyFormatter;
145
+ ctaLabel: string;
146
+ onError?: (error: Error) => void;
147
+ }
148
+
149
+ function ProductCard({ product, moneyFormatter, ctaLabel, onError }: ProductCardProps) {
150
+ const { amount, interval } = useMemo(
151
+ () =>
152
+ ProductPricing.displayLine(
153
+ product,
154
+ (amt, cur) => moneyFormatter.format(amt, cur),
155
+ ),
156
+ [product, moneyFormatter],
157
+ );
158
+
159
+ return (
160
+ <article
161
+ style={{
162
+ border: `1px solid ${VisualTokens.color.borderSubtle}`,
163
+ borderRadius: VisualTokens.radius.lg,
164
+ padding: VisualTokens.spacing.xxl,
165
+ display: 'flex',
166
+ flexDirection: 'column',
167
+ gap: VisualTokens.spacing.lg,
168
+ }}
169
+ >
170
+ <header>
171
+ <h3 style={{ margin: 0, fontSize: VisualTokens.fontSize.title }}>{product.name}</h3>
172
+ {product.description ? (
173
+ <p
174
+ style={{
175
+ margin: `${VisualTokens.spacing.xs}px 0 0`,
176
+ color: VisualTokens.color.textMuted,
177
+ fontSize: VisualTokens.fontSize.body,
178
+ }}
179
+ >
180
+ {product.description}
181
+ </p>
182
+ ) : null}
183
+ </header>
184
+
185
+ <div style={{ fontSize: VisualTokens.fontSize.display, fontWeight: VisualTokens.fontWeight.strong }}>
186
+ {amount}
187
+ {interval ? (
188
+ <span
189
+ style={{
190
+ fontSize: VisualTokens.fontSize.body,
191
+ fontWeight: VisualTokens.fontWeight.regular,
192
+ color: VisualTokens.color.textMuted,
193
+ }}
194
+ >
195
+ {' '}/ {interval}
196
+ </span>
197
+ ) : null}
198
+ </div>
199
+
200
+ {product.benefits.length > 0 ? (
201
+ <ul
202
+ style={{
203
+ paddingLeft: 18,
204
+ margin: 0,
205
+ color: '#374151',
206
+ fontSize: VisualTokens.fontSize.body,
207
+ }}
208
+ >
209
+ {product.benefits.slice(0, VisualTokens.layout.benefitListMax).map((b) => (
210
+ <li key={b.id}>{b.description ?? b.type}</li>
211
+ ))}
212
+ </ul>
213
+ ) : null}
214
+
215
+ <div style={{ marginTop: 'auto' }}>
216
+ <CheckoutButton
217
+ productId={product.id}
218
+ billingCycle={product.recurringInterval ?? undefined}
219
+ label={ctaLabel}
220
+ onError={onError}
221
+ />
222
+ </div>
223
+ </article>
224
+ );
225
+ }
@@ -0,0 +1,102 @@
1
+
2
+ import { useMemo } from 'react';
3
+ import { useBilling } from '../context/billing-context';
4
+ import type { SubscriptionStatus } from '../../domain/entities/subscription.entity';
5
+ import type { SubscriptionStatusValue } from '../../domain/enums/subscription-status.enum';
6
+ import { StatusTone } from '../utils/status-tone';
7
+ import { StatusLabel } from '../utils/status-label';
8
+ import { DateRenderer } from '../../infrastructure/utils/date-renderer';
9
+ import { VisualTokens, TONE_PAIRS } from '../theme/visual-tokens';
10
+
11
+ export interface SubscriptionStatusBadgeProps {
12
+ status?: SubscriptionStatus;
13
+ loading?: boolean;
14
+ locale?: string;
15
+ labels?: SubscriptionStatusBadgeLabels;
16
+ }
17
+
18
+ export interface SubscriptionStatusBadgeLabels {
19
+ loading?: string;
20
+ endsPrefix?: string;
21
+ statuses?: Partial<Record<SubscriptionStatusValue, string>>;
22
+ }
23
+
24
+ const DEFAULT_LABELS: Required<Omit<SubscriptionStatusBadgeLabels, 'statuses'>> = Object.freeze({
25
+ loading: 'Loading…',
26
+ endsPrefix: 'ends',
27
+ });
28
+
29
+ export function SubscriptionStatusBadge({
30
+ status: statusOverride,
31
+ loading: loadingOverride,
32
+ locale,
33
+ labels,
34
+ }: SubscriptionStatusBadgeProps) {
35
+ const ctx = useBilling();
36
+ const status = statusOverride ?? ctx.status;
37
+ const loading = loadingOverride ?? ctx.loading;
38
+
39
+ const dateRenderer = useMemo(() => new DateRenderer(locale), [locale]);
40
+ const text = useMemo(
41
+ () => ({ ...DEFAULT_LABELS, ...labels }),
42
+ [labels],
43
+ );
44
+
45
+ const pillStyle = useMemo<React.CSSProperties>(
46
+ () => ({
47
+ padding: `${VisualTokens.spacing.xs}px ${VisualTokens.spacing.lg - 2}px`,
48
+ borderRadius: VisualTokens.radius.pill,
49
+ fontSize: VisualTokens.fontSize.caption,
50
+ fontWeight: VisualTokens.fontWeight.strong,
51
+ }),
52
+ [],
53
+ );
54
+
55
+ if (loading) {
56
+ return (
57
+ <span
58
+ style={{
59
+ ...pillStyle,
60
+ ...TONE_PAIRS.neutral,
61
+ }}
62
+ >
63
+ {text.loading}
64
+ </span>
65
+ );
66
+ }
67
+
68
+ const tone = StatusTone.forStatus(status.subscriptionStatus);
69
+ const color = TONE_PAIRS[tone];
70
+ const label = StatusLabel.forStatus(status.subscriptionStatus, labels?.statuses);
71
+
72
+ const showPlan = status.plan && status.plan !== 'free';
73
+ const showEnds = status.cancelAtPeriodEnd;
74
+ const endsLabel = showEnds
75
+ ? `${text.endsPrefix} ${dateRenderer.renewal(status.currentPeriodEnd)}`
76
+ : null;
77
+
78
+ return (
79
+ <span
80
+ aria-label={`Subscription ${label}`}
81
+ style={{
82
+ backgroundColor: color.bg,
83
+ color: color.fg,
84
+ padding: pillStyle.padding,
85
+ borderRadius: VisualTokens.radius.pill,
86
+ fontSize: VisualTokens.fontSize.caption,
87
+ fontWeight: VisualTokens.fontWeight.strong,
88
+ display: 'inline-flex',
89
+ alignItems: 'center',
90
+ gap: VisualTokens.spacing.xs + 2,
91
+ }}
92
+ >
93
+ <span style={{ textTransform: 'capitalize' }}>{label}</span>
94
+ {showPlan ? (
95
+ <span style={{ opacity: VisualTokens.opacity.muted }}>· {status.plan}</span>
96
+ ) : null}
97
+ {endsLabel ? (
98
+ <span style={{ opacity: VisualTokens.opacity.muted }}>· {endsLabel}</span>
99
+ ) : null}
100
+ </span>
101
+ );
102
+ }
@@ -0,0 +1,30 @@
1
+ import { createContext, useContext } from 'react';
2
+ import type {
3
+ SubscriptionStatus,
4
+ CheckoutParams,
5
+ CancellationReason,
6
+ CancelResult,
7
+ SyncResult,
8
+ OrderItem,
9
+ } from '../../domain/entities';
10
+
11
+ export interface BillingContextValue {
12
+ status: SubscriptionStatus;
13
+ loading: boolean;
14
+ refresh: () => Promise<void>;
15
+ startCheckout: (params: CheckoutParams) => Promise<void>;
16
+ syncSubscription: () => Promise<SyncResult>;
17
+ getBillingHistory: () => Promise<OrderItem[]>;
18
+ cancelSubscription: (reason?: CancellationReason) => Promise<CancelResult>;
19
+ getPortalUrl: () => Promise<string>;
20
+ }
21
+
22
+ export const BillingContext = createContext<BillingContextValue | undefined>(undefined);
23
+
24
+ export function useBilling(): BillingContextValue {
25
+ const ctx = useContext(BillingContext);
26
+ if (!ctx) {
27
+ throw new Error('useBilling must be used within <BillingProvider>');
28
+ }
29
+ return ctx;
30
+ }
@@ -0,0 +1,3 @@
1
+
2
+ export { useBilling } from '../context/billing-context';
3
+ export type { BillingContextValue } from '../context/billing-context';