@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.
- package/LICENSE +21 -0
- package/README.md +349 -2
- package/dist/billing-adapter.interface-CDkeeowZ.d.mts +94 -0
- package/dist/billing-adapter.interface-m7yE50I0.d.ts +94 -0
- package/dist/billing-cycle.enum--hJRYlgP.d.mts +10 -0
- package/dist/billing-cycle.enum--hJRYlgP.d.ts +10 -0
- package/dist/billing-env-D4YjvA3M.d.mts +8 -0
- package/dist/billing-env-D4YjvA3M.d.ts +8 -0
- package/dist/checkout-metadata-D7Wp_DAD.d.mts +27 -0
- package/dist/checkout-metadata-D7Wp_DAD.d.ts +27 -0
- package/dist/chunk-2KV7JFYF.mjs +61 -0
- package/dist/chunk-2NH5DOLT.mjs +101 -0
- package/dist/chunk-3AXPZOCV.mjs +106 -0
- package/dist/chunk-4N3BASZ6.mjs +51 -0
- package/dist/chunk-NHKK3ENU.mjs +1128 -0
- package/dist/chunk-PIWNUAKW.mjs +30 -0
- package/dist/chunk-QFB7AOSQ.mjs +33 -0
- package/dist/chunk-RQTMBR74.mjs +30 -0
- package/dist/chunk-U45FS4FF.mjs +110 -0
- package/dist/chunk-X6R76FZT.mjs +0 -0
- package/dist/chunk-XQ2HJTTA.mjs +252 -0
- package/dist/core.d.mts +17 -0
- package/dist/core.d.ts +17 -0
- package/dist/core.js +425 -0
- package/dist/core.mjs +66 -0
- package/dist/http.d.mts +12 -0
- package/dist/http.d.ts +12 -0
- package/dist/http.js +115 -0
- package/dist/http.mjs +88 -0
- package/dist/index.d.mts +17 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +1929 -0
- package/dist/index.mjs +153 -0
- package/dist/memory.d.mts +14 -0
- package/dist/memory.d.ts +14 -0
- package/dist/memory.js +130 -0
- package/dist/memory.mjs +6 -0
- package/dist/portal.d.mts +445 -0
- package/dist/portal.d.ts +445 -0
- package/dist/portal.js +2927 -0
- package/dist/portal.mjs +2737 -0
- package/dist/product.mapper-CnY1ISOp.d.ts +97 -0
- package/dist/product.mapper-noVl-sel.d.mts +97 -0
- package/dist/products.d.mts +64 -0
- package/dist/products.d.ts +64 -0
- package/dist/products.js +370 -0
- package/dist/products.mjs +21 -0
- package/dist/react.d.mts +182 -0
- package/dist/react.d.ts +182 -0
- package/dist/react.js +1874 -0
- package/dist/react.mjs +105 -0
- package/dist/server.d.mts +177 -0
- package/dist/server.d.ts +177 -0
- package/dist/server.js +622 -0
- package/dist/server.mjs +417 -0
- package/dist/visual-tokens-BCLVR85G.d.mts +94 -0
- package/dist/visual-tokens-BCLVR85G.d.ts +94 -0
- package/package.json +122 -4
- package/src/core.ts +81 -0
- package/src/domain/entities/cancellation.entity.ts +28 -0
- package/src/domain/entities/checkout.entity.ts +21 -0
- package/src/domain/entities/index.ts +10 -0
- package/src/domain/entities/order.entity.ts +15 -0
- package/src/domain/entities/product.entity.ts +16 -0
- package/src/domain/entities/subscription.entity.ts +24 -0
- package/src/domain/entities/sync.entity.ts +4 -0
- package/src/domain/enums/amount-type.enum.ts +10 -0
- package/src/domain/enums/billing-cycle.enum.ts +30 -0
- package/src/domain/enums/index.ts +5 -0
- package/src/domain/enums/payment-provider.enum.ts +14 -0
- package/src/domain/enums/subscription-status.enum.ts +32 -0
- package/src/domain/index.ts +5 -0
- package/src/domain/interfaces/billing-adapter.interface.ts +18 -0
- package/src/domain/interfaces/index.ts +1 -0
- package/src/domain/mappers/product.mapper.ts +176 -0
- package/src/domain/utils/minor-units.ts +20 -0
- package/src/domain/utils/percent-discount.ts +17 -0
- package/src/http.ts +3 -0
- package/src/index.ts +12 -0
- package/src/infrastructure/browser/hosted-checkout.ts +26 -0
- package/src/infrastructure/config/billing-env.ts +31 -0
- package/src/infrastructure/constants/billing.constants.ts +10 -0
- package/src/infrastructure/http/http-client.ts +83 -0
- package/src/infrastructure/pagination/page-iterator.ts +43 -0
- package/src/infrastructure/query/query-builder.ts +26 -0
- package/src/infrastructure/services/billing.client.ts +300 -0
- package/src/infrastructure/services/create-billing-client.ts +23 -0
- package/src/infrastructure/services/http/create-http-adapter.ts +123 -0
- package/src/infrastructure/services/memory/create-memory-adapter.ts +126 -0
- package/src/infrastructure/services/product.repository.ts +198 -0
- package/src/infrastructure/utils/checkout-metadata.ts +55 -0
- package/src/infrastructure/utils/date-renderer.ts +53 -0
- package/src/infrastructure/utils/money-formatter.ts +39 -0
- package/src/infrastructure/utils/normalization.util.ts +22 -0
- package/src/infrastructure/utils/pagination-clamp.ts +11 -0
- package/src/infrastructure/utils/receipt.util.ts +6 -0
- package/src/infrastructure/utils/security.util.ts +23 -0
- package/src/infrastructure/utils/type-guards.util.ts +23 -0
- package/src/infrastructure/utils/validation.util.ts +10 -0
- package/src/infrastructure/webhook/parse-event.ts +135 -0
- package/src/infrastructure/webhook/verify-webhook.ts +124 -0
- package/src/memory.ts +3 -0
- package/src/portal.ts +2 -0
- package/src/presentation/components/BillingHistoryList.tsx +172 -0
- package/src/presentation/components/BillingProvider.tsx +179 -0
- package/src/presentation/components/CancelSubscriptionButton.tsx +155 -0
- package/src/presentation/components/CheckoutButton.tsx +76 -0
- package/src/presentation/components/CustomerPortalLink.tsx +71 -0
- package/src/presentation/components/PricingTable.tsx +225 -0
- package/src/presentation/components/SubscriptionStatusBadge.tsx +102 -0
- package/src/presentation/context/billing-context.ts +30 -0
- package/src/presentation/hooks/useBilling.ts +3 -0
- package/src/presentation/hooks/useCancelSubscription.ts +47 -0
- package/src/presentation/hooks/useCheckoutButton.ts +49 -0
- package/src/presentation/hooks/useProducts.ts +76 -0
- package/src/presentation/navigation/navigation.strategy.ts +16 -0
- package/src/presentation/portal/TurnkeyBillingPage.tsx +144 -0
- package/src/presentation/portal/components/BillingLayout.tsx +163 -0
- package/src/presentation/portal/components/BillingPage.tsx +22 -0
- package/src/presentation/portal/components/BillingPortal.tsx +248 -0
- package/src/presentation/portal/components/InvoiceCard.tsx +275 -0
- package/src/presentation/portal/components/OverviewTab.tsx +137 -0
- package/src/presentation/portal/components/PaymentMethodsList.tsx +268 -0
- package/src/presentation/portal/components/PlanComparison.tsx +405 -0
- package/src/presentation/portal/components/PlanTab.tsx +197 -0
- package/src/presentation/portal/components/PortalButton.tsx +89 -0
- package/src/presentation/portal/components/PortalSpinner.tsx +30 -0
- package/src/presentation/portal/components/UsageCard.tsx +158 -0
- package/src/presentation/portal/components/index.ts +11 -0
- package/src/presentation/portal/constants.ts +44 -0
- package/src/presentation/portal/create-portal-client.ts +202 -0
- package/src/presentation/portal/hooks/index.ts +3 -0
- package/src/presentation/portal/hooks/useBilling.ts +240 -0
- package/src/presentation/portal/index.ts +97 -0
- package/src/presentation/portal/labels.tsx +218 -0
- package/src/presentation/portal/styles.ts +70 -0
- package/src/presentation/portal/types.ts +213 -0
- package/src/presentation/portal/utils/billing.ts +193 -0
- package/src/presentation/portal/utils/index.ts +27 -0
- package/src/presentation/portal/utils/plans.ts +71 -0
- package/src/presentation/theme/visual-tokens.ts +103 -0
- package/src/presentation/utils/cancellation-reason-catalog.ts +18 -0
- package/src/presentation/utils/checkout-button-visual.ts +50 -0
- package/src/presentation/utils/inline-error-visual.ts +21 -0
- package/src/presentation/utils/product-pricing.ts +33 -0
- package/src/presentation/utils/status-label.ts +23 -0
- package/src/presentation/utils/status-tone.ts +25 -0
- package/src/products.ts +20 -0
- package/src/react.tsx +63 -0
- 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
|
+
}
|