@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
package/dist/react.js
ADDED
|
@@ -0,0 +1,1874 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/react.tsx
|
|
21
|
+
var react_exports = {};
|
|
22
|
+
__export(react_exports, {
|
|
23
|
+
BILLING_CYCLE: () => BILLING_CYCLE,
|
|
24
|
+
BILLING_CYCLE_VALUES: () => BILLING_CYCLE_VALUES,
|
|
25
|
+
BillingContext: () => BillingContext,
|
|
26
|
+
BillingHistoryList: () => BillingHistoryList,
|
|
27
|
+
BillingProvider: () => BillingProvider,
|
|
28
|
+
CANCELLATION_REASON: () => CANCELLATION_REASON,
|
|
29
|
+
CANCELLATION_REASON_CATALOG: () => CANCELLATION_REASON_CATALOG,
|
|
30
|
+
CANCELLATION_REASON_VALUES: () => CANCELLATION_REASON_VALUES,
|
|
31
|
+
CancelSubscriptionButton: () => CancelSubscriptionButton,
|
|
32
|
+
CheckoutButton: () => CheckoutButton,
|
|
33
|
+
CustomerPortalLink: () => CustomerPortalLink,
|
|
34
|
+
FREE_SUBSCRIPTION_STATUS: () => FREE_SUBSCRIPTION_STATUS,
|
|
35
|
+
NoopNavigationStrategy: () => NoopNavigationStrategy,
|
|
36
|
+
PAYMENT_PROVIDER: () => PAYMENT_PROVIDER,
|
|
37
|
+
PAYMENT_PROVIDER_VALUES: () => PAYMENT_PROVIDER_VALUES,
|
|
38
|
+
POLAR_AMOUNT_TYPE: () => POLAR_AMOUNT_TYPE,
|
|
39
|
+
PricingTable: () => PricingTable,
|
|
40
|
+
SUBSCRIPTION_STATUS: () => SUBSCRIPTION_STATUS,
|
|
41
|
+
SUBSCRIPTION_STATUS_VALUES: () => SUBSCRIPTION_STATUS_VALUES,
|
|
42
|
+
SubscriptionStatusBadge: () => SubscriptionStatusBadge,
|
|
43
|
+
TONE_PAIRS: () => TONE_PAIRS,
|
|
44
|
+
VisualTokens: () => VisualTokens,
|
|
45
|
+
WindowNavigationStrategy: () => WindowNavigationStrategy,
|
|
46
|
+
applyPercentDiscount: () => applyPercentDiscount,
|
|
47
|
+
buildCheckoutMetadata: () => buildCheckoutMetadata,
|
|
48
|
+
createMemoryBillingAdapter: () => createMemoryBillingAdapter,
|
|
49
|
+
formatReceiptNumber: () => formatReceiptNumber,
|
|
50
|
+
isActiveSubscription: () => isActiveSubscription,
|
|
51
|
+
isKnownBillingCycle: () => isKnownBillingCycle,
|
|
52
|
+
isKnownPaymentProvider: () => isKnownPaymentProvider,
|
|
53
|
+
isKnownSubscriptionStatus: () => isKnownSubscriptionStatus,
|
|
54
|
+
mapProduct: () => mapProduct,
|
|
55
|
+
mapProductList: () => mapProductList,
|
|
56
|
+
minorUnitDivisor: () => minorUnitDivisor,
|
|
57
|
+
normalizeCouponCode: () => normalizeCouponCode,
|
|
58
|
+
parseBillingCycle: () => parseBillingCycle,
|
|
59
|
+
readCheckoutMetadata: () => readCheckoutMetadata,
|
|
60
|
+
toMajorUnits: () => toMajorUnits,
|
|
61
|
+
toMinorUnits: () => toMinorUnits,
|
|
62
|
+
useBilling: () => useBilling,
|
|
63
|
+
useCancelSubscription: () => useCancelSubscription,
|
|
64
|
+
useCheckoutButton: () => useCheckoutButton,
|
|
65
|
+
useProducts: () => useProducts
|
|
66
|
+
});
|
|
67
|
+
module.exports = __toCommonJS(react_exports);
|
|
68
|
+
|
|
69
|
+
// src/domain/enums/payment-provider.enum.ts
|
|
70
|
+
var PAYMENT_PROVIDER = {
|
|
71
|
+
POLAR: "polar",
|
|
72
|
+
STRIPE: "stripe"
|
|
73
|
+
};
|
|
74
|
+
var PAYMENT_PROVIDER_VALUES = Object.values(PAYMENT_PROVIDER);
|
|
75
|
+
var PAYMENT_PROVIDER_SET = new Set(PAYMENT_PROVIDER_VALUES);
|
|
76
|
+
function isKnownPaymentProvider(value) {
|
|
77
|
+
return typeof value === "string" && PAYMENT_PROVIDER_SET.has(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/domain/enums/subscription-status.enum.ts
|
|
81
|
+
var SUBSCRIPTION_STATUS = {
|
|
82
|
+
ACTIVE: "active",
|
|
83
|
+
CANCELED: "canceled",
|
|
84
|
+
REVOKED: "revoked",
|
|
85
|
+
TRIALING: "trialing",
|
|
86
|
+
PAST_DUE: "past_due",
|
|
87
|
+
INCOMPLETE: "incomplete",
|
|
88
|
+
INCOMPLETE_EXPIRED: "incomplete_expired",
|
|
89
|
+
UNPAID: "unpaid",
|
|
90
|
+
NONE: "none"
|
|
91
|
+
};
|
|
92
|
+
var SUBSCRIPTION_STATUS_VALUES = Object.freeze([
|
|
93
|
+
SUBSCRIPTION_STATUS.ACTIVE,
|
|
94
|
+
SUBSCRIPTION_STATUS.CANCELED,
|
|
95
|
+
SUBSCRIPTION_STATUS.REVOKED,
|
|
96
|
+
SUBSCRIPTION_STATUS.TRIALING,
|
|
97
|
+
SUBSCRIPTION_STATUS.PAST_DUE,
|
|
98
|
+
SUBSCRIPTION_STATUS.INCOMPLETE,
|
|
99
|
+
SUBSCRIPTION_STATUS.INCOMPLETE_EXPIRED,
|
|
100
|
+
SUBSCRIPTION_STATUS.UNPAID,
|
|
101
|
+
SUBSCRIPTION_STATUS.NONE
|
|
102
|
+
]);
|
|
103
|
+
var SUBSCRIPTION_STATUS_SET = new Set(SUBSCRIPTION_STATUS_VALUES);
|
|
104
|
+
function isKnownSubscriptionStatus(value) {
|
|
105
|
+
return typeof value === "string" && SUBSCRIPTION_STATUS_SET.has(value.toLowerCase());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// src/domain/enums/billing-cycle.enum.ts
|
|
109
|
+
var BILLING_CYCLE = Object.freeze({
|
|
110
|
+
MONTHLY: "monthly",
|
|
111
|
+
YEARLY: "yearly"
|
|
112
|
+
});
|
|
113
|
+
var BILLING_CYCLE_VALUES = Object.freeze([
|
|
114
|
+
BILLING_CYCLE.MONTHLY,
|
|
115
|
+
BILLING_CYCLE.YEARLY
|
|
116
|
+
]);
|
|
117
|
+
var CANONICAL_BILLING_CYCLES = new Set(BILLING_CYCLE_VALUES);
|
|
118
|
+
var BILLING_CYCLE_ALIASES = /* @__PURE__ */ new Map([
|
|
119
|
+
[BILLING_CYCLE.MONTHLY, BILLING_CYCLE.MONTHLY],
|
|
120
|
+
["month", BILLING_CYCLE.MONTHLY],
|
|
121
|
+
[BILLING_CYCLE.YEARLY, BILLING_CYCLE.YEARLY],
|
|
122
|
+
["year", BILLING_CYCLE.YEARLY]
|
|
123
|
+
]);
|
|
124
|
+
function isKnownBillingCycle(value) {
|
|
125
|
+
return typeof value === "string" && CANONICAL_BILLING_CYCLES.has(value.toLowerCase());
|
|
126
|
+
}
|
|
127
|
+
function parseBillingCycle(value) {
|
|
128
|
+
if (typeof value !== "string") return null;
|
|
129
|
+
return BILLING_CYCLE_ALIASES.get(value.toLowerCase()) ?? null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// src/domain/enums/amount-type.enum.ts
|
|
133
|
+
var POLAR_AMOUNT_TYPE = Object.freeze({
|
|
134
|
+
FIXED: "fixed",
|
|
135
|
+
CUSTOM: "custom",
|
|
136
|
+
FREE: "free",
|
|
137
|
+
SEAT_BASED: "seat_based",
|
|
138
|
+
METERED_UNIT: "metered_unit"
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// src/domain/entities/cancellation.entity.ts
|
|
142
|
+
var CANCELLATION_REASON = Object.freeze({
|
|
143
|
+
TOO_EXPENSIVE: "too_expensive",
|
|
144
|
+
MISSING_FEATURES: "missing_features",
|
|
145
|
+
SWITCHED_SERVICE: "switched_service",
|
|
146
|
+
UNUSED: "unused",
|
|
147
|
+
CUSTOMER_SERVICE: "customer_service",
|
|
148
|
+
LOW_QUALITY: "low_quality",
|
|
149
|
+
TOO_COMPLEX: "too_complex",
|
|
150
|
+
OTHER: "other"
|
|
151
|
+
});
|
|
152
|
+
var CANCELLATION_REASON_VALUES = Object.freeze([
|
|
153
|
+
CANCELLATION_REASON.TOO_EXPENSIVE,
|
|
154
|
+
CANCELLATION_REASON.MISSING_FEATURES,
|
|
155
|
+
CANCELLATION_REASON.SWITCHED_SERVICE,
|
|
156
|
+
CANCELLATION_REASON.UNUSED,
|
|
157
|
+
CANCELLATION_REASON.CUSTOMER_SERVICE,
|
|
158
|
+
CANCELLATION_REASON.LOW_QUALITY,
|
|
159
|
+
CANCELLATION_REASON.TOO_COMPLEX,
|
|
160
|
+
CANCELLATION_REASON.OTHER
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
// src/domain/entities/subscription.entity.ts
|
|
164
|
+
var FREE_SUBSCRIPTION_STATUS = Object.freeze({
|
|
165
|
+
plan: "free",
|
|
166
|
+
subscriptionStatus: "none"
|
|
167
|
+
});
|
|
168
|
+
function isActiveSubscription(status) {
|
|
169
|
+
return status.subscriptionStatus === "active" || status.subscriptionStatus === "trialing";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/infrastructure/services/memory/create-memory-adapter.ts
|
|
173
|
+
function createMemoryBillingAdapter(config = {}) {
|
|
174
|
+
let status = {
|
|
175
|
+
plan: "free",
|
|
176
|
+
subscriptionStatus: "none",
|
|
177
|
+
billingCycle: "monthly",
|
|
178
|
+
cancelAtPeriodEnd: false,
|
|
179
|
+
...config.initialStatus
|
|
180
|
+
};
|
|
181
|
+
const history = config.initialHistory ? [...config.initialHistory] : [];
|
|
182
|
+
const pending = /* @__PURE__ */ new Map();
|
|
183
|
+
let sequence = 0;
|
|
184
|
+
function checkoutUrl(checkout) {
|
|
185
|
+
if (checkout.params.successUrl) {
|
|
186
|
+
return checkout.params.successUrl.replace("{CHECKOUT_ID}", checkout.id);
|
|
187
|
+
}
|
|
188
|
+
const base = config.checkoutUrlBase ?? "https://checkout.example";
|
|
189
|
+
return `${base.replace(/\/$/, "")}/${checkout.id}`;
|
|
190
|
+
}
|
|
191
|
+
function fulfill(checkout) {
|
|
192
|
+
const planName = checkout.params.planKey ?? checkout.params.productId ?? checkout.params.productKey ?? "default";
|
|
193
|
+
return {
|
|
194
|
+
...status,
|
|
195
|
+
plan: planName,
|
|
196
|
+
subscriptionStatus: "active",
|
|
197
|
+
subscriptionId: `sub_${checkout.id.slice(4)}`,
|
|
198
|
+
billingCycle: checkout.params.billingCycle ?? status.billingCycle,
|
|
199
|
+
cancelAtPeriodEnd: false,
|
|
200
|
+
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3).toISOString()
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
async getStatus(_userId) {
|
|
205
|
+
return { ...status };
|
|
206
|
+
},
|
|
207
|
+
async createCheckout(params) {
|
|
208
|
+
config.onCheckout?.(params);
|
|
209
|
+
sequence += 1;
|
|
210
|
+
const checkout = {
|
|
211
|
+
id: `chk_${Date.now()}_${sequence}`,
|
|
212
|
+
params
|
|
213
|
+
};
|
|
214
|
+
pending.set(checkout.id, checkout);
|
|
215
|
+
return {
|
|
216
|
+
id: checkout.id,
|
|
217
|
+
url: checkoutUrl(checkout)
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
async syncSubscription(_userId, checkoutId) {
|
|
221
|
+
const checkout = checkoutId ? pending.get(checkoutId) : void 0;
|
|
222
|
+
const last = checkout ? void 0 : [...pending.values()].pop();
|
|
223
|
+
const target = checkout ?? last;
|
|
224
|
+
if (!target) {
|
|
225
|
+
return { synced: false, plan: status.plan };
|
|
226
|
+
}
|
|
227
|
+
status = fulfill(target);
|
|
228
|
+
pending.delete(target.id);
|
|
229
|
+
history.unshift({
|
|
230
|
+
id: `ord_${target.id}`,
|
|
231
|
+
amount: 0,
|
|
232
|
+
currency: "usd",
|
|
233
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
234
|
+
productName: target.params.planKey ?? target.params.productId,
|
|
235
|
+
status: "paid",
|
|
236
|
+
paid: true
|
|
237
|
+
});
|
|
238
|
+
return { synced: true, plan: status.plan };
|
|
239
|
+
},
|
|
240
|
+
async getBillingHistory(_userId) {
|
|
241
|
+
return [...history];
|
|
242
|
+
},
|
|
243
|
+
async cancelSubscription(reason) {
|
|
244
|
+
config.onCancel?.(reason);
|
|
245
|
+
if (!isActiveSubscription(status)) {
|
|
246
|
+
return { success: false };
|
|
247
|
+
}
|
|
248
|
+
status = {
|
|
249
|
+
...status,
|
|
250
|
+
subscriptionStatus: "canceled",
|
|
251
|
+
cancelAtPeriodEnd: true
|
|
252
|
+
};
|
|
253
|
+
return {
|
|
254
|
+
success: true,
|
|
255
|
+
endsAt: status.currentPeriodEnd ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
async getPortalUrl(_userId) {
|
|
259
|
+
return config.portalUrl ?? "https://portal.example/billing";
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// src/domain/mappers/product.mapper.ts
|
|
265
|
+
var KNOWN_AMOUNT_TYPES = /* @__PURE__ */ new Set([
|
|
266
|
+
"fixed",
|
|
267
|
+
"custom",
|
|
268
|
+
"free",
|
|
269
|
+
"seat_based",
|
|
270
|
+
"metered_unit"
|
|
271
|
+
]);
|
|
272
|
+
function toAmountType(raw) {
|
|
273
|
+
if (raw && KNOWN_AMOUNT_TYPES.has(raw)) return raw;
|
|
274
|
+
return "fixed";
|
|
275
|
+
}
|
|
276
|
+
function toBillingCycle(interval) {
|
|
277
|
+
return parseBillingCycle(interval);
|
|
278
|
+
}
|
|
279
|
+
function asRecord(value) {
|
|
280
|
+
return value && typeof value === "object" ? value : {};
|
|
281
|
+
}
|
|
282
|
+
function toPrice(dto) {
|
|
283
|
+
return {
|
|
284
|
+
id: String(dto.id),
|
|
285
|
+
type: toAmountType(dto.type),
|
|
286
|
+
amountType: toAmountType(dto.amount_type),
|
|
287
|
+
amount: typeof dto.amount === "number" ? dto.amount : void 0,
|
|
288
|
+
currency: String(dto.currency),
|
|
289
|
+
recurringInterval: toBillingCycle(dto.recurring_interval) ?? void 0,
|
|
290
|
+
isArchived: Boolean(dto.is_archived),
|
|
291
|
+
productId: String(dto.product_id),
|
|
292
|
+
legacy: Boolean(dto.legacy)
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function toBenefit(dto) {
|
|
296
|
+
return {
|
|
297
|
+
id: String(dto.id),
|
|
298
|
+
type: String(dto.type),
|
|
299
|
+
description: dto.description ?? null,
|
|
300
|
+
properties: asRecord(dto.properties)
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function mapProduct(dto) {
|
|
304
|
+
if (!dto || typeof dto !== "object") {
|
|
305
|
+
throw new TypeError("mapProduct: expected a product DTO object");
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
id: String(dto.id),
|
|
309
|
+
name: String(dto.name),
|
|
310
|
+
description: dto.description ?? null,
|
|
311
|
+
isRecurring: Boolean(dto.is_recurring),
|
|
312
|
+
recurringInterval: toBillingCycle(dto.recurring_interval),
|
|
313
|
+
isArchived: Boolean(dto.is_archived),
|
|
314
|
+
organizationId: String(dto.organization_id),
|
|
315
|
+
prices: Array.isArray(dto.prices) ? dto.prices.map(toPrice) : [],
|
|
316
|
+
benefits: Array.isArray(dto.benefits) ? dto.benefits.map(toBenefit) : [],
|
|
317
|
+
metadata: asRecord(dto.metadata),
|
|
318
|
+
createdAt: String(dto.created_at),
|
|
319
|
+
modifiedAt: dto.modified_at ?? null
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function mapProductList(dto) {
|
|
323
|
+
if (!dto || typeof dto !== "object") {
|
|
324
|
+
return { items: [], pagination: { totalCount: 0, maxPage: 1 } };
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
items: Array.isArray(dto.items) ? dto.items.map(mapProduct) : [],
|
|
328
|
+
pagination: {
|
|
329
|
+
totalCount: Number(dto.pagination?.total_count ?? 0),
|
|
330
|
+
maxPage: Number(dto.pagination?.max_page ?? 1)
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// src/domain/utils/minor-units.ts
|
|
336
|
+
var MINOR_UNIT_DIVISOR = 100;
|
|
337
|
+
var ZERO_DECIMAL_CURRENCIES = /* @__PURE__ */ new Set([
|
|
338
|
+
"JPY",
|
|
339
|
+
"KRW",
|
|
340
|
+
"VND",
|
|
341
|
+
"CLP",
|
|
342
|
+
"PYG",
|
|
343
|
+
"XAF",
|
|
344
|
+
"XOF",
|
|
345
|
+
"BIF",
|
|
346
|
+
"DJF",
|
|
347
|
+
"GNF",
|
|
348
|
+
"XPF"
|
|
349
|
+
]);
|
|
350
|
+
function minorUnitDivisor(currency) {
|
|
351
|
+
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 1 : MINOR_UNIT_DIVISOR;
|
|
352
|
+
}
|
|
353
|
+
function toMajorUnits(amount, currency) {
|
|
354
|
+
return amount / minorUnitDivisor(currency);
|
|
355
|
+
}
|
|
356
|
+
function toMinorUnits(amount, currency) {
|
|
357
|
+
return Math.round(amount * minorUnitDivisor(currency));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// src/domain/utils/percent-discount.ts
|
|
361
|
+
function normalizeCouponCode(code) {
|
|
362
|
+
if (typeof code !== "string") return null;
|
|
363
|
+
const normalized = code.trim().toUpperCase();
|
|
364
|
+
return normalized.length > 0 ? normalized : null;
|
|
365
|
+
}
|
|
366
|
+
function applyPercentDiscount(baseAmount, percent, minimumAmount) {
|
|
367
|
+
if (!Number.isFinite(baseAmount) || baseAmount <= 0) return 0;
|
|
368
|
+
if (!Number.isFinite(percent) || percent <= 0) return Math.round(baseAmount);
|
|
369
|
+
const discounted = Math.round(baseAmount * (1 - percent / 100));
|
|
370
|
+
return Math.max(minimumAmount, discounted);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// src/infrastructure/utils/receipt.util.ts
|
|
374
|
+
function formatReceiptNumber(id, prefix = "RCP") {
|
|
375
|
+
if (!id || typeof id !== "string") return `${prefix}-00000000`;
|
|
376
|
+
const clean = id.replace(/[^a-zA-Z0-9]/g, "");
|
|
377
|
+
const tail = clean.length >= 8 ? clean.slice(-8) : clean.padStart(8, "0");
|
|
378
|
+
return `${prefix}-${tail.toUpperCase()}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// src/infrastructure/utils/checkout-metadata.ts
|
|
382
|
+
function asNonEmptyString(value) {
|
|
383
|
+
if (typeof value !== "string" || value.length === 0) return void 0;
|
|
384
|
+
return value;
|
|
385
|
+
}
|
|
386
|
+
function buildCheckoutMetadata(input) {
|
|
387
|
+
const meta = {
|
|
388
|
+
userId: input.userId,
|
|
389
|
+
itemType: input.itemType,
|
|
390
|
+
itemId: input.itemId
|
|
391
|
+
};
|
|
392
|
+
if (input.couponCode && input.couponCode.trim().length > 0) {
|
|
393
|
+
meta.couponCode = input.couponCode.trim();
|
|
394
|
+
}
|
|
395
|
+
if (input.extra) {
|
|
396
|
+
for (const [key, value] of Object.entries(input.extra)) {
|
|
397
|
+
if (typeof value === "string" && value.length > 0) {
|
|
398
|
+
meta[key] = value;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return meta;
|
|
403
|
+
}
|
|
404
|
+
function readCheckoutMetadata(metadata, allowedItemTypes) {
|
|
405
|
+
if (metadata === void 0) return null;
|
|
406
|
+
const userId = asNonEmptyString(metadata.userId);
|
|
407
|
+
const itemType = asNonEmptyString(metadata.itemType);
|
|
408
|
+
const itemId = asNonEmptyString(metadata.itemId);
|
|
409
|
+
if (!userId || !itemType || !itemId) return null;
|
|
410
|
+
if (!allowedItemTypes.includes(itemType)) return null;
|
|
411
|
+
const couponCode = asNonEmptyString(metadata.couponCode);
|
|
412
|
+
return { userId, itemType, itemId, ...couponCode ? { couponCode } : {} };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/presentation/components/BillingProvider.tsx
|
|
416
|
+
var import_react2 = require("react");
|
|
417
|
+
|
|
418
|
+
// src/infrastructure/utils/security.util.ts
|
|
419
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]"]);
|
|
420
|
+
function isValidCheckoutUrl(url) {
|
|
421
|
+
return typeof url === "string" && (url.startsWith("https://") || url.startsWith("http://"));
|
|
422
|
+
}
|
|
423
|
+
function isInsecureUrl(url) {
|
|
424
|
+
return typeof url === "string" && url.startsWith("http://");
|
|
425
|
+
}
|
|
426
|
+
function isLoopbackHost(url) {
|
|
427
|
+
try {
|
|
428
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
429
|
+
return LOOPBACK_HOSTS.has(host);
|
|
430
|
+
} catch {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function isProductionInsecureUrl(url) {
|
|
435
|
+
return isInsecureUrl(url) && !isLoopbackHost(url);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// src/infrastructure/utils/validation.util.ts
|
|
439
|
+
function normalizeUserId(userId) {
|
|
440
|
+
if (typeof userId !== "string") return void 0;
|
|
441
|
+
const trimmed = userId.trim();
|
|
442
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
443
|
+
}
|
|
444
|
+
function isValidProductId(productId) {
|
|
445
|
+
return typeof productId === "string" && productId.trim().length > 0;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/presentation/navigation/navigation.strategy.ts
|
|
449
|
+
var WindowNavigationStrategy = class {
|
|
450
|
+
redirect(url) {
|
|
451
|
+
window.location.href = url;
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
var NoopNavigationStrategy = class {
|
|
455
|
+
redirect(_url) {
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
// src/presentation/context/billing-context.ts
|
|
460
|
+
var import_react = require("react");
|
|
461
|
+
var BillingContext = (0, import_react.createContext)(void 0);
|
|
462
|
+
function useBilling() {
|
|
463
|
+
const ctx = (0, import_react.useContext)(BillingContext);
|
|
464
|
+
if (!ctx) {
|
|
465
|
+
throw new Error("useBilling must be used within <BillingProvider>");
|
|
466
|
+
}
|
|
467
|
+
return ctx;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/presentation/components/BillingProvider.tsx
|
|
471
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
472
|
+
function defaultNavigation() {
|
|
473
|
+
if (typeof window === "undefined") return new NoopNavigationStrategy();
|
|
474
|
+
return {
|
|
475
|
+
redirect(url) {
|
|
476
|
+
window.location.href = url;
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function BillingProvider({
|
|
481
|
+
adapter,
|
|
482
|
+
userId,
|
|
483
|
+
navigation,
|
|
484
|
+
children
|
|
485
|
+
}) {
|
|
486
|
+
const [status, setStatus] = (0, import_react2.useState)(FREE_SUBSCRIPTION_STATUS);
|
|
487
|
+
const [loading, setLoading] = (0, import_react2.useState)(true);
|
|
488
|
+
const adapterRef = (0, import_react2.useRef)(adapter);
|
|
489
|
+
const userIdRef = (0, import_react2.useRef)(normalizeUserId(userId));
|
|
490
|
+
const navigationRef = (0, import_react2.useRef)(navigation ?? defaultNavigation());
|
|
491
|
+
const refreshGenerationRef = (0, import_react2.useRef)(0);
|
|
492
|
+
adapterRef.current = adapter;
|
|
493
|
+
userIdRef.current = normalizeUserId(userId);
|
|
494
|
+
navigationRef.current = navigation ?? navigationRef.current;
|
|
495
|
+
const refresh = (0, import_react2.useCallback)(async () => {
|
|
496
|
+
const generation = ++refreshGenerationRef.current;
|
|
497
|
+
const uid = userIdRef.current;
|
|
498
|
+
if (!uid) {
|
|
499
|
+
setStatus(FREE_SUBSCRIPTION_STATUS);
|
|
500
|
+
setLoading(false);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
setLoading(true);
|
|
504
|
+
try {
|
|
505
|
+
const next = await adapterRef.current.getStatus(uid);
|
|
506
|
+
if (generation !== refreshGenerationRef.current) return;
|
|
507
|
+
setStatus(next);
|
|
508
|
+
} catch {
|
|
509
|
+
if (generation !== refreshGenerationRef.current) return;
|
|
510
|
+
setStatus(FREE_SUBSCRIPTION_STATUS);
|
|
511
|
+
} finally {
|
|
512
|
+
if (generation === refreshGenerationRef.current) {
|
|
513
|
+
setLoading(false);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}, []);
|
|
517
|
+
(0, import_react2.useEffect)(() => {
|
|
518
|
+
void refresh();
|
|
519
|
+
}, [userId, refresh]);
|
|
520
|
+
const startCheckout = (0, import_react2.useCallback)(async (params) => {
|
|
521
|
+
if (!isValidProductId(params.productId)) {
|
|
522
|
+
throw new Error("[billing] Invalid productId: must be a non-empty string");
|
|
523
|
+
}
|
|
524
|
+
const uid = userIdRef.current;
|
|
525
|
+
if (!uid) {
|
|
526
|
+
throw new Error("[billing] Cannot start checkout: no authenticated user");
|
|
527
|
+
}
|
|
528
|
+
const result = await adapterRef.current.createCheckout({ ...params, userId: uid });
|
|
529
|
+
if (!isValidCheckoutUrl(result.url)) {
|
|
530
|
+
throw new Error("[billing] Invalid checkout URL: must start with https:// or http://");
|
|
531
|
+
}
|
|
532
|
+
if (isProductionInsecureUrl(result.url)) {
|
|
533
|
+
throw new Error("[billing] Insecure http:// checkout URL in production");
|
|
534
|
+
}
|
|
535
|
+
navigationRef.current.redirect(result.url);
|
|
536
|
+
}, []);
|
|
537
|
+
const syncSubscription = (0, import_react2.useCallback)(async () => {
|
|
538
|
+
const uid = userIdRef.current;
|
|
539
|
+
if (!uid) return { synced: false };
|
|
540
|
+
const checkoutId = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("checkout_id") ?? void 0 : void 0;
|
|
541
|
+
const result = await adapterRef.current.syncSubscription(uid, checkoutId);
|
|
542
|
+
if (result.synced) {
|
|
543
|
+
await refresh();
|
|
544
|
+
}
|
|
545
|
+
return result;
|
|
546
|
+
}, [refresh]);
|
|
547
|
+
const getBillingHistory = (0, import_react2.useCallback)(async () => {
|
|
548
|
+
const uid = userIdRef.current;
|
|
549
|
+
if (!uid) return [];
|
|
550
|
+
return adapterRef.current.getBillingHistory(uid);
|
|
551
|
+
}, []);
|
|
552
|
+
const cancelSubscription = (0, import_react2.useCallback)(
|
|
553
|
+
async (reason) => {
|
|
554
|
+
const result = await adapterRef.current.cancelSubscription(reason);
|
|
555
|
+
if (result.success) {
|
|
556
|
+
await refresh();
|
|
557
|
+
}
|
|
558
|
+
return result;
|
|
559
|
+
},
|
|
560
|
+
[refresh]
|
|
561
|
+
);
|
|
562
|
+
const getPortalUrl = (0, import_react2.useCallback)(async () => {
|
|
563
|
+
const uid = userIdRef.current;
|
|
564
|
+
if (!uid) {
|
|
565
|
+
throw new Error("[billing] Cannot get portal URL: no authenticated user");
|
|
566
|
+
}
|
|
567
|
+
return adapterRef.current.getPortalUrl(uid);
|
|
568
|
+
}, []);
|
|
569
|
+
const value = (0, import_react2.useMemo)(
|
|
570
|
+
() => ({
|
|
571
|
+
status,
|
|
572
|
+
loading,
|
|
573
|
+
refresh,
|
|
574
|
+
startCheckout,
|
|
575
|
+
syncSubscription,
|
|
576
|
+
getBillingHistory,
|
|
577
|
+
cancelSubscription,
|
|
578
|
+
getPortalUrl
|
|
579
|
+
}),
|
|
580
|
+
[
|
|
581
|
+
status,
|
|
582
|
+
loading,
|
|
583
|
+
refresh,
|
|
584
|
+
startCheckout,
|
|
585
|
+
syncSubscription,
|
|
586
|
+
getBillingHistory,
|
|
587
|
+
cancelSubscription,
|
|
588
|
+
getPortalUrl
|
|
589
|
+
]
|
|
590
|
+
);
|
|
591
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(BillingContext.Provider, { value, children });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/presentation/components/CheckoutButton.tsx
|
|
595
|
+
var import_react5 = require("react");
|
|
596
|
+
|
|
597
|
+
// src/presentation/hooks/useCheckoutButton.ts
|
|
598
|
+
var import_react3 = require("react");
|
|
599
|
+
function useCheckoutButton(options) {
|
|
600
|
+
const { startCheckout } = useBilling();
|
|
601
|
+
const [loading, setLoading] = (0, import_react3.useState)(false);
|
|
602
|
+
const [error, setError] = (0, import_react3.useState)(null);
|
|
603
|
+
const { productId, billingCycle, successUrl, planKey, onError } = options;
|
|
604
|
+
const start = (0, import_react3.useCallback)(async () => {
|
|
605
|
+
setLoading(true);
|
|
606
|
+
setError(null);
|
|
607
|
+
try {
|
|
608
|
+
await startCheckout({
|
|
609
|
+
productId,
|
|
610
|
+
billingCycle,
|
|
611
|
+
successUrl,
|
|
612
|
+
planKey
|
|
613
|
+
});
|
|
614
|
+
} catch (err) {
|
|
615
|
+
const normalized = err instanceof Error ? err : new Error(String(err));
|
|
616
|
+
setError(normalized);
|
|
617
|
+
onError?.(normalized);
|
|
618
|
+
} finally {
|
|
619
|
+
setLoading(false);
|
|
620
|
+
}
|
|
621
|
+
}, [startCheckout, productId, billingCycle, successUrl, planKey, onError]);
|
|
622
|
+
return { start, loading, error };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// src/presentation/theme/visual-tokens.ts
|
|
626
|
+
var VisualTokens = Object.freeze({
|
|
627
|
+
spacing: Object.freeze({
|
|
628
|
+
none: 0,
|
|
629
|
+
xs: 4,
|
|
630
|
+
sm: 6,
|
|
631
|
+
md: 8,
|
|
632
|
+
lg: 12,
|
|
633
|
+
xl: 16,
|
|
634
|
+
xxl: 20,
|
|
635
|
+
section: 24,
|
|
636
|
+
page: 32
|
|
637
|
+
}),
|
|
638
|
+
radius: Object.freeze({
|
|
639
|
+
sm: 6,
|
|
640
|
+
md: 8,
|
|
641
|
+
lg: 12,
|
|
642
|
+
pill: 999
|
|
643
|
+
}),
|
|
644
|
+
fontSize: Object.freeze({
|
|
645
|
+
caption: 12,
|
|
646
|
+
body: 14,
|
|
647
|
+
title: 18,
|
|
648
|
+
display: 24
|
|
649
|
+
}),
|
|
650
|
+
fontWeight: Object.freeze({
|
|
651
|
+
regular: 400,
|
|
652
|
+
medium: 500,
|
|
653
|
+
strong: 600
|
|
654
|
+
}),
|
|
655
|
+
lineHeight: Object.freeze({
|
|
656
|
+
tight: 1.25,
|
|
657
|
+
normal: 1.5
|
|
658
|
+
}),
|
|
659
|
+
opacity: Object.freeze({
|
|
660
|
+
muted: 0.7,
|
|
661
|
+
disabled: 0.6,
|
|
662
|
+
hover: 0.85
|
|
663
|
+
}),
|
|
664
|
+
color: Object.freeze({
|
|
665
|
+
textPrimary: "#111827",
|
|
666
|
+
textMuted: "#6b7280",
|
|
667
|
+
textOnPrimary: "#ffffff",
|
|
668
|
+
textInverse: "#ffffff",
|
|
669
|
+
textError: "#b91c1c",
|
|
670
|
+
textOnError: "#ffffff",
|
|
671
|
+
surfacePrimary: "#ffffff",
|
|
672
|
+
surfaceMuted: "#f9fafb",
|
|
673
|
+
surfaceSubtle: "#f3f4f6",
|
|
674
|
+
surfaceWarning: "#fef3c7",
|
|
675
|
+
surfaceDanger: "#fee2e2",
|
|
676
|
+
surfaceDangerMuted: "#fef2f2",
|
|
677
|
+
borderSubtle: "#e5e7eb",
|
|
678
|
+
borderStrong: "#d1d5db",
|
|
679
|
+
borderWarning: "#fecaca",
|
|
680
|
+
brandPrimary: "#2563eb",
|
|
681
|
+
brandPrimaryHover: "#1d4ed8",
|
|
682
|
+
brandDanger: "#dc2626",
|
|
683
|
+
brandDangerHover: "#b91c1c",
|
|
684
|
+
brandDangerText: "#991b1b",
|
|
685
|
+
badgeNeutralBg: "#e5e7eb",
|
|
686
|
+
badgeNeutralFg: "#111827",
|
|
687
|
+
badgeActiveBg: "#dcfce7",
|
|
688
|
+
badgeActiveFg: "#166534",
|
|
689
|
+
badgeWarningBg: "#fef3c7",
|
|
690
|
+
badgeWarningFg: "#92400e",
|
|
691
|
+
badgeDangerBg: "#fee2e2",
|
|
692
|
+
badgeDangerFg: "#991b1b"
|
|
693
|
+
}),
|
|
694
|
+
transition: Object.freeze({
|
|
695
|
+
instant: "120ms ease"
|
|
696
|
+
}),
|
|
697
|
+
layout: Object.freeze({
|
|
698
|
+
cardMinWidth: 260,
|
|
699
|
+
benefitListMax: 5
|
|
700
|
+
})
|
|
701
|
+
});
|
|
702
|
+
var TONE_PAIRS = Object.freeze({
|
|
703
|
+
neutral: { bg: VisualTokens.color.badgeNeutralBg, fg: VisualTokens.color.badgeNeutralFg },
|
|
704
|
+
active: { bg: VisualTokens.color.badgeActiveBg, fg: VisualTokens.color.badgeActiveFg },
|
|
705
|
+
warning: { bg: VisualTokens.color.badgeWarningBg, fg: VisualTokens.color.badgeWarningFg },
|
|
706
|
+
danger: { bg: VisualTokens.color.badgeDangerBg, fg: VisualTokens.color.badgeDangerFg }
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
// src/presentation/utils/checkout-button-visual.ts
|
|
710
|
+
var VARIANT_COLOR_SCHEMES = Object.freeze({
|
|
711
|
+
primary: Object.freeze({
|
|
712
|
+
backgroundColor: VisualTokens.color.brandPrimary,
|
|
713
|
+
color: VisualTokens.color.textOnPrimary,
|
|
714
|
+
border: `1px solid ${VisualTokens.color.brandPrimary}`
|
|
715
|
+
}),
|
|
716
|
+
secondary: Object.freeze({
|
|
717
|
+
backgroundColor: VisualTokens.color.surfacePrimary,
|
|
718
|
+
color: VisualTokens.color.textPrimary,
|
|
719
|
+
border: `1px solid ${VisualTokens.color.borderStrong}`
|
|
720
|
+
}),
|
|
721
|
+
danger: Object.freeze({
|
|
722
|
+
backgroundColor: VisualTokens.color.brandDanger,
|
|
723
|
+
color: VisualTokens.color.textOnPrimary,
|
|
724
|
+
border: `1px solid ${VisualTokens.color.brandDanger}`
|
|
725
|
+
})
|
|
726
|
+
});
|
|
727
|
+
var CheckoutButtonVisual = class {
|
|
728
|
+
static resolve({ variant, disabled, loading }) {
|
|
729
|
+
const scheme = VARIANT_COLOR_SCHEMES[variant];
|
|
730
|
+
const isInactive = disabled || loading;
|
|
731
|
+
return {
|
|
732
|
+
...scheme,
|
|
733
|
+
padding: `${VisualTokens.spacing.lg}px ${VisualTokens.spacing.xl}px`,
|
|
734
|
+
fontSize: VisualTokens.fontSize.body,
|
|
735
|
+
fontWeight: VisualTokens.fontWeight.strong,
|
|
736
|
+
borderRadius: VisualTokens.radius.md,
|
|
737
|
+
cursor: isInactive ? "not-allowed" : "pointer",
|
|
738
|
+
opacity: isInactive ? VisualTokens.opacity.disabled : 1,
|
|
739
|
+
transition: VisualTokens.transition.instant
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
// src/presentation/utils/inline-error-visual.ts
|
|
745
|
+
var import_react4 = require("react");
|
|
746
|
+
var InlineErrorVisual = class _InlineErrorVisual {
|
|
747
|
+
static style() {
|
|
748
|
+
return {
|
|
749
|
+
color: VisualTokens.color.textError,
|
|
750
|
+
fontSize: VisualTokens.fontSize.caption
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
static element(message) {
|
|
754
|
+
if (!message) return null;
|
|
755
|
+
return (0, import_react4.createElement)(
|
|
756
|
+
"span",
|
|
757
|
+
{ role: "alert", style: _InlineErrorVisual.style() },
|
|
758
|
+
message
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
// src/presentation/components/CheckoutButton.tsx
|
|
764
|
+
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
765
|
+
var DEFAULT_LABELS = Object.freeze({
|
|
766
|
+
loading: "Redirecting\u2026"
|
|
767
|
+
});
|
|
768
|
+
function CheckoutButton({
|
|
769
|
+
productId,
|
|
770
|
+
billingCycle,
|
|
771
|
+
successUrl,
|
|
772
|
+
planKey,
|
|
773
|
+
label,
|
|
774
|
+
variant = "primary",
|
|
775
|
+
disabled = false,
|
|
776
|
+
onError,
|
|
777
|
+
className,
|
|
778
|
+
labels
|
|
779
|
+
}) {
|
|
780
|
+
const { start, loading, error } = useCheckoutButton({
|
|
781
|
+
productId,
|
|
782
|
+
billingCycle,
|
|
783
|
+
successUrl,
|
|
784
|
+
planKey,
|
|
785
|
+
onError
|
|
786
|
+
});
|
|
787
|
+
const style = (0, import_react5.useMemo)(
|
|
788
|
+
() => CheckoutButtonVisual.resolve({ variant, disabled, loading }),
|
|
789
|
+
[variant, disabled, loading]
|
|
790
|
+
);
|
|
791
|
+
const text = (0, import_react5.useMemo)(
|
|
792
|
+
() => ({ ...DEFAULT_LABELS, ...labels }),
|
|
793
|
+
[labels]
|
|
794
|
+
);
|
|
795
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { style: { display: "inline-flex", flexDirection: "column", gap: 4 }, children: [
|
|
796
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
797
|
+
"button",
|
|
798
|
+
{
|
|
799
|
+
type: "button",
|
|
800
|
+
style,
|
|
801
|
+
className,
|
|
802
|
+
onClick: () => void start(),
|
|
803
|
+
disabled: disabled || loading,
|
|
804
|
+
"aria-busy": loading,
|
|
805
|
+
children: loading ? text.loading : label
|
|
806
|
+
}
|
|
807
|
+
),
|
|
808
|
+
InlineErrorVisual.element(error?.message)
|
|
809
|
+
] });
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// src/presentation/components/SubscriptionStatusBadge.tsx
|
|
813
|
+
var import_react6 = require("react");
|
|
814
|
+
|
|
815
|
+
// src/presentation/utils/status-tone.ts
|
|
816
|
+
var StatusTone = class _StatusTone {
|
|
817
|
+
static MAP = Object.freeze({
|
|
818
|
+
active: "active",
|
|
819
|
+
trialing: "active",
|
|
820
|
+
past_due: "warning",
|
|
821
|
+
incomplete: "warning",
|
|
822
|
+
canceled: "danger",
|
|
823
|
+
revoked: "danger",
|
|
824
|
+
unpaid: "danger",
|
|
825
|
+
incomplete_expired: "danger",
|
|
826
|
+
none: "neutral"
|
|
827
|
+
});
|
|
828
|
+
static forStatus(status) {
|
|
829
|
+
return _StatusTone.MAP[status] ?? "neutral";
|
|
830
|
+
}
|
|
831
|
+
static pairFor(status) {
|
|
832
|
+
return TONE_PAIRS[_StatusTone.forStatus(status)];
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
// src/presentation/utils/status-label.ts
|
|
837
|
+
var StatusLabel = class _StatusLabel {
|
|
838
|
+
static MAP = Object.freeze({
|
|
839
|
+
active: "Active",
|
|
840
|
+
trialing: "Trial",
|
|
841
|
+
past_due: "Past due",
|
|
842
|
+
incomplete: "Incomplete",
|
|
843
|
+
canceled: "Canceled",
|
|
844
|
+
revoked: "Revoked",
|
|
845
|
+
unpaid: "Unpaid",
|
|
846
|
+
incomplete_expired: "Expired",
|
|
847
|
+
none: "Free"
|
|
848
|
+
});
|
|
849
|
+
static forStatus(status, overrides) {
|
|
850
|
+
return overrides?.[status] ?? _StatusLabel.MAP[status] ?? "Free";
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
// src/infrastructure/utils/date-renderer.ts
|
|
855
|
+
var DEFAULT_LOCALE = "en-US";
|
|
856
|
+
var ISO_DATE_DISPLAY_LENGTH = 10;
|
|
857
|
+
var FALLBACK_DATE_LABEL = "soon";
|
|
858
|
+
var FALLBACK_LONG_DATE = "\u2014";
|
|
859
|
+
var DateRenderer = class _DateRenderer {
|
|
860
|
+
dateLength;
|
|
861
|
+
shortFormatter;
|
|
862
|
+
longFormatter;
|
|
863
|
+
constructor(locale = DEFAULT_LOCALE, dateLength = ISO_DATE_DISPLAY_LENGTH) {
|
|
864
|
+
this.dateLength = dateLength;
|
|
865
|
+
this.shortFormatter = new Intl.DateTimeFormat(locale, {
|
|
866
|
+
year: "numeric",
|
|
867
|
+
month: "short",
|
|
868
|
+
day: "numeric"
|
|
869
|
+
});
|
|
870
|
+
this.longFormatter = new Intl.DateTimeFormat(locale, {
|
|
871
|
+
year: "numeric",
|
|
872
|
+
month: "long",
|
|
873
|
+
day: "numeric"
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
short(iso) {
|
|
877
|
+
if (!iso) return FALLBACK_LONG_DATE;
|
|
878
|
+
return iso.slice(0, this.dateLength);
|
|
879
|
+
}
|
|
880
|
+
renewal(iso, fallback = FALLBACK_DATE_LABEL) {
|
|
881
|
+
const date = _DateRenderer.parse(iso);
|
|
882
|
+
if (!date) return fallback;
|
|
883
|
+
return this.shortFormatter.format(date);
|
|
884
|
+
}
|
|
885
|
+
long(iso) {
|
|
886
|
+
if (!iso) return FALLBACK_LONG_DATE;
|
|
887
|
+
const date = _DateRenderer.parse(iso);
|
|
888
|
+
if (!date) return iso;
|
|
889
|
+
return this.longFormatter.format(date);
|
|
890
|
+
}
|
|
891
|
+
static parse(iso) {
|
|
892
|
+
if (!iso) return null;
|
|
893
|
+
const date = new Date(iso);
|
|
894
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
// src/presentation/components/SubscriptionStatusBadge.tsx
|
|
899
|
+
var import_jsx_runtime3 = require("react/jsx-runtime");
|
|
900
|
+
var DEFAULT_LABELS2 = Object.freeze({
|
|
901
|
+
loading: "Loading\u2026",
|
|
902
|
+
endsPrefix: "ends"
|
|
903
|
+
});
|
|
904
|
+
function SubscriptionStatusBadge({
|
|
905
|
+
status: statusOverride,
|
|
906
|
+
loading: loadingOverride,
|
|
907
|
+
locale,
|
|
908
|
+
labels
|
|
909
|
+
}) {
|
|
910
|
+
const ctx = useBilling();
|
|
911
|
+
const status = statusOverride ?? ctx.status;
|
|
912
|
+
const loading = loadingOverride ?? ctx.loading;
|
|
913
|
+
const dateRenderer = (0, import_react6.useMemo)(() => new DateRenderer(locale), [locale]);
|
|
914
|
+
const text = (0, import_react6.useMemo)(
|
|
915
|
+
() => ({ ...DEFAULT_LABELS2, ...labels }),
|
|
916
|
+
[labels]
|
|
917
|
+
);
|
|
918
|
+
const pillStyle = (0, import_react6.useMemo)(
|
|
919
|
+
() => ({
|
|
920
|
+
padding: `${VisualTokens.spacing.xs}px ${VisualTokens.spacing.lg - 2}px`,
|
|
921
|
+
borderRadius: VisualTokens.radius.pill,
|
|
922
|
+
fontSize: VisualTokens.fontSize.caption,
|
|
923
|
+
fontWeight: VisualTokens.fontWeight.strong
|
|
924
|
+
}),
|
|
925
|
+
[]
|
|
926
|
+
);
|
|
927
|
+
if (loading) {
|
|
928
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
929
|
+
"span",
|
|
930
|
+
{
|
|
931
|
+
style: {
|
|
932
|
+
...pillStyle,
|
|
933
|
+
...TONE_PAIRS.neutral
|
|
934
|
+
},
|
|
935
|
+
children: text.loading
|
|
936
|
+
}
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
const tone = StatusTone.forStatus(status.subscriptionStatus);
|
|
940
|
+
const color = TONE_PAIRS[tone];
|
|
941
|
+
const label = StatusLabel.forStatus(status.subscriptionStatus, labels?.statuses);
|
|
942
|
+
const showPlan = status.plan && status.plan !== "free";
|
|
943
|
+
const showEnds = status.cancelAtPeriodEnd;
|
|
944
|
+
const endsLabel = showEnds ? `${text.endsPrefix} ${dateRenderer.renewal(status.currentPeriodEnd)}` : null;
|
|
945
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
|
|
946
|
+
"span",
|
|
947
|
+
{
|
|
948
|
+
"aria-label": `Subscription ${label}`,
|
|
949
|
+
style: {
|
|
950
|
+
backgroundColor: color.bg,
|
|
951
|
+
color: color.fg,
|
|
952
|
+
padding: pillStyle.padding,
|
|
953
|
+
borderRadius: VisualTokens.radius.pill,
|
|
954
|
+
fontSize: VisualTokens.fontSize.caption,
|
|
955
|
+
fontWeight: VisualTokens.fontWeight.strong,
|
|
956
|
+
display: "inline-flex",
|
|
957
|
+
alignItems: "center",
|
|
958
|
+
gap: VisualTokens.spacing.xs + 2
|
|
959
|
+
},
|
|
960
|
+
children: [
|
|
961
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: { textTransform: "capitalize" }, children: label }),
|
|
962
|
+
showPlan ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { style: { opacity: VisualTokens.opacity.muted }, children: [
|
|
963
|
+
"\xB7 ",
|
|
964
|
+
status.plan
|
|
965
|
+
] }) : null,
|
|
966
|
+
endsLabel ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { style: { opacity: VisualTokens.opacity.muted }, children: [
|
|
967
|
+
"\xB7 ",
|
|
968
|
+
endsLabel
|
|
969
|
+
] }) : null
|
|
970
|
+
]
|
|
971
|
+
}
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// src/presentation/components/PricingTable.tsx
|
|
976
|
+
var import_react8 = require("react");
|
|
977
|
+
|
|
978
|
+
// src/presentation/hooks/useProducts.ts
|
|
979
|
+
var import_react7 = require("react");
|
|
980
|
+
|
|
981
|
+
// src/infrastructure/constants/billing.constants.ts
|
|
982
|
+
var API_BASE_URL = "https://api.polar.sh/v1";
|
|
983
|
+
var API_MAX_PAGE_SIZE = 100;
|
|
984
|
+
|
|
985
|
+
// src/infrastructure/http/http-client.ts
|
|
986
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
987
|
+
var FetchHttpClient = class {
|
|
988
|
+
async request(request) {
|
|
989
|
+
const init = {
|
|
990
|
+
method: request.method,
|
|
991
|
+
headers: {
|
|
992
|
+
Accept: "application/json",
|
|
993
|
+
...request.headers ?? {}
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
if (request.body !== void 0) {
|
|
997
|
+
init.body = JSON.stringify(request.body);
|
|
998
|
+
init.headers["Content-Type"] = "application/json";
|
|
999
|
+
}
|
|
1000
|
+
const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1001
|
+
const controller = new AbortController();
|
|
1002
|
+
let timedOut = false;
|
|
1003
|
+
const timer = setTimeout(() => {
|
|
1004
|
+
timedOut = true;
|
|
1005
|
+
controller.abort();
|
|
1006
|
+
}, timeoutMs);
|
|
1007
|
+
const forwardAbort = () => controller.abort();
|
|
1008
|
+
if (request.signal) {
|
|
1009
|
+
if (request.signal.aborted) controller.abort();
|
|
1010
|
+
else request.signal.addEventListener("abort", forwardAbort);
|
|
1011
|
+
}
|
|
1012
|
+
init.signal = controller.signal;
|
|
1013
|
+
let response;
|
|
1014
|
+
try {
|
|
1015
|
+
response = await fetch(request.url, init);
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
if (timedOut && error instanceof Error && error.name === "AbortError") {
|
|
1018
|
+
throw new DOMException(`Request timed out after ${timeoutMs}ms`, "TimeoutError");
|
|
1019
|
+
}
|
|
1020
|
+
throw error;
|
|
1021
|
+
} finally {
|
|
1022
|
+
clearTimeout(timer);
|
|
1023
|
+
request.signal?.removeEventListener("abort", forwardAbort);
|
|
1024
|
+
}
|
|
1025
|
+
const bodyText = await response.text();
|
|
1026
|
+
let data = null;
|
|
1027
|
+
if (bodyText.length > 0) {
|
|
1028
|
+
try {
|
|
1029
|
+
data = JSON.parse(bodyText);
|
|
1030
|
+
} catch {
|
|
1031
|
+
data = null;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return {
|
|
1035
|
+
status: response.status,
|
|
1036
|
+
ok: response.ok,
|
|
1037
|
+
data,
|
|
1038
|
+
text: async () => bodyText
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
// src/infrastructure/query/query-builder.ts
|
|
1044
|
+
var QueryBuilder = class {
|
|
1045
|
+
params;
|
|
1046
|
+
constructor(initial) {
|
|
1047
|
+
this.params = new URLSearchParams();
|
|
1048
|
+
if (initial) this.setMany(initial);
|
|
1049
|
+
}
|
|
1050
|
+
set(key, value) {
|
|
1051
|
+
if (value === void 0 || value === null) return this;
|
|
1052
|
+
this.params.set(key, String(value));
|
|
1053
|
+
return this;
|
|
1054
|
+
}
|
|
1055
|
+
setMany(values) {
|
|
1056
|
+
for (const [key, value] of Object.entries(values)) {
|
|
1057
|
+
this.set(key, value);
|
|
1058
|
+
}
|
|
1059
|
+
return this;
|
|
1060
|
+
}
|
|
1061
|
+
toString() {
|
|
1062
|
+
return this.params.toString();
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
// src/infrastructure/pagination/page-iterator.ts
|
|
1067
|
+
var PageIterator = class {
|
|
1068
|
+
fetcher;
|
|
1069
|
+
limit;
|
|
1070
|
+
signal;
|
|
1071
|
+
constructor(fetcher, options = {}) {
|
|
1072
|
+
this.fetcher = fetcher;
|
|
1073
|
+
this.limit = options.limit ?? 100;
|
|
1074
|
+
this.signal = options.signal;
|
|
1075
|
+
}
|
|
1076
|
+
async *[Symbol.asyncIterator]() {
|
|
1077
|
+
let page = 1;
|
|
1078
|
+
while (true) {
|
|
1079
|
+
if (this.signal?.aborted) return;
|
|
1080
|
+
const current = await this.fetcher.fetchPage(page, this.limit);
|
|
1081
|
+
for (const item of current.items) {
|
|
1082
|
+
if (this.signal?.aborted) return;
|
|
1083
|
+
yield item;
|
|
1084
|
+
}
|
|
1085
|
+
if (current.isLast || page >= current.maxPage) return;
|
|
1086
|
+
page++;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
// src/infrastructure/utils/pagination-clamp.ts
|
|
1092
|
+
var MIN_PAGE_SIZE = 1;
|
|
1093
|
+
var PaginationClamp = class {
|
|
1094
|
+
static pageSize(raw, max = API_MAX_PAGE_SIZE) {
|
|
1095
|
+
if (raw === void 0) return max;
|
|
1096
|
+
return Math.min(Math.max(MIN_PAGE_SIZE, raw), max);
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
// src/infrastructure/services/product.repository.ts
|
|
1101
|
+
var BillingApiError = class _BillingApiError extends Error {
|
|
1102
|
+
constructor(message, status, body) {
|
|
1103
|
+
super(message);
|
|
1104
|
+
this.status = status;
|
|
1105
|
+
this.body = body;
|
|
1106
|
+
this.name = "BillingApiError";
|
|
1107
|
+
Object.setPrototypeOf(this, _BillingApiError.prototype);
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
var ProductRepository = class {
|
|
1111
|
+
http;
|
|
1112
|
+
apiToken;
|
|
1113
|
+
baseUrl;
|
|
1114
|
+
constructor(config, http = new FetchHttpClient()) {
|
|
1115
|
+
this.http = http;
|
|
1116
|
+
this.apiToken = config.apiToken;
|
|
1117
|
+
this.baseUrl = config.baseUrl ?? API_BASE_URL;
|
|
1118
|
+
}
|
|
1119
|
+
async list(params, options) {
|
|
1120
|
+
const query = new QueryBuilder({
|
|
1121
|
+
organization_id: params?.organizationId,
|
|
1122
|
+
is_recurring: params?.isRecurring,
|
|
1123
|
+
page: params?.page,
|
|
1124
|
+
limit: this.clampLimit(params?.limit)
|
|
1125
|
+
});
|
|
1126
|
+
const url = `${this.baseUrl}/products?${query.toString()}`;
|
|
1127
|
+
const response = await this.http.request({
|
|
1128
|
+
method: "GET",
|
|
1129
|
+
url,
|
|
1130
|
+
headers: this.authHeaders(),
|
|
1131
|
+
signal: options?.signal,
|
|
1132
|
+
timeoutMs: options?.timeoutMs
|
|
1133
|
+
});
|
|
1134
|
+
if (!response.ok) {
|
|
1135
|
+
const body = await response.text();
|
|
1136
|
+
throw new BillingApiError(
|
|
1137
|
+
`Billing API error: ${response.status}`,
|
|
1138
|
+
response.status,
|
|
1139
|
+
body
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
return mapProductList(response.data);
|
|
1143
|
+
}
|
|
1144
|
+
async findById(productId, options) {
|
|
1145
|
+
const url = `${this.baseUrl}/products/${encodeURIComponent(productId)}`;
|
|
1146
|
+
const response = await this.http.request({
|
|
1147
|
+
method: "GET",
|
|
1148
|
+
url,
|
|
1149
|
+
headers: this.authHeaders(),
|
|
1150
|
+
signal: options?.signal,
|
|
1151
|
+
timeoutMs: options?.timeoutMs
|
|
1152
|
+
});
|
|
1153
|
+
if (response.status === 404) return null;
|
|
1154
|
+
if (!response.ok) {
|
|
1155
|
+
const body = await response.text();
|
|
1156
|
+
throw new BillingApiError(
|
|
1157
|
+
`Billing API error: ${response.status}`,
|
|
1158
|
+
response.status,
|
|
1159
|
+
body
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
if (!response.data) {
|
|
1163
|
+
throw new BillingApiError(
|
|
1164
|
+
"Billing API returned an empty product body",
|
|
1165
|
+
response.status
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
return mapProduct(response.data);
|
|
1169
|
+
}
|
|
1170
|
+
iterateAll(params, options = {}) {
|
|
1171
|
+
const limit = this.clampLimit(options.limit);
|
|
1172
|
+
const fetcher = {
|
|
1173
|
+
fetchPage: async (page, pageLimit) => {
|
|
1174
|
+
const response = await this.list(
|
|
1175
|
+
{ ...params, page, limit: pageLimit },
|
|
1176
|
+
{ signal: options.signal }
|
|
1177
|
+
);
|
|
1178
|
+
const pageData = {
|
|
1179
|
+
items: response.items,
|
|
1180
|
+
page,
|
|
1181
|
+
totalCount: response.pagination.totalCount,
|
|
1182
|
+
maxPage: response.pagination.maxPage,
|
|
1183
|
+
isLast: page >= response.pagination.maxPage
|
|
1184
|
+
};
|
|
1185
|
+
return pageData;
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
return new PageIterator(fetcher, { limit, signal: options.signal });
|
|
1189
|
+
}
|
|
1190
|
+
async listAll(params, options = {}) {
|
|
1191
|
+
const out = [];
|
|
1192
|
+
for await (const product of this.iterateAll(params, options)) {
|
|
1193
|
+
out.push(product);
|
|
1194
|
+
}
|
|
1195
|
+
return out;
|
|
1196
|
+
}
|
|
1197
|
+
authHeaders() {
|
|
1198
|
+
return { Authorization: `Bearer ${this.apiToken}` };
|
|
1199
|
+
}
|
|
1200
|
+
clampLimit(limit) {
|
|
1201
|
+
return PaginationClamp.pageSize(limit, API_MAX_PAGE_SIZE);
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
var defaultClient = new FetchHttpClient();
|
|
1205
|
+
async function getAllProducts(config, params, options = {}) {
|
|
1206
|
+
return new ProductRepository(config, defaultClient).listAll(params, options);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// src/presentation/hooks/useProducts.ts
|
|
1210
|
+
function useProducts(options) {
|
|
1211
|
+
const { apiToken, organizationId, isRecurring = true, enabled = true } = options;
|
|
1212
|
+
const [products, setProducts] = (0, import_react7.useState)([]);
|
|
1213
|
+
const [loading, setLoading] = (0, import_react7.useState)(enabled);
|
|
1214
|
+
const [error, setError] = (0, import_react7.useState)(null);
|
|
1215
|
+
const optionsRef = (0, import_react7.useRef)({ apiToken, organizationId, isRecurring, enabled });
|
|
1216
|
+
optionsRef.current = { apiToken, organizationId, isRecurring, enabled };
|
|
1217
|
+
const abortRef = (0, import_react7.useRef)(null);
|
|
1218
|
+
const fetchProducts = (0, import_react7.useCallback)(async () => {
|
|
1219
|
+
const { apiToken: token, organizationId: org, isRecurring: recurring, enabled: isEnabled } = optionsRef.current;
|
|
1220
|
+
abortRef.current?.abort();
|
|
1221
|
+
if (!isEnabled) {
|
|
1222
|
+
setLoading(false);
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const controller = new AbortController();
|
|
1226
|
+
abortRef.current = controller;
|
|
1227
|
+
setLoading(true);
|
|
1228
|
+
setError(null);
|
|
1229
|
+
try {
|
|
1230
|
+
const params = { isRecurring: recurring };
|
|
1231
|
+
if (org) params.organizationId = org;
|
|
1232
|
+
const data = await getAllProducts({ apiToken: token }, params, {
|
|
1233
|
+
signal: controller.signal
|
|
1234
|
+
});
|
|
1235
|
+
if (controller.signal.aborted) return;
|
|
1236
|
+
setProducts(data);
|
|
1237
|
+
} catch (err) {
|
|
1238
|
+
if (controller.signal.aborted) return;
|
|
1239
|
+
setError(err instanceof Error ? err : new Error("Failed to fetch products"));
|
|
1240
|
+
} finally {
|
|
1241
|
+
if (!controller.signal.aborted) {
|
|
1242
|
+
setLoading(false);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}, []);
|
|
1246
|
+
(0, import_react7.useEffect)(() => {
|
|
1247
|
+
void fetchProducts();
|
|
1248
|
+
return () => {
|
|
1249
|
+
abortRef.current?.abort();
|
|
1250
|
+
};
|
|
1251
|
+
}, [apiToken, organizationId, isRecurring, enabled, fetchProducts]);
|
|
1252
|
+
return { products, loading, error, refetch: fetchProducts };
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// src/presentation/utils/product-pricing.ts
|
|
1256
|
+
var FALLBACK_PRICE_LABEL = "Custom";
|
|
1257
|
+
var ProductPricing = class _ProductPricing {
|
|
1258
|
+
static recurringPrice(product, cycle) {
|
|
1259
|
+
const recurring = product.prices.filter(
|
|
1260
|
+
(p) => p.recurringInterval !== void 0
|
|
1261
|
+
);
|
|
1262
|
+
if (recurring.length === 0) {
|
|
1263
|
+
return product.prices[0] ?? null;
|
|
1264
|
+
}
|
|
1265
|
+
if (cycle) {
|
|
1266
|
+
const match = recurring.find((p) => p.recurringInterval === cycle);
|
|
1267
|
+
if (match) return match;
|
|
1268
|
+
}
|
|
1269
|
+
return recurring[0] ?? null;
|
|
1270
|
+
}
|
|
1271
|
+
static displayLine(product, formatter, cycle) {
|
|
1272
|
+
const price = _ProductPricing.recurringPrice(product, cycle);
|
|
1273
|
+
if (!price) {
|
|
1274
|
+
return { amount: FALLBACK_PRICE_LABEL, interval: null };
|
|
1275
|
+
}
|
|
1276
|
+
const amount = formatter(price.amount, price.currency);
|
|
1277
|
+
return { amount, interval: price.recurringInterval ?? null };
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
// src/infrastructure/utils/money-formatter.ts
|
|
1282
|
+
var DEFAULT_LOCALE2 = "en-US";
|
|
1283
|
+
var DEFAULT_CURRENCY = "USD";
|
|
1284
|
+
var MoneyFormatter = class {
|
|
1285
|
+
locale;
|
|
1286
|
+
constructor(locale = DEFAULT_LOCALE2) {
|
|
1287
|
+
this.locale = locale;
|
|
1288
|
+
}
|
|
1289
|
+
format(amount, currency, options = {}) {
|
|
1290
|
+
if (amount === void 0 || amount === null) {
|
|
1291
|
+
return "Custom";
|
|
1292
|
+
}
|
|
1293
|
+
const code = (currency ?? options.fallbackCurrency ?? DEFAULT_CURRENCY).toUpperCase();
|
|
1294
|
+
try {
|
|
1295
|
+
return new Intl.NumberFormat(this.locale, {
|
|
1296
|
+
style: "currency",
|
|
1297
|
+
currency: code,
|
|
1298
|
+
maximumFractionDigits: options.maximumFractionDigits ?? 2
|
|
1299
|
+
}).format(amount / minorUnitDivisor(code));
|
|
1300
|
+
} catch {
|
|
1301
|
+
return `${amount} ${code}`;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
// src/presentation/components/PricingTable.tsx
|
|
1307
|
+
var import_jsx_runtime4 = require("react/jsx-runtime");
|
|
1308
|
+
var DEFAULT_LABELS3 = Object.freeze({
|
|
1309
|
+
loading: "Loading pricing\u2026",
|
|
1310
|
+
empty: "No products available.",
|
|
1311
|
+
errorPrefix: "Failed to load pricing:",
|
|
1312
|
+
retry: "Retry"
|
|
1313
|
+
});
|
|
1314
|
+
function PricingTable({
|
|
1315
|
+
products: productsOverride,
|
|
1316
|
+
apiToken,
|
|
1317
|
+
organizationId,
|
|
1318
|
+
isRecurring = true,
|
|
1319
|
+
ctaLabel = "Subscribe",
|
|
1320
|
+
onError,
|
|
1321
|
+
filterInterval = "monthly",
|
|
1322
|
+
locale,
|
|
1323
|
+
labels
|
|
1324
|
+
}) {
|
|
1325
|
+
const enabled = productsOverride === void 0 && apiToken !== void 0;
|
|
1326
|
+
const { products: fetchedProducts, loading, error, refetch } = useProducts({
|
|
1327
|
+
apiToken: apiToken ?? "",
|
|
1328
|
+
organizationId,
|
|
1329
|
+
isRecurring,
|
|
1330
|
+
enabled
|
|
1331
|
+
});
|
|
1332
|
+
const products = productsOverride ?? fetchedProducts;
|
|
1333
|
+
const moneyFormatter = (0, import_react8.useMemo)(() => new MoneyFormatter(locale), [locale]);
|
|
1334
|
+
const text = (0, import_react8.useMemo)(
|
|
1335
|
+
() => ({ ...DEFAULT_LABELS3, ...labels }),
|
|
1336
|
+
[labels]
|
|
1337
|
+
);
|
|
1338
|
+
const visible = (0, import_react8.useMemo)(() => {
|
|
1339
|
+
if (filterInterval === null) return products;
|
|
1340
|
+
return products.filter((p) => p.recurringInterval === filterInterval);
|
|
1341
|
+
}, [products, filterInterval]);
|
|
1342
|
+
if (enabled && loading) {
|
|
1343
|
+
return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
1344
|
+
"div",
|
|
1345
|
+
{
|
|
1346
|
+
role: "status",
|
|
1347
|
+
"aria-live": "polite",
|
|
1348
|
+
style: {
|
|
1349
|
+
padding: VisualTokens.spacing.section,
|
|
1350
|
+
textAlign: "center"
|
|
1351
|
+
},
|
|
1352
|
+
children: text.loading
|
|
1353
|
+
}
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
if (error) {
|
|
1357
|
+
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
|
|
1358
|
+
"div",
|
|
1359
|
+
{
|
|
1360
|
+
role: "alert",
|
|
1361
|
+
style: {
|
|
1362
|
+
padding: VisualTokens.spacing.xl,
|
|
1363
|
+
background: VisualTokens.color.surfaceDanger,
|
|
1364
|
+
borderRadius: VisualTokens.radius.md
|
|
1365
|
+
},
|
|
1366
|
+
children: [
|
|
1367
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("p", { style: { margin: 0, color: VisualTokens.color.brandDangerText }, children: [
|
|
1368
|
+
text.errorPrefix,
|
|
1369
|
+
" ",
|
|
1370
|
+
error.message
|
|
1371
|
+
] }),
|
|
1372
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
1373
|
+
"button",
|
|
1374
|
+
{
|
|
1375
|
+
type: "button",
|
|
1376
|
+
onClick: () => void refetch(),
|
|
1377
|
+
style: { marginTop: VisualTokens.spacing.md, padding: "6px 12px" },
|
|
1378
|
+
children: text.retry
|
|
1379
|
+
}
|
|
1380
|
+
)
|
|
1381
|
+
]
|
|
1382
|
+
}
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
if (visible.length === 0) {
|
|
1386
|
+
return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
1387
|
+
"div",
|
|
1388
|
+
{
|
|
1389
|
+
style: {
|
|
1390
|
+
padding: VisualTokens.spacing.section,
|
|
1391
|
+
textAlign: "center",
|
|
1392
|
+
color: VisualTokens.color.textMuted
|
|
1393
|
+
},
|
|
1394
|
+
children: text.empty
|
|
1395
|
+
}
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
1399
|
+
"div",
|
|
1400
|
+
{
|
|
1401
|
+
style: {
|
|
1402
|
+
display: "grid",
|
|
1403
|
+
gridTemplateColumns: `repeat(auto-fit, minmax(${VisualTokens.layout.cardMinWidth}px, 1fr))`,
|
|
1404
|
+
gap: VisualTokens.spacing.xl
|
|
1405
|
+
},
|
|
1406
|
+
children: visible.map((product) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
1407
|
+
ProductCard,
|
|
1408
|
+
{
|
|
1409
|
+
product,
|
|
1410
|
+
moneyFormatter,
|
|
1411
|
+
ctaLabel,
|
|
1412
|
+
onError
|
|
1413
|
+
},
|
|
1414
|
+
product.id
|
|
1415
|
+
))
|
|
1416
|
+
}
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
function ProductCard({ product, moneyFormatter, ctaLabel, onError }) {
|
|
1420
|
+
const { amount, interval } = (0, import_react8.useMemo)(
|
|
1421
|
+
() => ProductPricing.displayLine(
|
|
1422
|
+
product,
|
|
1423
|
+
(amt, cur) => moneyFormatter.format(amt, cur)
|
|
1424
|
+
),
|
|
1425
|
+
[product, moneyFormatter]
|
|
1426
|
+
);
|
|
1427
|
+
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
|
|
1428
|
+
"article",
|
|
1429
|
+
{
|
|
1430
|
+
style: {
|
|
1431
|
+
border: `1px solid ${VisualTokens.color.borderSubtle}`,
|
|
1432
|
+
borderRadius: VisualTokens.radius.lg,
|
|
1433
|
+
padding: VisualTokens.spacing.xxl,
|
|
1434
|
+
display: "flex",
|
|
1435
|
+
flexDirection: "column",
|
|
1436
|
+
gap: VisualTokens.spacing.lg
|
|
1437
|
+
},
|
|
1438
|
+
children: [
|
|
1439
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("header", { children: [
|
|
1440
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("h3", { style: { margin: 0, fontSize: VisualTokens.fontSize.title }, children: product.name }),
|
|
1441
|
+
product.description ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
1442
|
+
"p",
|
|
1443
|
+
{
|
|
1444
|
+
style: {
|
|
1445
|
+
margin: `${VisualTokens.spacing.xs}px 0 0`,
|
|
1446
|
+
color: VisualTokens.color.textMuted,
|
|
1447
|
+
fontSize: VisualTokens.fontSize.body
|
|
1448
|
+
},
|
|
1449
|
+
children: product.description
|
|
1450
|
+
}
|
|
1451
|
+
) : null
|
|
1452
|
+
] }),
|
|
1453
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { style: { fontSize: VisualTokens.fontSize.display, fontWeight: VisualTokens.fontWeight.strong }, children: [
|
|
1454
|
+
amount,
|
|
1455
|
+
interval ? /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
|
|
1456
|
+
"span",
|
|
1457
|
+
{
|
|
1458
|
+
style: {
|
|
1459
|
+
fontSize: VisualTokens.fontSize.body,
|
|
1460
|
+
fontWeight: VisualTokens.fontWeight.regular,
|
|
1461
|
+
color: VisualTokens.color.textMuted
|
|
1462
|
+
},
|
|
1463
|
+
children: [
|
|
1464
|
+
" ",
|
|
1465
|
+
"/ ",
|
|
1466
|
+
interval
|
|
1467
|
+
]
|
|
1468
|
+
}
|
|
1469
|
+
) : null
|
|
1470
|
+
] }),
|
|
1471
|
+
product.benefits.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
1472
|
+
"ul",
|
|
1473
|
+
{
|
|
1474
|
+
style: {
|
|
1475
|
+
paddingLeft: 18,
|
|
1476
|
+
margin: 0,
|
|
1477
|
+
color: "#374151",
|
|
1478
|
+
fontSize: VisualTokens.fontSize.body
|
|
1479
|
+
},
|
|
1480
|
+
children: product.benefits.slice(0, VisualTokens.layout.benefitListMax).map((b) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("li", { children: b.description ?? b.type }, b.id))
|
|
1481
|
+
}
|
|
1482
|
+
) : null,
|
|
1483
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { style: { marginTop: "auto" }, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
1484
|
+
CheckoutButton,
|
|
1485
|
+
{
|
|
1486
|
+
productId: product.id,
|
|
1487
|
+
billingCycle: product.recurringInterval ?? void 0,
|
|
1488
|
+
label: ctaLabel,
|
|
1489
|
+
onError
|
|
1490
|
+
}
|
|
1491
|
+
) })
|
|
1492
|
+
]
|
|
1493
|
+
}
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// src/presentation/components/BillingHistoryList.tsx
|
|
1498
|
+
var import_react9 = require("react");
|
|
1499
|
+
var import_jsx_runtime5 = require("react/jsx-runtime");
|
|
1500
|
+
var DEFAULT_LABELS4 = Object.freeze({
|
|
1501
|
+
loading: "Loading billing history\u2026",
|
|
1502
|
+
empty: "No invoices yet.",
|
|
1503
|
+
refresh: "Refresh",
|
|
1504
|
+
retry: "Retry",
|
|
1505
|
+
paid: "Paid",
|
|
1506
|
+
invoiceLink: "View",
|
|
1507
|
+
invoiceFallback: "\u2014",
|
|
1508
|
+
colDate: "Date",
|
|
1509
|
+
colProduct: "Product",
|
|
1510
|
+
colStatus: "Status",
|
|
1511
|
+
colAmount: "Amount",
|
|
1512
|
+
colInvoice: "Invoice"
|
|
1513
|
+
});
|
|
1514
|
+
function BillingHistoryList({
|
|
1515
|
+
autoLoad = true,
|
|
1516
|
+
orders: ordersOverride,
|
|
1517
|
+
loading: loadingOverride,
|
|
1518
|
+
locale,
|
|
1519
|
+
labels
|
|
1520
|
+
}) {
|
|
1521
|
+
const { getBillingHistory } = useBilling();
|
|
1522
|
+
const [orders, setOrders] = (0, import_react9.useState)(ordersOverride ?? []);
|
|
1523
|
+
const [loading, setLoading] = (0, import_react9.useState)(autoLoad && ordersOverride === void 0);
|
|
1524
|
+
const [error, setError] = (0, import_react9.useState)(null);
|
|
1525
|
+
const moneyFormatter = (0, import_react9.useMemo)(() => new MoneyFormatter(locale), [locale]);
|
|
1526
|
+
const dateRenderer = (0, import_react9.useMemo)(() => new DateRenderer(locale), [locale]);
|
|
1527
|
+
const text = (0, import_react9.useMemo)(
|
|
1528
|
+
() => ({ ...DEFAULT_LABELS4, ...labels }),
|
|
1529
|
+
[labels]
|
|
1530
|
+
);
|
|
1531
|
+
const refresh = (0, import_react9.useCallback)(async () => {
|
|
1532
|
+
setLoading(true);
|
|
1533
|
+
setError(null);
|
|
1534
|
+
try {
|
|
1535
|
+
const next = await getBillingHistory();
|
|
1536
|
+
setOrders(next);
|
|
1537
|
+
} catch (err) {
|
|
1538
|
+
setError(err instanceof Error ? err.message : "Failed to load billing history");
|
|
1539
|
+
} finally {
|
|
1540
|
+
setLoading(false);
|
|
1541
|
+
}
|
|
1542
|
+
}, [getBillingHistory]);
|
|
1543
|
+
(0, import_react9.useEffect)(() => {
|
|
1544
|
+
if (autoLoad && ordersOverride === void 0) {
|
|
1545
|
+
void refresh();
|
|
1546
|
+
}
|
|
1547
|
+
}, [autoLoad, ordersOverride, refresh]);
|
|
1548
|
+
if (ordersOverride !== void 0 && ordersOverride.length > 0) {
|
|
1549
|
+
return renderList(ordersOverride, moneyFormatter, dateRenderer, text);
|
|
1550
|
+
}
|
|
1551
|
+
if (loadingOverride !== void 0 && loadingOverride) {
|
|
1552
|
+
return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { role: "status", children: text.loading });
|
|
1553
|
+
}
|
|
1554
|
+
if (loading) {
|
|
1555
|
+
return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { role: "status", children: text.loading });
|
|
1556
|
+
}
|
|
1557
|
+
if (error) {
|
|
1558
|
+
return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(
|
|
1559
|
+
"div",
|
|
1560
|
+
{
|
|
1561
|
+
role: "alert",
|
|
1562
|
+
style: {
|
|
1563
|
+
background: VisualTokens.color.surfaceDanger,
|
|
1564
|
+
padding: VisualTokens.spacing.lg,
|
|
1565
|
+
borderRadius: VisualTokens.radius.md
|
|
1566
|
+
},
|
|
1567
|
+
children: [
|
|
1568
|
+
error,
|
|
1569
|
+
" ",
|
|
1570
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("button", { type: "button", onClick: () => void refresh(), children: text.retry })
|
|
1571
|
+
]
|
|
1572
|
+
}
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
if (orders.length === 0) {
|
|
1576
|
+
return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: { color: VisualTokens.color.textMuted }, children: text.empty });
|
|
1577
|
+
}
|
|
1578
|
+
return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { children: [
|
|
1579
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: { marginBottom: VisualTokens.spacing.md }, children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("button", { type: "button", onClick: () => void refresh(), children: text.refresh }) }),
|
|
1580
|
+
renderList(orders, moneyFormatter, dateRenderer, text)
|
|
1581
|
+
] });
|
|
1582
|
+
}
|
|
1583
|
+
function renderList(orders, moneyFormatter, dateRenderer, text) {
|
|
1584
|
+
return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("table", { style: { width: "100%", borderCollapse: "collapse" }, children: [
|
|
1585
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("thead", { children: /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("tr", { style: { textAlign: "left", borderBottom: `1px solid ${VisualTokens.color.borderSubtle}` }, children: [
|
|
1586
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("th", { style: { padding: VisualTokens.spacing.md }, children: text.colDate }),
|
|
1587
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("th", { style: { padding: VisualTokens.spacing.md }, children: text.colProduct }),
|
|
1588
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("th", { style: { padding: VisualTokens.spacing.md }, children: text.colStatus }),
|
|
1589
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("th", { style: { padding: VisualTokens.spacing.md }, children: text.colAmount }),
|
|
1590
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("th", { style: { padding: VisualTokens.spacing.md }, children: text.colInvoice })
|
|
1591
|
+
] }) }),
|
|
1592
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("tbody", { children: orders.map((order) => /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("tr", { style: { borderBottom: `1px solid ${VisualTokens.color.surfaceSubtle}` }, children: [
|
|
1593
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("td", { style: { padding: VisualTokens.spacing.md }, children: dateRenderer.short(order.createdAt) }),
|
|
1594
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("td", { style: { padding: VisualTokens.spacing.md }, children: order.productName }),
|
|
1595
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("td", { style: { padding: VisualTokens.spacing.md }, children: order.paid ? text.paid : order.status }),
|
|
1596
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("td", { style: { padding: VisualTokens.spacing.md }, children: moneyFormatter.format(order.amount, order.currency) }),
|
|
1597
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("td", { style: { padding: VisualTokens.spacing.md }, children: order.invoiceUrl ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("a", { href: order.invoiceUrl, target: "_blank", rel: "noreferrer", children: text.invoiceLink }) : text.invoiceFallback })
|
|
1598
|
+
] }, order.id)) })
|
|
1599
|
+
] });
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// src/presentation/components/CustomerPortalLink.tsx
|
|
1603
|
+
var import_react10 = require("react");
|
|
1604
|
+
var import_jsx_runtime6 = require("react/jsx-runtime");
|
|
1605
|
+
var DEFAULT_LABEL = "Manage subscription";
|
|
1606
|
+
var DEFAULT_LOADING_LABEL = "Opening\u2026";
|
|
1607
|
+
function CustomerPortalLink({
|
|
1608
|
+
label = DEFAULT_LABEL,
|
|
1609
|
+
target = "open",
|
|
1610
|
+
className,
|
|
1611
|
+
labels
|
|
1612
|
+
}) {
|
|
1613
|
+
const { getPortalUrl } = useBilling();
|
|
1614
|
+
const [loading, setLoading] = (0, import_react10.useState)(false);
|
|
1615
|
+
const [error, setError] = (0, import_react10.useState)(null);
|
|
1616
|
+
const loadingLabel = labels?.loading ?? DEFAULT_LOADING_LABEL;
|
|
1617
|
+
const open = async () => {
|
|
1618
|
+
setLoading(true);
|
|
1619
|
+
setError(null);
|
|
1620
|
+
try {
|
|
1621
|
+
const url = await getPortalUrl();
|
|
1622
|
+
if (target === "open") {
|
|
1623
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
1624
|
+
} else {
|
|
1625
|
+
window.location.href = url;
|
|
1626
|
+
}
|
|
1627
|
+
} catch (err) {
|
|
1628
|
+
setError(err instanceof Error ? err.message : "Failed to open portal");
|
|
1629
|
+
} finally {
|
|
1630
|
+
setLoading(false);
|
|
1631
|
+
}
|
|
1632
|
+
};
|
|
1633
|
+
return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { style: { display: "inline-flex", flexDirection: "column", gap: VisualTokens.spacing.xs }, children: [
|
|
1634
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
1635
|
+
"button",
|
|
1636
|
+
{
|
|
1637
|
+
type: "button",
|
|
1638
|
+
onClick: () => void open(),
|
|
1639
|
+
disabled: loading,
|
|
1640
|
+
className,
|
|
1641
|
+
style: {
|
|
1642
|
+
background: "transparent",
|
|
1643
|
+
color: VisualTokens.color.brandPrimary,
|
|
1644
|
+
border: "none",
|
|
1645
|
+
padding: 0,
|
|
1646
|
+
textDecoration: "underline",
|
|
1647
|
+
cursor: loading ? "wait" : "pointer"
|
|
1648
|
+
},
|
|
1649
|
+
children: loading ? loadingLabel : label
|
|
1650
|
+
}
|
|
1651
|
+
),
|
|
1652
|
+
InlineErrorVisual.element(error)
|
|
1653
|
+
] });
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// src/presentation/components/CancelSubscriptionButton.tsx
|
|
1657
|
+
var import_react12 = require("react");
|
|
1658
|
+
|
|
1659
|
+
// src/presentation/hooks/useCancelSubscription.ts
|
|
1660
|
+
var import_react11 = require("react");
|
|
1661
|
+
function useCancelSubscription(options = {}) {
|
|
1662
|
+
const { cancelSubscription } = useBilling();
|
|
1663
|
+
const [loading, setLoading] = (0, import_react11.useState)(false);
|
|
1664
|
+
const [error, setError] = (0, import_react11.useState)(null);
|
|
1665
|
+
const { onSuccess, onError } = options;
|
|
1666
|
+
const cancel = (0, import_react11.useCallback)(
|
|
1667
|
+
async (reason) => {
|
|
1668
|
+
setLoading(true);
|
|
1669
|
+
setError(null);
|
|
1670
|
+
try {
|
|
1671
|
+
const result = await cancelSubscription(reason);
|
|
1672
|
+
onSuccess?.(result);
|
|
1673
|
+
return result;
|
|
1674
|
+
} catch (err) {
|
|
1675
|
+
const normalized = err instanceof Error ? err : new Error(String(err));
|
|
1676
|
+
setError(normalized);
|
|
1677
|
+
onError?.(normalized);
|
|
1678
|
+
return null;
|
|
1679
|
+
} finally {
|
|
1680
|
+
setLoading(false);
|
|
1681
|
+
}
|
|
1682
|
+
},
|
|
1683
|
+
[cancelSubscription, onSuccess, onError]
|
|
1684
|
+
);
|
|
1685
|
+
return { cancel, loading, error };
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// src/presentation/utils/cancellation-reason-catalog.ts
|
|
1689
|
+
var CANCELLATION_REASON_CATALOG = Object.freeze([
|
|
1690
|
+
{ value: "too_expensive", label: "Too expensive" },
|
|
1691
|
+
{ value: "missing_features", label: "Missing features" },
|
|
1692
|
+
{ value: "switched_service", label: "Switched to a different service" },
|
|
1693
|
+
{ value: "unused", label: "Not using it" },
|
|
1694
|
+
{ value: "customer_service", label: "Poor customer service" },
|
|
1695
|
+
{ value: "low_quality", label: "Quality is too low" },
|
|
1696
|
+
{ value: "too_complex", label: "Too complex" },
|
|
1697
|
+
{ value: "other", label: "Other" }
|
|
1698
|
+
]);
|
|
1699
|
+
|
|
1700
|
+
// src/presentation/components/CancelSubscriptionButton.tsx
|
|
1701
|
+
var import_jsx_runtime7 = require("react/jsx-runtime");
|
|
1702
|
+
var DEFAULT_LABELS5 = Object.freeze({
|
|
1703
|
+
action: "Cancel subscription",
|
|
1704
|
+
confirm: "Confirm cancel",
|
|
1705
|
+
keep: "Keep subscription",
|
|
1706
|
+
canceling: "Canceling\u2026",
|
|
1707
|
+
promptTitle: "Why are you canceling? (optional)",
|
|
1708
|
+
noReason: "Prefer not to say"
|
|
1709
|
+
});
|
|
1710
|
+
function CancelSubscriptionButton({
|
|
1711
|
+
label,
|
|
1712
|
+
renderPrompt,
|
|
1713
|
+
onSuccess,
|
|
1714
|
+
reasonCatalog = CANCELLATION_REASON_CATALOG,
|
|
1715
|
+
labels
|
|
1716
|
+
}) {
|
|
1717
|
+
const { cancel, loading, error } = useCancelSubscription({
|
|
1718
|
+
onSuccess: () => onSuccess?.()
|
|
1719
|
+
});
|
|
1720
|
+
const [promptOpen, setPromptOpen] = (0, import_react12.useState)(false);
|
|
1721
|
+
const [reason, setReason] = (0, import_react12.useState)("");
|
|
1722
|
+
const text = { ...DEFAULT_LABELS5, ...labels };
|
|
1723
|
+
const triggerLabel = label ?? text.action;
|
|
1724
|
+
const onConfirm = async (selected) => {
|
|
1725
|
+
setPromptOpen(false);
|
|
1726
|
+
const result = await cancel(selected);
|
|
1727
|
+
if (result?.success) {
|
|
1728
|
+
setReason("");
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
if (!promptOpen) {
|
|
1732
|
+
return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("span", { style: { display: "inline-flex", flexDirection: "column", gap: VisualTokens.spacing.xs }, children: [
|
|
1733
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
|
|
1734
|
+
"button",
|
|
1735
|
+
{
|
|
1736
|
+
type: "button",
|
|
1737
|
+
onClick: () => setPromptOpen(true),
|
|
1738
|
+
style: {
|
|
1739
|
+
background: "transparent",
|
|
1740
|
+
color: VisualTokens.color.brandDangerText,
|
|
1741
|
+
border: `1px solid ${VisualTokens.color.borderWarning}`,
|
|
1742
|
+
borderRadius: VisualTokens.radius.md,
|
|
1743
|
+
padding: `${VisualTokens.spacing.md}px ${VisualTokens.spacing.lg}px`,
|
|
1744
|
+
cursor: "pointer",
|
|
1745
|
+
fontWeight: VisualTokens.fontWeight.strong
|
|
1746
|
+
},
|
|
1747
|
+
children: triggerLabel
|
|
1748
|
+
}
|
|
1749
|
+
),
|
|
1750
|
+
InlineErrorVisual.element(error?.message)
|
|
1751
|
+
] });
|
|
1752
|
+
}
|
|
1753
|
+
if (renderPrompt) {
|
|
1754
|
+
return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_jsx_runtime7.Fragment, { children: renderPrompt((selected) => void onConfirm(selected)) });
|
|
1755
|
+
}
|
|
1756
|
+
return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
|
|
1757
|
+
"div",
|
|
1758
|
+
{
|
|
1759
|
+
role: "dialog",
|
|
1760
|
+
"aria-label": "Confirm cancellation",
|
|
1761
|
+
style: {
|
|
1762
|
+
border: `1px solid ${VisualTokens.color.borderWarning}`,
|
|
1763
|
+
background: VisualTokens.color.surfaceDangerMuted,
|
|
1764
|
+
borderRadius: VisualTokens.radius.md,
|
|
1765
|
+
padding: VisualTokens.spacing.lg,
|
|
1766
|
+
display: "flex",
|
|
1767
|
+
flexDirection: "column",
|
|
1768
|
+
gap: VisualTokens.spacing.md
|
|
1769
|
+
},
|
|
1770
|
+
children: [
|
|
1771
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("label", { style: { fontSize: VisualTokens.fontSize.body, fontWeight: VisualTokens.fontWeight.strong }, children: [
|
|
1772
|
+
text.promptTitle,
|
|
1773
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
|
|
1774
|
+
"select",
|
|
1775
|
+
{
|
|
1776
|
+
value: reason,
|
|
1777
|
+
onChange: (e) => setReason(e.target.value),
|
|
1778
|
+
style: {
|
|
1779
|
+
display: "block",
|
|
1780
|
+
marginTop: VisualTokens.spacing.xs,
|
|
1781
|
+
width: "100%",
|
|
1782
|
+
padding: VisualTokens.spacing.sm
|
|
1783
|
+
},
|
|
1784
|
+
children: [
|
|
1785
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)("option", { value: "", children: text.noReason }),
|
|
1786
|
+
reasonCatalog.map((opt) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("option", { value: opt.value, children: opt.label }, opt.value))
|
|
1787
|
+
]
|
|
1788
|
+
}
|
|
1789
|
+
)
|
|
1790
|
+
] }),
|
|
1791
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { display: "flex", gap: VisualTokens.spacing.md }, children: [
|
|
1792
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
|
|
1793
|
+
"button",
|
|
1794
|
+
{
|
|
1795
|
+
type: "button",
|
|
1796
|
+
onClick: () => void onConfirm(reason || void 0),
|
|
1797
|
+
disabled: loading,
|
|
1798
|
+
style: {
|
|
1799
|
+
background: VisualTokens.color.brandDanger,
|
|
1800
|
+
color: VisualTokens.color.textOnPrimary,
|
|
1801
|
+
border: "none",
|
|
1802
|
+
padding: `${VisualTokens.spacing.sm}px ${VisualTokens.spacing.lg}px`,
|
|
1803
|
+
borderRadius: VisualTokens.radius.sm
|
|
1804
|
+
},
|
|
1805
|
+
children: loading ? text.canceling : text.confirm
|
|
1806
|
+
}
|
|
1807
|
+
),
|
|
1808
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
|
|
1809
|
+
"button",
|
|
1810
|
+
{
|
|
1811
|
+
type: "button",
|
|
1812
|
+
onClick: () => setPromptOpen(false),
|
|
1813
|
+
disabled: loading,
|
|
1814
|
+
style: {
|
|
1815
|
+
background: "transparent",
|
|
1816
|
+
border: `1px solid ${VisualTokens.color.borderStrong}`,
|
|
1817
|
+
padding: `${VisualTokens.spacing.sm}px ${VisualTokens.spacing.lg}px`,
|
|
1818
|
+
borderRadius: VisualTokens.radius.sm
|
|
1819
|
+
},
|
|
1820
|
+
children: text.keep
|
|
1821
|
+
}
|
|
1822
|
+
)
|
|
1823
|
+
] }),
|
|
1824
|
+
InlineErrorVisual.element(error?.message)
|
|
1825
|
+
]
|
|
1826
|
+
}
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1829
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1830
|
+
0 && (module.exports = {
|
|
1831
|
+
BILLING_CYCLE,
|
|
1832
|
+
BILLING_CYCLE_VALUES,
|
|
1833
|
+
BillingContext,
|
|
1834
|
+
BillingHistoryList,
|
|
1835
|
+
BillingProvider,
|
|
1836
|
+
CANCELLATION_REASON,
|
|
1837
|
+
CANCELLATION_REASON_CATALOG,
|
|
1838
|
+
CANCELLATION_REASON_VALUES,
|
|
1839
|
+
CancelSubscriptionButton,
|
|
1840
|
+
CheckoutButton,
|
|
1841
|
+
CustomerPortalLink,
|
|
1842
|
+
FREE_SUBSCRIPTION_STATUS,
|
|
1843
|
+
NoopNavigationStrategy,
|
|
1844
|
+
PAYMENT_PROVIDER,
|
|
1845
|
+
PAYMENT_PROVIDER_VALUES,
|
|
1846
|
+
POLAR_AMOUNT_TYPE,
|
|
1847
|
+
PricingTable,
|
|
1848
|
+
SUBSCRIPTION_STATUS,
|
|
1849
|
+
SUBSCRIPTION_STATUS_VALUES,
|
|
1850
|
+
SubscriptionStatusBadge,
|
|
1851
|
+
TONE_PAIRS,
|
|
1852
|
+
VisualTokens,
|
|
1853
|
+
WindowNavigationStrategy,
|
|
1854
|
+
applyPercentDiscount,
|
|
1855
|
+
buildCheckoutMetadata,
|
|
1856
|
+
createMemoryBillingAdapter,
|
|
1857
|
+
formatReceiptNumber,
|
|
1858
|
+
isActiveSubscription,
|
|
1859
|
+
isKnownBillingCycle,
|
|
1860
|
+
isKnownPaymentProvider,
|
|
1861
|
+
isKnownSubscriptionStatus,
|
|
1862
|
+
mapProduct,
|
|
1863
|
+
mapProductList,
|
|
1864
|
+
minorUnitDivisor,
|
|
1865
|
+
normalizeCouponCode,
|
|
1866
|
+
parseBillingCycle,
|
|
1867
|
+
readCheckoutMetadata,
|
|
1868
|
+
toMajorUnits,
|
|
1869
|
+
toMinorUnits,
|
|
1870
|
+
useBilling,
|
|
1871
|
+
useCancelSubscription,
|
|
1872
|
+
useCheckoutButton,
|
|
1873
|
+
useProducts
|
|
1874
|
+
});
|