@umituz/web-payment 0.0.0-stage → 1.0.1
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,1128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FREE_SUBSCRIPTION_STATUS
|
|
3
|
+
} from "./chunk-3AXPZOCV.mjs";
|
|
4
|
+
import {
|
|
5
|
+
NoopNavigationStrategy,
|
|
6
|
+
TONE_PAIRS,
|
|
7
|
+
VisualTokens
|
|
8
|
+
} from "./chunk-2NH5DOLT.mjs";
|
|
9
|
+
import {
|
|
10
|
+
minorUnitDivisor
|
|
11
|
+
} from "./chunk-PIWNUAKW.mjs";
|
|
12
|
+
import {
|
|
13
|
+
getAllProducts
|
|
14
|
+
} from "./chunk-XQ2HJTTA.mjs";
|
|
15
|
+
|
|
16
|
+
// src/presentation/components/BillingProvider.tsx
|
|
17
|
+
import {
|
|
18
|
+
useCallback,
|
|
19
|
+
useEffect,
|
|
20
|
+
useMemo,
|
|
21
|
+
useRef,
|
|
22
|
+
useState
|
|
23
|
+
} from "react";
|
|
24
|
+
|
|
25
|
+
// src/infrastructure/utils/security.util.ts
|
|
26
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]"]);
|
|
27
|
+
function isValidCheckoutUrl(url) {
|
|
28
|
+
return typeof url === "string" && (url.startsWith("https://") || url.startsWith("http://"));
|
|
29
|
+
}
|
|
30
|
+
function isInsecureUrl(url) {
|
|
31
|
+
return typeof url === "string" && url.startsWith("http://");
|
|
32
|
+
}
|
|
33
|
+
function isLoopbackHost(url) {
|
|
34
|
+
try {
|
|
35
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
36
|
+
return LOOPBACK_HOSTS.has(host);
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function isProductionInsecureUrl(url) {
|
|
42
|
+
return isInsecureUrl(url) && !isLoopbackHost(url);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/infrastructure/utils/validation.util.ts
|
|
46
|
+
function normalizeUserId(userId) {
|
|
47
|
+
if (typeof userId !== "string") return void 0;
|
|
48
|
+
const trimmed = userId.trim();
|
|
49
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
50
|
+
}
|
|
51
|
+
function isValidProductId(productId) {
|
|
52
|
+
return typeof productId === "string" && productId.trim().length > 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// src/presentation/context/billing-context.ts
|
|
56
|
+
import { createContext, useContext } from "react";
|
|
57
|
+
var BillingContext = createContext(void 0);
|
|
58
|
+
function useBilling() {
|
|
59
|
+
const ctx = useContext(BillingContext);
|
|
60
|
+
if (!ctx) {
|
|
61
|
+
throw new Error("useBilling must be used within <BillingProvider>");
|
|
62
|
+
}
|
|
63
|
+
return ctx;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/presentation/components/BillingProvider.tsx
|
|
67
|
+
import { jsx } from "react/jsx-runtime";
|
|
68
|
+
function defaultNavigation() {
|
|
69
|
+
if (typeof window === "undefined") return new NoopNavigationStrategy();
|
|
70
|
+
return {
|
|
71
|
+
redirect(url) {
|
|
72
|
+
window.location.href = url;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function BillingProvider({
|
|
77
|
+
adapter,
|
|
78
|
+
userId,
|
|
79
|
+
navigation,
|
|
80
|
+
children
|
|
81
|
+
}) {
|
|
82
|
+
const [status, setStatus] = useState(FREE_SUBSCRIPTION_STATUS);
|
|
83
|
+
const [loading, setLoading] = useState(true);
|
|
84
|
+
const adapterRef = useRef(adapter);
|
|
85
|
+
const userIdRef = useRef(normalizeUserId(userId));
|
|
86
|
+
const navigationRef = useRef(navigation ?? defaultNavigation());
|
|
87
|
+
const refreshGenerationRef = useRef(0);
|
|
88
|
+
adapterRef.current = adapter;
|
|
89
|
+
userIdRef.current = normalizeUserId(userId);
|
|
90
|
+
navigationRef.current = navigation ?? navigationRef.current;
|
|
91
|
+
const refresh = useCallback(async () => {
|
|
92
|
+
const generation = ++refreshGenerationRef.current;
|
|
93
|
+
const uid = userIdRef.current;
|
|
94
|
+
if (!uid) {
|
|
95
|
+
setStatus(FREE_SUBSCRIPTION_STATUS);
|
|
96
|
+
setLoading(false);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setLoading(true);
|
|
100
|
+
try {
|
|
101
|
+
const next = await adapterRef.current.getStatus(uid);
|
|
102
|
+
if (generation !== refreshGenerationRef.current) return;
|
|
103
|
+
setStatus(next);
|
|
104
|
+
} catch {
|
|
105
|
+
if (generation !== refreshGenerationRef.current) return;
|
|
106
|
+
setStatus(FREE_SUBSCRIPTION_STATUS);
|
|
107
|
+
} finally {
|
|
108
|
+
if (generation === refreshGenerationRef.current) {
|
|
109
|
+
setLoading(false);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}, []);
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
void refresh();
|
|
115
|
+
}, [userId, refresh]);
|
|
116
|
+
const startCheckout = useCallback(async (params) => {
|
|
117
|
+
if (!isValidProductId(params.productId)) {
|
|
118
|
+
throw new Error("[billing] Invalid productId: must be a non-empty string");
|
|
119
|
+
}
|
|
120
|
+
const uid = userIdRef.current;
|
|
121
|
+
if (!uid) {
|
|
122
|
+
throw new Error("[billing] Cannot start checkout: no authenticated user");
|
|
123
|
+
}
|
|
124
|
+
const result = await adapterRef.current.createCheckout({ ...params, userId: uid });
|
|
125
|
+
if (!isValidCheckoutUrl(result.url)) {
|
|
126
|
+
throw new Error("[billing] Invalid checkout URL: must start with https:// or http://");
|
|
127
|
+
}
|
|
128
|
+
if (isProductionInsecureUrl(result.url)) {
|
|
129
|
+
throw new Error("[billing] Insecure http:// checkout URL in production");
|
|
130
|
+
}
|
|
131
|
+
navigationRef.current.redirect(result.url);
|
|
132
|
+
}, []);
|
|
133
|
+
const syncSubscription = useCallback(async () => {
|
|
134
|
+
const uid = userIdRef.current;
|
|
135
|
+
if (!uid) return { synced: false };
|
|
136
|
+
const checkoutId = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("checkout_id") ?? void 0 : void 0;
|
|
137
|
+
const result = await adapterRef.current.syncSubscription(uid, checkoutId);
|
|
138
|
+
if (result.synced) {
|
|
139
|
+
await refresh();
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}, [refresh]);
|
|
143
|
+
const getBillingHistory = useCallback(async () => {
|
|
144
|
+
const uid = userIdRef.current;
|
|
145
|
+
if (!uid) return [];
|
|
146
|
+
return adapterRef.current.getBillingHistory(uid);
|
|
147
|
+
}, []);
|
|
148
|
+
const cancelSubscription = useCallback(
|
|
149
|
+
async (reason) => {
|
|
150
|
+
const result = await adapterRef.current.cancelSubscription(reason);
|
|
151
|
+
if (result.success) {
|
|
152
|
+
await refresh();
|
|
153
|
+
}
|
|
154
|
+
return result;
|
|
155
|
+
},
|
|
156
|
+
[refresh]
|
|
157
|
+
);
|
|
158
|
+
const getPortalUrl = useCallback(async () => {
|
|
159
|
+
const uid = userIdRef.current;
|
|
160
|
+
if (!uid) {
|
|
161
|
+
throw new Error("[billing] Cannot get portal URL: no authenticated user");
|
|
162
|
+
}
|
|
163
|
+
return adapterRef.current.getPortalUrl(uid);
|
|
164
|
+
}, []);
|
|
165
|
+
const value = useMemo(
|
|
166
|
+
() => ({
|
|
167
|
+
status,
|
|
168
|
+
loading,
|
|
169
|
+
refresh,
|
|
170
|
+
startCheckout,
|
|
171
|
+
syncSubscription,
|
|
172
|
+
getBillingHistory,
|
|
173
|
+
cancelSubscription,
|
|
174
|
+
getPortalUrl
|
|
175
|
+
}),
|
|
176
|
+
[
|
|
177
|
+
status,
|
|
178
|
+
loading,
|
|
179
|
+
refresh,
|
|
180
|
+
startCheckout,
|
|
181
|
+
syncSubscription,
|
|
182
|
+
getBillingHistory,
|
|
183
|
+
cancelSubscription,
|
|
184
|
+
getPortalUrl
|
|
185
|
+
]
|
|
186
|
+
);
|
|
187
|
+
return /* @__PURE__ */ jsx(BillingContext.Provider, { value, children });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/presentation/components/CheckoutButton.tsx
|
|
191
|
+
import { useMemo as useMemo2 } from "react";
|
|
192
|
+
|
|
193
|
+
// src/presentation/hooks/useCheckoutButton.ts
|
|
194
|
+
import { useCallback as useCallback2, useState as useState2 } from "react";
|
|
195
|
+
function useCheckoutButton(options) {
|
|
196
|
+
const { startCheckout } = useBilling();
|
|
197
|
+
const [loading, setLoading] = useState2(false);
|
|
198
|
+
const [error, setError] = useState2(null);
|
|
199
|
+
const { productId, billingCycle, successUrl, planKey, onError } = options;
|
|
200
|
+
const start = useCallback2(async () => {
|
|
201
|
+
setLoading(true);
|
|
202
|
+
setError(null);
|
|
203
|
+
try {
|
|
204
|
+
await startCheckout({
|
|
205
|
+
productId,
|
|
206
|
+
billingCycle,
|
|
207
|
+
successUrl,
|
|
208
|
+
planKey
|
|
209
|
+
});
|
|
210
|
+
} catch (err) {
|
|
211
|
+
const normalized = err instanceof Error ? err : new Error(String(err));
|
|
212
|
+
setError(normalized);
|
|
213
|
+
onError?.(normalized);
|
|
214
|
+
} finally {
|
|
215
|
+
setLoading(false);
|
|
216
|
+
}
|
|
217
|
+
}, [startCheckout, productId, billingCycle, successUrl, planKey, onError]);
|
|
218
|
+
return { start, loading, error };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// src/presentation/utils/checkout-button-visual.ts
|
|
222
|
+
var VARIANT_COLOR_SCHEMES = Object.freeze({
|
|
223
|
+
primary: Object.freeze({
|
|
224
|
+
backgroundColor: VisualTokens.color.brandPrimary,
|
|
225
|
+
color: VisualTokens.color.textOnPrimary,
|
|
226
|
+
border: `1px solid ${VisualTokens.color.brandPrimary}`
|
|
227
|
+
}),
|
|
228
|
+
secondary: Object.freeze({
|
|
229
|
+
backgroundColor: VisualTokens.color.surfacePrimary,
|
|
230
|
+
color: VisualTokens.color.textPrimary,
|
|
231
|
+
border: `1px solid ${VisualTokens.color.borderStrong}`
|
|
232
|
+
}),
|
|
233
|
+
danger: Object.freeze({
|
|
234
|
+
backgroundColor: VisualTokens.color.brandDanger,
|
|
235
|
+
color: VisualTokens.color.textOnPrimary,
|
|
236
|
+
border: `1px solid ${VisualTokens.color.brandDanger}`
|
|
237
|
+
})
|
|
238
|
+
});
|
|
239
|
+
var CheckoutButtonVisual = class {
|
|
240
|
+
static resolve({ variant, disabled, loading }) {
|
|
241
|
+
const scheme = VARIANT_COLOR_SCHEMES[variant];
|
|
242
|
+
const isInactive = disabled || loading;
|
|
243
|
+
return {
|
|
244
|
+
...scheme,
|
|
245
|
+
padding: `${VisualTokens.spacing.lg}px ${VisualTokens.spacing.xl}px`,
|
|
246
|
+
fontSize: VisualTokens.fontSize.body,
|
|
247
|
+
fontWeight: VisualTokens.fontWeight.strong,
|
|
248
|
+
borderRadius: VisualTokens.radius.md,
|
|
249
|
+
cursor: isInactive ? "not-allowed" : "pointer",
|
|
250
|
+
opacity: isInactive ? VisualTokens.opacity.disabled : 1,
|
|
251
|
+
transition: VisualTokens.transition.instant
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// src/presentation/utils/inline-error-visual.ts
|
|
257
|
+
import { createElement } from "react";
|
|
258
|
+
var InlineErrorVisual = class _InlineErrorVisual {
|
|
259
|
+
static style() {
|
|
260
|
+
return {
|
|
261
|
+
color: VisualTokens.color.textError,
|
|
262
|
+
fontSize: VisualTokens.fontSize.caption
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
static element(message) {
|
|
266
|
+
if (!message) return null;
|
|
267
|
+
return createElement(
|
|
268
|
+
"span",
|
|
269
|
+
{ role: "alert", style: _InlineErrorVisual.style() },
|
|
270
|
+
message
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// src/presentation/components/CheckoutButton.tsx
|
|
276
|
+
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
277
|
+
var DEFAULT_LABELS = Object.freeze({
|
|
278
|
+
loading: "Redirecting\u2026"
|
|
279
|
+
});
|
|
280
|
+
function CheckoutButton({
|
|
281
|
+
productId,
|
|
282
|
+
billingCycle,
|
|
283
|
+
successUrl,
|
|
284
|
+
planKey,
|
|
285
|
+
label,
|
|
286
|
+
variant = "primary",
|
|
287
|
+
disabled = false,
|
|
288
|
+
onError,
|
|
289
|
+
className,
|
|
290
|
+
labels
|
|
291
|
+
}) {
|
|
292
|
+
const { start, loading, error } = useCheckoutButton({
|
|
293
|
+
productId,
|
|
294
|
+
billingCycle,
|
|
295
|
+
successUrl,
|
|
296
|
+
planKey,
|
|
297
|
+
onError
|
|
298
|
+
});
|
|
299
|
+
const style = useMemo2(
|
|
300
|
+
() => CheckoutButtonVisual.resolve({ variant, disabled, loading }),
|
|
301
|
+
[variant, disabled, loading]
|
|
302
|
+
);
|
|
303
|
+
const text = useMemo2(
|
|
304
|
+
() => ({ ...DEFAULT_LABELS, ...labels }),
|
|
305
|
+
[labels]
|
|
306
|
+
);
|
|
307
|
+
return /* @__PURE__ */ jsxs("span", { style: { display: "inline-flex", flexDirection: "column", gap: 4 }, children: [
|
|
308
|
+
/* @__PURE__ */ jsx2(
|
|
309
|
+
"button",
|
|
310
|
+
{
|
|
311
|
+
type: "button",
|
|
312
|
+
style,
|
|
313
|
+
className,
|
|
314
|
+
onClick: () => void start(),
|
|
315
|
+
disabled: disabled || loading,
|
|
316
|
+
"aria-busy": loading,
|
|
317
|
+
children: loading ? text.loading : label
|
|
318
|
+
}
|
|
319
|
+
),
|
|
320
|
+
InlineErrorVisual.element(error?.message)
|
|
321
|
+
] });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/presentation/components/SubscriptionStatusBadge.tsx
|
|
325
|
+
import { useMemo as useMemo3 } from "react";
|
|
326
|
+
|
|
327
|
+
// src/presentation/utils/status-tone.ts
|
|
328
|
+
var StatusTone = class _StatusTone {
|
|
329
|
+
static MAP = Object.freeze({
|
|
330
|
+
active: "active",
|
|
331
|
+
trialing: "active",
|
|
332
|
+
past_due: "warning",
|
|
333
|
+
incomplete: "warning",
|
|
334
|
+
canceled: "danger",
|
|
335
|
+
revoked: "danger",
|
|
336
|
+
unpaid: "danger",
|
|
337
|
+
incomplete_expired: "danger",
|
|
338
|
+
none: "neutral"
|
|
339
|
+
});
|
|
340
|
+
static forStatus(status) {
|
|
341
|
+
return _StatusTone.MAP[status] ?? "neutral";
|
|
342
|
+
}
|
|
343
|
+
static pairFor(status) {
|
|
344
|
+
return TONE_PAIRS[_StatusTone.forStatus(status)];
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// src/presentation/utils/status-label.ts
|
|
349
|
+
var StatusLabel = class _StatusLabel {
|
|
350
|
+
static MAP = Object.freeze({
|
|
351
|
+
active: "Active",
|
|
352
|
+
trialing: "Trial",
|
|
353
|
+
past_due: "Past due",
|
|
354
|
+
incomplete: "Incomplete",
|
|
355
|
+
canceled: "Canceled",
|
|
356
|
+
revoked: "Revoked",
|
|
357
|
+
unpaid: "Unpaid",
|
|
358
|
+
incomplete_expired: "Expired",
|
|
359
|
+
none: "Free"
|
|
360
|
+
});
|
|
361
|
+
static forStatus(status, overrides) {
|
|
362
|
+
return overrides?.[status] ?? _StatusLabel.MAP[status] ?? "Free";
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// src/infrastructure/utils/date-renderer.ts
|
|
367
|
+
var DEFAULT_LOCALE = "en-US";
|
|
368
|
+
var ISO_DATE_DISPLAY_LENGTH = 10;
|
|
369
|
+
var FALLBACK_DATE_LABEL = "soon";
|
|
370
|
+
var FALLBACK_LONG_DATE = "\u2014";
|
|
371
|
+
var DateRenderer = class _DateRenderer {
|
|
372
|
+
dateLength;
|
|
373
|
+
shortFormatter;
|
|
374
|
+
longFormatter;
|
|
375
|
+
constructor(locale = DEFAULT_LOCALE, dateLength = ISO_DATE_DISPLAY_LENGTH) {
|
|
376
|
+
this.dateLength = dateLength;
|
|
377
|
+
this.shortFormatter = new Intl.DateTimeFormat(locale, {
|
|
378
|
+
year: "numeric",
|
|
379
|
+
month: "short",
|
|
380
|
+
day: "numeric"
|
|
381
|
+
});
|
|
382
|
+
this.longFormatter = new Intl.DateTimeFormat(locale, {
|
|
383
|
+
year: "numeric",
|
|
384
|
+
month: "long",
|
|
385
|
+
day: "numeric"
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
short(iso) {
|
|
389
|
+
if (!iso) return FALLBACK_LONG_DATE;
|
|
390
|
+
return iso.slice(0, this.dateLength);
|
|
391
|
+
}
|
|
392
|
+
renewal(iso, fallback = FALLBACK_DATE_LABEL) {
|
|
393
|
+
const date = _DateRenderer.parse(iso);
|
|
394
|
+
if (!date) return fallback;
|
|
395
|
+
return this.shortFormatter.format(date);
|
|
396
|
+
}
|
|
397
|
+
long(iso) {
|
|
398
|
+
if (!iso) return FALLBACK_LONG_DATE;
|
|
399
|
+
const date = _DateRenderer.parse(iso);
|
|
400
|
+
if (!date) return iso;
|
|
401
|
+
return this.longFormatter.format(date);
|
|
402
|
+
}
|
|
403
|
+
static parse(iso) {
|
|
404
|
+
if (!iso) return null;
|
|
405
|
+
const date = new Date(iso);
|
|
406
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// src/presentation/components/SubscriptionStatusBadge.tsx
|
|
411
|
+
import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
412
|
+
var DEFAULT_LABELS2 = Object.freeze({
|
|
413
|
+
loading: "Loading\u2026",
|
|
414
|
+
endsPrefix: "ends"
|
|
415
|
+
});
|
|
416
|
+
function SubscriptionStatusBadge({
|
|
417
|
+
status: statusOverride,
|
|
418
|
+
loading: loadingOverride,
|
|
419
|
+
locale,
|
|
420
|
+
labels
|
|
421
|
+
}) {
|
|
422
|
+
const ctx = useBilling();
|
|
423
|
+
const status = statusOverride ?? ctx.status;
|
|
424
|
+
const loading = loadingOverride ?? ctx.loading;
|
|
425
|
+
const dateRenderer = useMemo3(() => new DateRenderer(locale), [locale]);
|
|
426
|
+
const text = useMemo3(
|
|
427
|
+
() => ({ ...DEFAULT_LABELS2, ...labels }),
|
|
428
|
+
[labels]
|
|
429
|
+
);
|
|
430
|
+
const pillStyle = useMemo3(
|
|
431
|
+
() => ({
|
|
432
|
+
padding: `${VisualTokens.spacing.xs}px ${VisualTokens.spacing.lg - 2}px`,
|
|
433
|
+
borderRadius: VisualTokens.radius.pill,
|
|
434
|
+
fontSize: VisualTokens.fontSize.caption,
|
|
435
|
+
fontWeight: VisualTokens.fontWeight.strong
|
|
436
|
+
}),
|
|
437
|
+
[]
|
|
438
|
+
);
|
|
439
|
+
if (loading) {
|
|
440
|
+
return /* @__PURE__ */ jsx3(
|
|
441
|
+
"span",
|
|
442
|
+
{
|
|
443
|
+
style: {
|
|
444
|
+
...pillStyle,
|
|
445
|
+
...TONE_PAIRS.neutral
|
|
446
|
+
},
|
|
447
|
+
children: text.loading
|
|
448
|
+
}
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
const tone = StatusTone.forStatus(status.subscriptionStatus);
|
|
452
|
+
const color = TONE_PAIRS[tone];
|
|
453
|
+
const label = StatusLabel.forStatus(status.subscriptionStatus, labels?.statuses);
|
|
454
|
+
const showPlan = status.plan && status.plan !== "free";
|
|
455
|
+
const showEnds = status.cancelAtPeriodEnd;
|
|
456
|
+
const endsLabel = showEnds ? `${text.endsPrefix} ${dateRenderer.renewal(status.currentPeriodEnd)}` : null;
|
|
457
|
+
return /* @__PURE__ */ jsxs2(
|
|
458
|
+
"span",
|
|
459
|
+
{
|
|
460
|
+
"aria-label": `Subscription ${label}`,
|
|
461
|
+
style: {
|
|
462
|
+
backgroundColor: color.bg,
|
|
463
|
+
color: color.fg,
|
|
464
|
+
padding: pillStyle.padding,
|
|
465
|
+
borderRadius: VisualTokens.radius.pill,
|
|
466
|
+
fontSize: VisualTokens.fontSize.caption,
|
|
467
|
+
fontWeight: VisualTokens.fontWeight.strong,
|
|
468
|
+
display: "inline-flex",
|
|
469
|
+
alignItems: "center",
|
|
470
|
+
gap: VisualTokens.spacing.xs + 2
|
|
471
|
+
},
|
|
472
|
+
children: [
|
|
473
|
+
/* @__PURE__ */ jsx3("span", { style: { textTransform: "capitalize" }, children: label }),
|
|
474
|
+
showPlan ? /* @__PURE__ */ jsxs2("span", { style: { opacity: VisualTokens.opacity.muted }, children: [
|
|
475
|
+
"\xB7 ",
|
|
476
|
+
status.plan
|
|
477
|
+
] }) : null,
|
|
478
|
+
endsLabel ? /* @__PURE__ */ jsxs2("span", { style: { opacity: VisualTokens.opacity.muted }, children: [
|
|
479
|
+
"\xB7 ",
|
|
480
|
+
endsLabel
|
|
481
|
+
] }) : null
|
|
482
|
+
]
|
|
483
|
+
}
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/presentation/components/PricingTable.tsx
|
|
488
|
+
import { useMemo as useMemo4 } from "react";
|
|
489
|
+
|
|
490
|
+
// src/presentation/hooks/useProducts.ts
|
|
491
|
+
import { useCallback as useCallback3, useEffect as useEffect2, useRef as useRef2, useState as useState3 } from "react";
|
|
492
|
+
function useProducts(options) {
|
|
493
|
+
const { apiToken, organizationId, isRecurring = true, enabled = true } = options;
|
|
494
|
+
const [products, setProducts] = useState3([]);
|
|
495
|
+
const [loading, setLoading] = useState3(enabled);
|
|
496
|
+
const [error, setError] = useState3(null);
|
|
497
|
+
const optionsRef = useRef2({ apiToken, organizationId, isRecurring, enabled });
|
|
498
|
+
optionsRef.current = { apiToken, organizationId, isRecurring, enabled };
|
|
499
|
+
const abortRef = useRef2(null);
|
|
500
|
+
const fetchProducts = useCallback3(async () => {
|
|
501
|
+
const { apiToken: token, organizationId: org, isRecurring: recurring, enabled: isEnabled } = optionsRef.current;
|
|
502
|
+
abortRef.current?.abort();
|
|
503
|
+
if (!isEnabled) {
|
|
504
|
+
setLoading(false);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const controller = new AbortController();
|
|
508
|
+
abortRef.current = controller;
|
|
509
|
+
setLoading(true);
|
|
510
|
+
setError(null);
|
|
511
|
+
try {
|
|
512
|
+
const params = { isRecurring: recurring };
|
|
513
|
+
if (org) params.organizationId = org;
|
|
514
|
+
const data = await getAllProducts({ apiToken: token }, params, {
|
|
515
|
+
signal: controller.signal
|
|
516
|
+
});
|
|
517
|
+
if (controller.signal.aborted) return;
|
|
518
|
+
setProducts(data);
|
|
519
|
+
} catch (err) {
|
|
520
|
+
if (controller.signal.aborted) return;
|
|
521
|
+
setError(err instanceof Error ? err : new Error("Failed to fetch products"));
|
|
522
|
+
} finally {
|
|
523
|
+
if (!controller.signal.aborted) {
|
|
524
|
+
setLoading(false);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}, []);
|
|
528
|
+
useEffect2(() => {
|
|
529
|
+
void fetchProducts();
|
|
530
|
+
return () => {
|
|
531
|
+
abortRef.current?.abort();
|
|
532
|
+
};
|
|
533
|
+
}, [apiToken, organizationId, isRecurring, enabled, fetchProducts]);
|
|
534
|
+
return { products, loading, error, refetch: fetchProducts };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/presentation/utils/product-pricing.ts
|
|
538
|
+
var FALLBACK_PRICE_LABEL = "Custom";
|
|
539
|
+
var ProductPricing = class _ProductPricing {
|
|
540
|
+
static recurringPrice(product, cycle) {
|
|
541
|
+
const recurring = product.prices.filter(
|
|
542
|
+
(p) => p.recurringInterval !== void 0
|
|
543
|
+
);
|
|
544
|
+
if (recurring.length === 0) {
|
|
545
|
+
return product.prices[0] ?? null;
|
|
546
|
+
}
|
|
547
|
+
if (cycle) {
|
|
548
|
+
const match = recurring.find((p) => p.recurringInterval === cycle);
|
|
549
|
+
if (match) return match;
|
|
550
|
+
}
|
|
551
|
+
return recurring[0] ?? null;
|
|
552
|
+
}
|
|
553
|
+
static displayLine(product, formatter, cycle) {
|
|
554
|
+
const price = _ProductPricing.recurringPrice(product, cycle);
|
|
555
|
+
if (!price) {
|
|
556
|
+
return { amount: FALLBACK_PRICE_LABEL, interval: null };
|
|
557
|
+
}
|
|
558
|
+
const amount = formatter(price.amount, price.currency);
|
|
559
|
+
return { amount, interval: price.recurringInterval ?? null };
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
// src/infrastructure/utils/money-formatter.ts
|
|
564
|
+
var DEFAULT_LOCALE2 = "en-US";
|
|
565
|
+
var DEFAULT_CURRENCY = "USD";
|
|
566
|
+
var MoneyFormatter = class {
|
|
567
|
+
locale;
|
|
568
|
+
constructor(locale = DEFAULT_LOCALE2) {
|
|
569
|
+
this.locale = locale;
|
|
570
|
+
}
|
|
571
|
+
format(amount, currency, options = {}) {
|
|
572
|
+
if (amount === void 0 || amount === null) {
|
|
573
|
+
return "Custom";
|
|
574
|
+
}
|
|
575
|
+
const code = (currency ?? options.fallbackCurrency ?? DEFAULT_CURRENCY).toUpperCase();
|
|
576
|
+
try {
|
|
577
|
+
return new Intl.NumberFormat(this.locale, {
|
|
578
|
+
style: "currency",
|
|
579
|
+
currency: code,
|
|
580
|
+
maximumFractionDigits: options.maximumFractionDigits ?? 2
|
|
581
|
+
}).format(amount / minorUnitDivisor(code));
|
|
582
|
+
} catch {
|
|
583
|
+
return `${amount} ${code}`;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
// src/presentation/components/PricingTable.tsx
|
|
589
|
+
import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
590
|
+
var DEFAULT_LABELS3 = Object.freeze({
|
|
591
|
+
loading: "Loading pricing\u2026",
|
|
592
|
+
empty: "No products available.",
|
|
593
|
+
errorPrefix: "Failed to load pricing:",
|
|
594
|
+
retry: "Retry"
|
|
595
|
+
});
|
|
596
|
+
function PricingTable({
|
|
597
|
+
products: productsOverride,
|
|
598
|
+
apiToken,
|
|
599
|
+
organizationId,
|
|
600
|
+
isRecurring = true,
|
|
601
|
+
ctaLabel = "Subscribe",
|
|
602
|
+
onError,
|
|
603
|
+
filterInterval = "monthly",
|
|
604
|
+
locale,
|
|
605
|
+
labels
|
|
606
|
+
}) {
|
|
607
|
+
const enabled = productsOverride === void 0 && apiToken !== void 0;
|
|
608
|
+
const { products: fetchedProducts, loading, error, refetch } = useProducts({
|
|
609
|
+
apiToken: apiToken ?? "",
|
|
610
|
+
organizationId,
|
|
611
|
+
isRecurring,
|
|
612
|
+
enabled
|
|
613
|
+
});
|
|
614
|
+
const products = productsOverride ?? fetchedProducts;
|
|
615
|
+
const moneyFormatter = useMemo4(() => new MoneyFormatter(locale), [locale]);
|
|
616
|
+
const text = useMemo4(
|
|
617
|
+
() => ({ ...DEFAULT_LABELS3, ...labels }),
|
|
618
|
+
[labels]
|
|
619
|
+
);
|
|
620
|
+
const visible = useMemo4(() => {
|
|
621
|
+
if (filterInterval === null) return products;
|
|
622
|
+
return products.filter((p) => p.recurringInterval === filterInterval);
|
|
623
|
+
}, [products, filterInterval]);
|
|
624
|
+
if (enabled && loading) {
|
|
625
|
+
return /* @__PURE__ */ jsx4(
|
|
626
|
+
"div",
|
|
627
|
+
{
|
|
628
|
+
role: "status",
|
|
629
|
+
"aria-live": "polite",
|
|
630
|
+
style: {
|
|
631
|
+
padding: VisualTokens.spacing.section,
|
|
632
|
+
textAlign: "center"
|
|
633
|
+
},
|
|
634
|
+
children: text.loading
|
|
635
|
+
}
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
if (error) {
|
|
639
|
+
return /* @__PURE__ */ jsxs3(
|
|
640
|
+
"div",
|
|
641
|
+
{
|
|
642
|
+
role: "alert",
|
|
643
|
+
style: {
|
|
644
|
+
padding: VisualTokens.spacing.xl,
|
|
645
|
+
background: VisualTokens.color.surfaceDanger,
|
|
646
|
+
borderRadius: VisualTokens.radius.md
|
|
647
|
+
},
|
|
648
|
+
children: [
|
|
649
|
+
/* @__PURE__ */ jsxs3("p", { style: { margin: 0, color: VisualTokens.color.brandDangerText }, children: [
|
|
650
|
+
text.errorPrefix,
|
|
651
|
+
" ",
|
|
652
|
+
error.message
|
|
653
|
+
] }),
|
|
654
|
+
/* @__PURE__ */ jsx4(
|
|
655
|
+
"button",
|
|
656
|
+
{
|
|
657
|
+
type: "button",
|
|
658
|
+
onClick: () => void refetch(),
|
|
659
|
+
style: { marginTop: VisualTokens.spacing.md, padding: "6px 12px" },
|
|
660
|
+
children: text.retry
|
|
661
|
+
}
|
|
662
|
+
)
|
|
663
|
+
]
|
|
664
|
+
}
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
if (visible.length === 0) {
|
|
668
|
+
return /* @__PURE__ */ jsx4(
|
|
669
|
+
"div",
|
|
670
|
+
{
|
|
671
|
+
style: {
|
|
672
|
+
padding: VisualTokens.spacing.section,
|
|
673
|
+
textAlign: "center",
|
|
674
|
+
color: VisualTokens.color.textMuted
|
|
675
|
+
},
|
|
676
|
+
children: text.empty
|
|
677
|
+
}
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
return /* @__PURE__ */ jsx4(
|
|
681
|
+
"div",
|
|
682
|
+
{
|
|
683
|
+
style: {
|
|
684
|
+
display: "grid",
|
|
685
|
+
gridTemplateColumns: `repeat(auto-fit, minmax(${VisualTokens.layout.cardMinWidth}px, 1fr))`,
|
|
686
|
+
gap: VisualTokens.spacing.xl
|
|
687
|
+
},
|
|
688
|
+
children: visible.map((product) => /* @__PURE__ */ jsx4(
|
|
689
|
+
ProductCard,
|
|
690
|
+
{
|
|
691
|
+
product,
|
|
692
|
+
moneyFormatter,
|
|
693
|
+
ctaLabel,
|
|
694
|
+
onError
|
|
695
|
+
},
|
|
696
|
+
product.id
|
|
697
|
+
))
|
|
698
|
+
}
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
function ProductCard({ product, moneyFormatter, ctaLabel, onError }) {
|
|
702
|
+
const { amount, interval } = useMemo4(
|
|
703
|
+
() => ProductPricing.displayLine(
|
|
704
|
+
product,
|
|
705
|
+
(amt, cur) => moneyFormatter.format(amt, cur)
|
|
706
|
+
),
|
|
707
|
+
[product, moneyFormatter]
|
|
708
|
+
);
|
|
709
|
+
return /* @__PURE__ */ jsxs3(
|
|
710
|
+
"article",
|
|
711
|
+
{
|
|
712
|
+
style: {
|
|
713
|
+
border: `1px solid ${VisualTokens.color.borderSubtle}`,
|
|
714
|
+
borderRadius: VisualTokens.radius.lg,
|
|
715
|
+
padding: VisualTokens.spacing.xxl,
|
|
716
|
+
display: "flex",
|
|
717
|
+
flexDirection: "column",
|
|
718
|
+
gap: VisualTokens.spacing.lg
|
|
719
|
+
},
|
|
720
|
+
children: [
|
|
721
|
+
/* @__PURE__ */ jsxs3("header", { children: [
|
|
722
|
+
/* @__PURE__ */ jsx4("h3", { style: { margin: 0, fontSize: VisualTokens.fontSize.title }, children: product.name }),
|
|
723
|
+
product.description ? /* @__PURE__ */ jsx4(
|
|
724
|
+
"p",
|
|
725
|
+
{
|
|
726
|
+
style: {
|
|
727
|
+
margin: `${VisualTokens.spacing.xs}px 0 0`,
|
|
728
|
+
color: VisualTokens.color.textMuted,
|
|
729
|
+
fontSize: VisualTokens.fontSize.body
|
|
730
|
+
},
|
|
731
|
+
children: product.description
|
|
732
|
+
}
|
|
733
|
+
) : null
|
|
734
|
+
] }),
|
|
735
|
+
/* @__PURE__ */ jsxs3("div", { style: { fontSize: VisualTokens.fontSize.display, fontWeight: VisualTokens.fontWeight.strong }, children: [
|
|
736
|
+
amount,
|
|
737
|
+
interval ? /* @__PURE__ */ jsxs3(
|
|
738
|
+
"span",
|
|
739
|
+
{
|
|
740
|
+
style: {
|
|
741
|
+
fontSize: VisualTokens.fontSize.body,
|
|
742
|
+
fontWeight: VisualTokens.fontWeight.regular,
|
|
743
|
+
color: VisualTokens.color.textMuted
|
|
744
|
+
},
|
|
745
|
+
children: [
|
|
746
|
+
" ",
|
|
747
|
+
"/ ",
|
|
748
|
+
interval
|
|
749
|
+
]
|
|
750
|
+
}
|
|
751
|
+
) : null
|
|
752
|
+
] }),
|
|
753
|
+
product.benefits.length > 0 ? /* @__PURE__ */ jsx4(
|
|
754
|
+
"ul",
|
|
755
|
+
{
|
|
756
|
+
style: {
|
|
757
|
+
paddingLeft: 18,
|
|
758
|
+
margin: 0,
|
|
759
|
+
color: "#374151",
|
|
760
|
+
fontSize: VisualTokens.fontSize.body
|
|
761
|
+
},
|
|
762
|
+
children: product.benefits.slice(0, VisualTokens.layout.benefitListMax).map((b) => /* @__PURE__ */ jsx4("li", { children: b.description ?? b.type }, b.id))
|
|
763
|
+
}
|
|
764
|
+
) : null,
|
|
765
|
+
/* @__PURE__ */ jsx4("div", { style: { marginTop: "auto" }, children: /* @__PURE__ */ jsx4(
|
|
766
|
+
CheckoutButton,
|
|
767
|
+
{
|
|
768
|
+
productId: product.id,
|
|
769
|
+
billingCycle: product.recurringInterval ?? void 0,
|
|
770
|
+
label: ctaLabel,
|
|
771
|
+
onError
|
|
772
|
+
}
|
|
773
|
+
) })
|
|
774
|
+
]
|
|
775
|
+
}
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// src/presentation/components/BillingHistoryList.tsx
|
|
780
|
+
import { useEffect as useEffect3, useState as useState4, useCallback as useCallback4, useMemo as useMemo5 } from "react";
|
|
781
|
+
import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
782
|
+
var DEFAULT_LABELS4 = Object.freeze({
|
|
783
|
+
loading: "Loading billing history\u2026",
|
|
784
|
+
empty: "No invoices yet.",
|
|
785
|
+
refresh: "Refresh",
|
|
786
|
+
retry: "Retry",
|
|
787
|
+
paid: "Paid",
|
|
788
|
+
invoiceLink: "View",
|
|
789
|
+
invoiceFallback: "\u2014",
|
|
790
|
+
colDate: "Date",
|
|
791
|
+
colProduct: "Product",
|
|
792
|
+
colStatus: "Status",
|
|
793
|
+
colAmount: "Amount",
|
|
794
|
+
colInvoice: "Invoice"
|
|
795
|
+
});
|
|
796
|
+
function BillingHistoryList({
|
|
797
|
+
autoLoad = true,
|
|
798
|
+
orders: ordersOverride,
|
|
799
|
+
loading: loadingOverride,
|
|
800
|
+
locale,
|
|
801
|
+
labels
|
|
802
|
+
}) {
|
|
803
|
+
const { getBillingHistory } = useBilling();
|
|
804
|
+
const [orders, setOrders] = useState4(ordersOverride ?? []);
|
|
805
|
+
const [loading, setLoading] = useState4(autoLoad && ordersOverride === void 0);
|
|
806
|
+
const [error, setError] = useState4(null);
|
|
807
|
+
const moneyFormatter = useMemo5(() => new MoneyFormatter(locale), [locale]);
|
|
808
|
+
const dateRenderer = useMemo5(() => new DateRenderer(locale), [locale]);
|
|
809
|
+
const text = useMemo5(
|
|
810
|
+
() => ({ ...DEFAULT_LABELS4, ...labels }),
|
|
811
|
+
[labels]
|
|
812
|
+
);
|
|
813
|
+
const refresh = useCallback4(async () => {
|
|
814
|
+
setLoading(true);
|
|
815
|
+
setError(null);
|
|
816
|
+
try {
|
|
817
|
+
const next = await getBillingHistory();
|
|
818
|
+
setOrders(next);
|
|
819
|
+
} catch (err) {
|
|
820
|
+
setError(err instanceof Error ? err.message : "Failed to load billing history");
|
|
821
|
+
} finally {
|
|
822
|
+
setLoading(false);
|
|
823
|
+
}
|
|
824
|
+
}, [getBillingHistory]);
|
|
825
|
+
useEffect3(() => {
|
|
826
|
+
if (autoLoad && ordersOverride === void 0) {
|
|
827
|
+
void refresh();
|
|
828
|
+
}
|
|
829
|
+
}, [autoLoad, ordersOverride, refresh]);
|
|
830
|
+
if (ordersOverride !== void 0 && ordersOverride.length > 0) {
|
|
831
|
+
return renderList(ordersOverride, moneyFormatter, dateRenderer, text);
|
|
832
|
+
}
|
|
833
|
+
if (loadingOverride !== void 0 && loadingOverride) {
|
|
834
|
+
return /* @__PURE__ */ jsx5("div", { role: "status", children: text.loading });
|
|
835
|
+
}
|
|
836
|
+
if (loading) {
|
|
837
|
+
return /* @__PURE__ */ jsx5("div", { role: "status", children: text.loading });
|
|
838
|
+
}
|
|
839
|
+
if (error) {
|
|
840
|
+
return /* @__PURE__ */ jsxs4(
|
|
841
|
+
"div",
|
|
842
|
+
{
|
|
843
|
+
role: "alert",
|
|
844
|
+
style: {
|
|
845
|
+
background: VisualTokens.color.surfaceDanger,
|
|
846
|
+
padding: VisualTokens.spacing.lg,
|
|
847
|
+
borderRadius: VisualTokens.radius.md
|
|
848
|
+
},
|
|
849
|
+
children: [
|
|
850
|
+
error,
|
|
851
|
+
" ",
|
|
852
|
+
/* @__PURE__ */ jsx5("button", { type: "button", onClick: () => void refresh(), children: text.retry })
|
|
853
|
+
]
|
|
854
|
+
}
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
if (orders.length === 0) {
|
|
858
|
+
return /* @__PURE__ */ jsx5("div", { style: { color: VisualTokens.color.textMuted }, children: text.empty });
|
|
859
|
+
}
|
|
860
|
+
return /* @__PURE__ */ jsxs4("div", { children: [
|
|
861
|
+
/* @__PURE__ */ jsx5("div", { style: { marginBottom: VisualTokens.spacing.md }, children: /* @__PURE__ */ jsx5("button", { type: "button", onClick: () => void refresh(), children: text.refresh }) }),
|
|
862
|
+
renderList(orders, moneyFormatter, dateRenderer, text)
|
|
863
|
+
] });
|
|
864
|
+
}
|
|
865
|
+
function renderList(orders, moneyFormatter, dateRenderer, text) {
|
|
866
|
+
return /* @__PURE__ */ jsxs4("table", { style: { width: "100%", borderCollapse: "collapse" }, children: [
|
|
867
|
+
/* @__PURE__ */ jsx5("thead", { children: /* @__PURE__ */ jsxs4("tr", { style: { textAlign: "left", borderBottom: `1px solid ${VisualTokens.color.borderSubtle}` }, children: [
|
|
868
|
+
/* @__PURE__ */ jsx5("th", { style: { padding: VisualTokens.spacing.md }, children: text.colDate }),
|
|
869
|
+
/* @__PURE__ */ jsx5("th", { style: { padding: VisualTokens.spacing.md }, children: text.colProduct }),
|
|
870
|
+
/* @__PURE__ */ jsx5("th", { style: { padding: VisualTokens.spacing.md }, children: text.colStatus }),
|
|
871
|
+
/* @__PURE__ */ jsx5("th", { style: { padding: VisualTokens.spacing.md }, children: text.colAmount }),
|
|
872
|
+
/* @__PURE__ */ jsx5("th", { style: { padding: VisualTokens.spacing.md }, children: text.colInvoice })
|
|
873
|
+
] }) }),
|
|
874
|
+
/* @__PURE__ */ jsx5("tbody", { children: orders.map((order) => /* @__PURE__ */ jsxs4("tr", { style: { borderBottom: `1px solid ${VisualTokens.color.surfaceSubtle}` }, children: [
|
|
875
|
+
/* @__PURE__ */ jsx5("td", { style: { padding: VisualTokens.spacing.md }, children: dateRenderer.short(order.createdAt) }),
|
|
876
|
+
/* @__PURE__ */ jsx5("td", { style: { padding: VisualTokens.spacing.md }, children: order.productName }),
|
|
877
|
+
/* @__PURE__ */ jsx5("td", { style: { padding: VisualTokens.spacing.md }, children: order.paid ? text.paid : order.status }),
|
|
878
|
+
/* @__PURE__ */ jsx5("td", { style: { padding: VisualTokens.spacing.md }, children: moneyFormatter.format(order.amount, order.currency) }),
|
|
879
|
+
/* @__PURE__ */ jsx5("td", { style: { padding: VisualTokens.spacing.md }, children: order.invoiceUrl ? /* @__PURE__ */ jsx5("a", { href: order.invoiceUrl, target: "_blank", rel: "noreferrer", children: text.invoiceLink }) : text.invoiceFallback })
|
|
880
|
+
] }, order.id)) })
|
|
881
|
+
] });
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// src/presentation/components/CustomerPortalLink.tsx
|
|
885
|
+
import { useState as useState5 } from "react";
|
|
886
|
+
import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
887
|
+
var DEFAULT_LABEL = "Manage subscription";
|
|
888
|
+
var DEFAULT_LOADING_LABEL = "Opening\u2026";
|
|
889
|
+
function CustomerPortalLink({
|
|
890
|
+
label = DEFAULT_LABEL,
|
|
891
|
+
target = "open",
|
|
892
|
+
className,
|
|
893
|
+
labels
|
|
894
|
+
}) {
|
|
895
|
+
const { getPortalUrl } = useBilling();
|
|
896
|
+
const [loading, setLoading] = useState5(false);
|
|
897
|
+
const [error, setError] = useState5(null);
|
|
898
|
+
const loadingLabel = labels?.loading ?? DEFAULT_LOADING_LABEL;
|
|
899
|
+
const open = async () => {
|
|
900
|
+
setLoading(true);
|
|
901
|
+
setError(null);
|
|
902
|
+
try {
|
|
903
|
+
const url = await getPortalUrl();
|
|
904
|
+
if (target === "open") {
|
|
905
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
906
|
+
} else {
|
|
907
|
+
window.location.href = url;
|
|
908
|
+
}
|
|
909
|
+
} catch (err) {
|
|
910
|
+
setError(err instanceof Error ? err.message : "Failed to open portal");
|
|
911
|
+
} finally {
|
|
912
|
+
setLoading(false);
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
return /* @__PURE__ */ jsxs5("span", { style: { display: "inline-flex", flexDirection: "column", gap: VisualTokens.spacing.xs }, children: [
|
|
916
|
+
/* @__PURE__ */ jsx6(
|
|
917
|
+
"button",
|
|
918
|
+
{
|
|
919
|
+
type: "button",
|
|
920
|
+
onClick: () => void open(),
|
|
921
|
+
disabled: loading,
|
|
922
|
+
className,
|
|
923
|
+
style: {
|
|
924
|
+
background: "transparent",
|
|
925
|
+
color: VisualTokens.color.brandPrimary,
|
|
926
|
+
border: "none",
|
|
927
|
+
padding: 0,
|
|
928
|
+
textDecoration: "underline",
|
|
929
|
+
cursor: loading ? "wait" : "pointer"
|
|
930
|
+
},
|
|
931
|
+
children: loading ? loadingLabel : label
|
|
932
|
+
}
|
|
933
|
+
),
|
|
934
|
+
InlineErrorVisual.element(error)
|
|
935
|
+
] });
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// src/presentation/components/CancelSubscriptionButton.tsx
|
|
939
|
+
import { useState as useState7 } from "react";
|
|
940
|
+
|
|
941
|
+
// src/presentation/hooks/useCancelSubscription.ts
|
|
942
|
+
import { useCallback as useCallback5, useState as useState6 } from "react";
|
|
943
|
+
function useCancelSubscription(options = {}) {
|
|
944
|
+
const { cancelSubscription } = useBilling();
|
|
945
|
+
const [loading, setLoading] = useState6(false);
|
|
946
|
+
const [error, setError] = useState6(null);
|
|
947
|
+
const { onSuccess, onError } = options;
|
|
948
|
+
const cancel = useCallback5(
|
|
949
|
+
async (reason) => {
|
|
950
|
+
setLoading(true);
|
|
951
|
+
setError(null);
|
|
952
|
+
try {
|
|
953
|
+
const result = await cancelSubscription(reason);
|
|
954
|
+
onSuccess?.(result);
|
|
955
|
+
return result;
|
|
956
|
+
} catch (err) {
|
|
957
|
+
const normalized = err instanceof Error ? err : new Error(String(err));
|
|
958
|
+
setError(normalized);
|
|
959
|
+
onError?.(normalized);
|
|
960
|
+
return null;
|
|
961
|
+
} finally {
|
|
962
|
+
setLoading(false);
|
|
963
|
+
}
|
|
964
|
+
},
|
|
965
|
+
[cancelSubscription, onSuccess, onError]
|
|
966
|
+
);
|
|
967
|
+
return { cancel, loading, error };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// src/presentation/utils/cancellation-reason-catalog.ts
|
|
971
|
+
var CANCELLATION_REASON_CATALOG = Object.freeze([
|
|
972
|
+
{ value: "too_expensive", label: "Too expensive" },
|
|
973
|
+
{ value: "missing_features", label: "Missing features" },
|
|
974
|
+
{ value: "switched_service", label: "Switched to a different service" },
|
|
975
|
+
{ value: "unused", label: "Not using it" },
|
|
976
|
+
{ value: "customer_service", label: "Poor customer service" },
|
|
977
|
+
{ value: "low_quality", label: "Quality is too low" },
|
|
978
|
+
{ value: "too_complex", label: "Too complex" },
|
|
979
|
+
{ value: "other", label: "Other" }
|
|
980
|
+
]);
|
|
981
|
+
|
|
982
|
+
// src/presentation/components/CancelSubscriptionButton.tsx
|
|
983
|
+
import { Fragment, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
984
|
+
var DEFAULT_LABELS5 = Object.freeze({
|
|
985
|
+
action: "Cancel subscription",
|
|
986
|
+
confirm: "Confirm cancel",
|
|
987
|
+
keep: "Keep subscription",
|
|
988
|
+
canceling: "Canceling\u2026",
|
|
989
|
+
promptTitle: "Why are you canceling? (optional)",
|
|
990
|
+
noReason: "Prefer not to say"
|
|
991
|
+
});
|
|
992
|
+
function CancelSubscriptionButton({
|
|
993
|
+
label,
|
|
994
|
+
renderPrompt,
|
|
995
|
+
onSuccess,
|
|
996
|
+
reasonCatalog = CANCELLATION_REASON_CATALOG,
|
|
997
|
+
labels
|
|
998
|
+
}) {
|
|
999
|
+
const { cancel, loading, error } = useCancelSubscription({
|
|
1000
|
+
onSuccess: () => onSuccess?.()
|
|
1001
|
+
});
|
|
1002
|
+
const [promptOpen, setPromptOpen] = useState7(false);
|
|
1003
|
+
const [reason, setReason] = useState7("");
|
|
1004
|
+
const text = { ...DEFAULT_LABELS5, ...labels };
|
|
1005
|
+
const triggerLabel = label ?? text.action;
|
|
1006
|
+
const onConfirm = async (selected) => {
|
|
1007
|
+
setPromptOpen(false);
|
|
1008
|
+
const result = await cancel(selected);
|
|
1009
|
+
if (result?.success) {
|
|
1010
|
+
setReason("");
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
if (!promptOpen) {
|
|
1014
|
+
return /* @__PURE__ */ jsxs6("span", { style: { display: "inline-flex", flexDirection: "column", gap: VisualTokens.spacing.xs }, children: [
|
|
1015
|
+
/* @__PURE__ */ jsx7(
|
|
1016
|
+
"button",
|
|
1017
|
+
{
|
|
1018
|
+
type: "button",
|
|
1019
|
+
onClick: () => setPromptOpen(true),
|
|
1020
|
+
style: {
|
|
1021
|
+
background: "transparent",
|
|
1022
|
+
color: VisualTokens.color.brandDangerText,
|
|
1023
|
+
border: `1px solid ${VisualTokens.color.borderWarning}`,
|
|
1024
|
+
borderRadius: VisualTokens.radius.md,
|
|
1025
|
+
padding: `${VisualTokens.spacing.md}px ${VisualTokens.spacing.lg}px`,
|
|
1026
|
+
cursor: "pointer",
|
|
1027
|
+
fontWeight: VisualTokens.fontWeight.strong
|
|
1028
|
+
},
|
|
1029
|
+
children: triggerLabel
|
|
1030
|
+
}
|
|
1031
|
+
),
|
|
1032
|
+
InlineErrorVisual.element(error?.message)
|
|
1033
|
+
] });
|
|
1034
|
+
}
|
|
1035
|
+
if (renderPrompt) {
|
|
1036
|
+
return /* @__PURE__ */ jsx7(Fragment, { children: renderPrompt((selected) => void onConfirm(selected)) });
|
|
1037
|
+
}
|
|
1038
|
+
return /* @__PURE__ */ jsxs6(
|
|
1039
|
+
"div",
|
|
1040
|
+
{
|
|
1041
|
+
role: "dialog",
|
|
1042
|
+
"aria-label": "Confirm cancellation",
|
|
1043
|
+
style: {
|
|
1044
|
+
border: `1px solid ${VisualTokens.color.borderWarning}`,
|
|
1045
|
+
background: VisualTokens.color.surfaceDangerMuted,
|
|
1046
|
+
borderRadius: VisualTokens.radius.md,
|
|
1047
|
+
padding: VisualTokens.spacing.lg,
|
|
1048
|
+
display: "flex",
|
|
1049
|
+
flexDirection: "column",
|
|
1050
|
+
gap: VisualTokens.spacing.md
|
|
1051
|
+
},
|
|
1052
|
+
children: [
|
|
1053
|
+
/* @__PURE__ */ jsxs6("label", { style: { fontSize: VisualTokens.fontSize.body, fontWeight: VisualTokens.fontWeight.strong }, children: [
|
|
1054
|
+
text.promptTitle,
|
|
1055
|
+
/* @__PURE__ */ jsxs6(
|
|
1056
|
+
"select",
|
|
1057
|
+
{
|
|
1058
|
+
value: reason,
|
|
1059
|
+
onChange: (e) => setReason(e.target.value),
|
|
1060
|
+
style: {
|
|
1061
|
+
display: "block",
|
|
1062
|
+
marginTop: VisualTokens.spacing.xs,
|
|
1063
|
+
width: "100%",
|
|
1064
|
+
padding: VisualTokens.spacing.sm
|
|
1065
|
+
},
|
|
1066
|
+
children: [
|
|
1067
|
+
/* @__PURE__ */ jsx7("option", { value: "", children: text.noReason }),
|
|
1068
|
+
reasonCatalog.map((opt) => /* @__PURE__ */ jsx7("option", { value: opt.value, children: opt.label }, opt.value))
|
|
1069
|
+
]
|
|
1070
|
+
}
|
|
1071
|
+
)
|
|
1072
|
+
] }),
|
|
1073
|
+
/* @__PURE__ */ jsxs6("div", { style: { display: "flex", gap: VisualTokens.spacing.md }, children: [
|
|
1074
|
+
/* @__PURE__ */ jsx7(
|
|
1075
|
+
"button",
|
|
1076
|
+
{
|
|
1077
|
+
type: "button",
|
|
1078
|
+
onClick: () => void onConfirm(reason || void 0),
|
|
1079
|
+
disabled: loading,
|
|
1080
|
+
style: {
|
|
1081
|
+
background: VisualTokens.color.brandDanger,
|
|
1082
|
+
color: VisualTokens.color.textOnPrimary,
|
|
1083
|
+
border: "none",
|
|
1084
|
+
padding: `${VisualTokens.spacing.sm}px ${VisualTokens.spacing.lg}px`,
|
|
1085
|
+
borderRadius: VisualTokens.radius.sm
|
|
1086
|
+
},
|
|
1087
|
+
children: loading ? text.canceling : text.confirm
|
|
1088
|
+
}
|
|
1089
|
+
),
|
|
1090
|
+
/* @__PURE__ */ jsx7(
|
|
1091
|
+
"button",
|
|
1092
|
+
{
|
|
1093
|
+
type: "button",
|
|
1094
|
+
onClick: () => setPromptOpen(false),
|
|
1095
|
+
disabled: loading,
|
|
1096
|
+
style: {
|
|
1097
|
+
background: "transparent",
|
|
1098
|
+
border: `1px solid ${VisualTokens.color.borderStrong}`,
|
|
1099
|
+
padding: `${VisualTokens.spacing.sm}px ${VisualTokens.spacing.lg}px`,
|
|
1100
|
+
borderRadius: VisualTokens.radius.sm
|
|
1101
|
+
},
|
|
1102
|
+
children: text.keep
|
|
1103
|
+
}
|
|
1104
|
+
)
|
|
1105
|
+
] }),
|
|
1106
|
+
InlineErrorVisual.element(error?.message)
|
|
1107
|
+
]
|
|
1108
|
+
}
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
export {
|
|
1113
|
+
isInsecureUrl,
|
|
1114
|
+
isLoopbackHost,
|
|
1115
|
+
BillingContext,
|
|
1116
|
+
useBilling,
|
|
1117
|
+
BillingProvider,
|
|
1118
|
+
useCheckoutButton,
|
|
1119
|
+
CheckoutButton,
|
|
1120
|
+
SubscriptionStatusBadge,
|
|
1121
|
+
useProducts,
|
|
1122
|
+
PricingTable,
|
|
1123
|
+
BillingHistoryList,
|
|
1124
|
+
CustomerPortalLink,
|
|
1125
|
+
useCancelSubscription,
|
|
1126
|
+
CANCELLATION_REASON_CATALOG,
|
|
1127
|
+
CancelSubscriptionButton
|
|
1128
|
+
};
|