@meetreeve/billing-kit 0.1.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/dist/index.mjs ADDED
@@ -0,0 +1,886 @@
1
+ "use client";
2
+
3
+ // src/format.ts
4
+ function dollarsFromCents(cents) {
5
+ return Math.round(cents / 100);
6
+ }
7
+ function formatPrice(cents, currency = "usd") {
8
+ const symbol = currency.toLowerCase() === "usd" ? "$" : `${currency.toUpperCase()} `;
9
+ return `${symbol}${dollarsFromCents(cents)}`;
10
+ }
11
+ function formatCredits(n) {
12
+ return n.toLocaleString();
13
+ }
14
+
15
+ // src/entitlements.ts
16
+ function resolveUnlockingPlan(plans, featureKey) {
17
+ for (const plan of plans) {
18
+ const limit = plan.feature_limits?.[featureKey];
19
+ if (limit !== void 0 && limit !== 0) return plan;
20
+ }
21
+ return null;
22
+ }
23
+ function isFeatureUnlocked(plan, featureKey) {
24
+ if (!plan) return false;
25
+ const limit = plan.feature_limits?.[featureKey];
26
+ return limit !== void 0 && limit !== 0;
27
+ }
28
+ function isUnlimited(limit) {
29
+ return limit === -1;
30
+ }
31
+
32
+ // src/PricingTable.tsx
33
+ import { useMemo, useState } from "react";
34
+ import { jsx, jsxs } from "react/jsx-runtime";
35
+ function ctaLabel(plan, currentPlanId, override) {
36
+ if (override) return override;
37
+ if (plan.id === currentPlanId) return "Current plan";
38
+ return `Get ${plan.name}`;
39
+ }
40
+ function PlanCard({
41
+ plan,
42
+ currentPlanId,
43
+ presentation,
44
+ onSelectPlan
45
+ }) {
46
+ const [busy, setBusy] = useState(false);
47
+ const [error, setError] = useState(null);
48
+ const isCurrent = plan.id === currentPlanId;
49
+ const highlighted = presentation?.highlighted ?? plan.popular ?? false;
50
+ const purchasable = plan.purchasable ?? true;
51
+ const badgeText = isCurrent ? null : presentation?.badge ?? (plan.popular ? "Most popular" : null);
52
+ const creditsLabel = plan.credits_grant_monthly != null ? `${formatCredits(plan.credits_grant_monthly)} credits / month` : plan.credits_grant_lifetime != null ? `${formatCredits(plan.credits_grant_lifetime)} credits, lifetime` : null;
53
+ const handleClick = async () => {
54
+ if (!onSelectPlan || isCurrent || !purchasable) return;
55
+ setBusy(true);
56
+ setError(null);
57
+ try {
58
+ await onSelectPlan(plan);
59
+ } catch (e) {
60
+ setError(e instanceof Error ? e.message : "Could not start checkout.");
61
+ } finally {
62
+ setBusy(false);
63
+ }
64
+ };
65
+ return /* @__PURE__ */ jsxs(
66
+ "div",
67
+ {
68
+ "data-testid": `pricing-plan-${plan.id}`,
69
+ className: "relative flex flex-col rounded-2xl border p-6 " + (highlighted ? "border-primary bg-card shadow-lg" : "border-border bg-card") + (presentation?.className ? ` ${presentation.className}` : ""),
70
+ children: [
71
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
72
+ /* @__PURE__ */ jsx("h3", { className: "text-xs font-semibold uppercase tracking-wide text-muted-foreground", children: plan.name }),
73
+ isCurrent ? /* @__PURE__ */ jsx(
74
+ "span",
75
+ {
76
+ "data-testid": `pricing-plan-${plan.id}-current-badge`,
77
+ className: "rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary",
78
+ children: "Your plan"
79
+ }
80
+ ) : badgeText ? /* @__PURE__ */ jsx("span", { className: "rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary", children: badgeText }) : null
81
+ ] }),
82
+ /* @__PURE__ */ jsxs("div", { className: "mt-3 flex items-baseline gap-1", children: [
83
+ /* @__PURE__ */ jsx("span", { className: "text-3xl font-bold text-foreground", children: formatPrice(plan.price_cents, plan.currency) }),
84
+ /* @__PURE__ */ jsxs("span", { className: "text-sm text-muted-foreground", children: [
85
+ "/",
86
+ plan.interval
87
+ ] })
88
+ ] }),
89
+ creditsLabel && /* @__PURE__ */ jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: creditsLabel }),
90
+ presentation?.tagline && /* @__PURE__ */ jsx("p", { className: "mt-2 text-sm text-muted-foreground", children: presentation.tagline }),
91
+ plan.features.length > 0 && /* @__PURE__ */ jsx("ul", { className: "mt-4 flex-1 space-y-2 text-sm", children: plan.features.map((f) => /* @__PURE__ */ jsx("li", { className: "text-muted-foreground", children: f }, f)) }),
92
+ /* @__PURE__ */ jsx(
93
+ "button",
94
+ {
95
+ type: "button",
96
+ onClick: handleClick,
97
+ disabled: isCurrent || busy || !purchasable,
98
+ className: "mt-6 w-full rounded-md bg-foreground px-4 py-2.5 text-sm font-semibold text-background transition-colors disabled:cursor-not-allowed disabled:opacity-60",
99
+ children: busy ? "Starting\u2026" : ctaLabel(plan, currentPlanId, presentation?.cta)
100
+ }
101
+ ),
102
+ error && /* @__PURE__ */ jsx("p", { role: "alert", className: "mt-2 text-xs text-destructive", children: error }),
103
+ !purchasable && !isCurrent && /* @__PURE__ */ jsx("p", { className: "mt-2 text-xs text-muted-foreground", children: "Not available for new subscriptions." })
104
+ ]
105
+ }
106
+ );
107
+ }
108
+ function PackCard({
109
+ pack,
110
+ best,
111
+ onBuyPack
112
+ }) {
113
+ const [busy, setBusy] = useState(false);
114
+ const [error, setError] = useState(null);
115
+ const dollars = formatPrice(pack.price_cents, pack.currency);
116
+ const perCredit = `$${(dollarsFromCents(pack.price_cents) / pack.credits).toFixed(3)}`;
117
+ const handleClick = async () => {
118
+ if (!onBuyPack) return;
119
+ setBusy(true);
120
+ setError(null);
121
+ try {
122
+ await onBuyPack(pack);
123
+ } catch (e) {
124
+ setError(e instanceof Error ? e.message : "Could not start checkout.");
125
+ } finally {
126
+ setBusy(false);
127
+ }
128
+ };
129
+ return /* @__PURE__ */ jsxs(
130
+ "div",
131
+ {
132
+ "data-testid": `pricing-pack-${pack.id}`,
133
+ className: "flex flex-col gap-4 rounded-2xl border p-5 " + (best ? "border-primary" : "border-border"),
134
+ children: [
135
+ /* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-4", children: [
136
+ /* @__PURE__ */ jsxs("div", { children: [
137
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
138
+ /* @__PURE__ */ jsx("h4", { className: "text-base font-semibold text-foreground", children: pack.name }),
139
+ best && /* @__PURE__ */ jsx("span", { className: "text-[10px] font-semibold uppercase tracking-wide text-primary", children: "Best value" })
140
+ ] }),
141
+ /* @__PURE__ */ jsxs("p", { className: "mt-1 text-sm text-muted-foreground", children: [
142
+ formatCredits(pack.credits),
143
+ " credits"
144
+ ] })
145
+ ] }),
146
+ /* @__PURE__ */ jsxs("div", { className: "text-right", children: [
147
+ /* @__PURE__ */ jsx("div", { className: "text-xl font-bold text-foreground", children: dollars }),
148
+ /* @__PURE__ */ jsxs("div", { className: "text-[10px] uppercase tracking-wide text-muted-foreground", children: [
149
+ perCredit,
150
+ " / credit"
151
+ ] })
152
+ ] })
153
+ ] }),
154
+ /* @__PURE__ */ jsx(
155
+ "button",
156
+ {
157
+ type: "button",
158
+ onClick: handleClick,
159
+ disabled: busy,
160
+ className: "w-full rounded-md border border-border px-4 py-2 text-sm font-semibold text-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-60",
161
+ children: busy ? "Starting\u2026" : `Buy ${pack.name}`
162
+ }
163
+ ),
164
+ error && /* @__PURE__ */ jsx("p", { role: "alert", className: "text-xs text-destructive", children: error })
165
+ ]
166
+ }
167
+ );
168
+ }
169
+ function PackGrid({
170
+ packs,
171
+ onBuyPack
172
+ }) {
173
+ if (!packs.length) return null;
174
+ const maxCredits = Math.max(...packs.map((p) => p.credits));
175
+ return /* @__PURE__ */ jsx("div", { "data-testid": "pricing-packs", className: "mt-10 grid gap-4 md:grid-cols-2 md:gap-6", children: packs.map((pack) => /* @__PURE__ */ jsx(
176
+ PackCard,
177
+ {
178
+ pack,
179
+ best: packs.length > 1 && pack.credits === maxCredits,
180
+ onBuyPack
181
+ },
182
+ pack.id
183
+ )) });
184
+ }
185
+ function PricingTable({
186
+ catalog,
187
+ variant = "grid",
188
+ bandPlanId,
189
+ showPacks,
190
+ currentPlanId,
191
+ presentation,
192
+ onSelectPlan,
193
+ onBuyPack,
194
+ className
195
+ }) {
196
+ const plans = catalog.plans ?? [];
197
+ const packs = catalog.packs ?? [];
198
+ const shouldShowPacks = showPacks ?? packs.length > 0;
199
+ const bandPlan = useMemo(() => {
200
+ if (variant !== "band") return null;
201
+ return plans.find((p) => p.id === bandPlanId) ?? plans.find((p) => p.id === currentPlanId) ?? plans.find((p) => p.popular) ?? plans[0] ?? null;
202
+ }, [variant, catalog.plans, bandPlanId, currentPlanId]);
203
+ return /* @__PURE__ */ jsxs("div", { className, "data-testid": "pricing-table", "data-variant": variant, children: [
204
+ variant === "grid" ? /* @__PURE__ */ jsx("div", { className: "grid gap-6 md:grid-cols-3", children: plans.map((plan) => /* @__PURE__ */ jsx(
205
+ PlanCard,
206
+ {
207
+ plan,
208
+ currentPlanId,
209
+ presentation: presentation?.[plan.id],
210
+ onSelectPlan
211
+ },
212
+ plan.id
213
+ )) }) : bandPlan ? /* @__PURE__ */ jsx("div", { className: "mx-auto max-w-md", children: /* @__PURE__ */ jsx(
214
+ PlanCard,
215
+ {
216
+ plan: bandPlan,
217
+ currentPlanId,
218
+ presentation: presentation?.[bandPlan.id],
219
+ onSelectPlan
220
+ }
221
+ ) }) : null,
222
+ shouldShowPacks && /* @__PURE__ */ jsx(PackGrid, { packs, onBuyPack })
223
+ ] });
224
+ }
225
+
226
+ // src/UpgradePaywall.tsx
227
+ import { useMemo as useMemo2, useState as useState2 } from "react";
228
+ import * as Dialog from "@radix-ui/react-dialog";
229
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
230
+ function humanizeFeatureKey(key) {
231
+ return key.replace(/_max$/, "").replace(/_/g, " ");
232
+ }
233
+ function PaywallBody({
234
+ featureKey,
235
+ catalog,
236
+ currentPlanId,
237
+ onUpgrade,
238
+ upgradeOptions = ["plan"],
239
+ onAddon,
240
+ addonLabel = "Add capacity",
241
+ secondaryAction,
242
+ title,
243
+ description,
244
+ onClose,
245
+ DescriptionTag = "p"
246
+ }) {
247
+ const [busy, setBusy] = useState2(null);
248
+ const [error, setError] = useState2(null);
249
+ const currentPlan = useMemo2(
250
+ () => catalog.plans.find((p) => p.id === currentPlanId) ?? null,
251
+ [catalog.plans, currentPlanId]
252
+ );
253
+ const alreadyUnlocked = isFeatureUnlocked(currentPlan, featureKey);
254
+ const targetPlan = useMemo2(
255
+ () => resolveUnlockingPlan(catalog.plans, featureKey),
256
+ [catalog.plans, featureKey]
257
+ );
258
+ const actionPlan = targetPlan ?? catalog.plans[catalog.plans.length - 1] ?? null;
259
+ const handleUpgrade = async () => {
260
+ if (!onUpgrade || !actionPlan) return;
261
+ setBusy("plan");
262
+ setError(null);
263
+ try {
264
+ await onUpgrade(actionPlan);
265
+ } catch (e) {
266
+ setError(e instanceof Error ? e.message : "Could not start checkout.");
267
+ } finally {
268
+ setBusy(null);
269
+ }
270
+ };
271
+ const handleAddon = async () => {
272
+ if (!onAddon) return;
273
+ setBusy("addon");
274
+ setError(null);
275
+ try {
276
+ await onAddon();
277
+ } catch (e) {
278
+ setError(e instanceof Error ? e.message : "Could not start checkout.");
279
+ } finally {
280
+ setBusy(null);
281
+ }
282
+ };
283
+ const resolvedTitle = title ?? (alreadyUnlocked ? "You already have this" : targetPlan ? `Upgrade to ${targetPlan.name} to unlock this` : "Upgrade to unlock this");
284
+ const resolvedDescription = description ?? (alreadyUnlocked ? `Your current plan already includes ${humanizeFeatureKey(featureKey)}.` : targetPlan ? `${targetPlan.name} unlocks ${humanizeFeatureKey(featureKey)} for ${formatPrice(targetPlan.price_cents, targetPlan.currency)}/${targetPlan.interval}.` : `No plan in your current catalog unlocks ${humanizeFeatureKey(featureKey)} yet \u2014 see all plans to find the right fit.`);
285
+ return /* @__PURE__ */ jsxs2("div", { "data-testid": "upgrade-paywall-body", className: "flex flex-col gap-1", children: [
286
+ /* @__PURE__ */ jsx2("h2", { className: "text-lg font-semibold text-foreground", children: resolvedTitle }),
287
+ /* @__PURE__ */ jsx2(DescriptionTag, { className: "text-sm text-muted-foreground", children: resolvedDescription }),
288
+ /* @__PURE__ */ jsxs2("div", { className: "mt-5 flex flex-col gap-2", children: [
289
+ !alreadyUnlocked && upgradeOptions.includes("plan") && actionPlan && /* @__PURE__ */ jsx2(
290
+ "button",
291
+ {
292
+ type: "button",
293
+ onClick: handleUpgrade,
294
+ disabled: busy !== null,
295
+ className: "inline-flex items-center justify-center gap-2 rounded-md bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-60",
296
+ children: busy === "plan" ? "Starting\u2026" : `Upgrade to ${actionPlan.name} \xB7 ${formatPrice(actionPlan.price_cents, actionPlan.currency)}/${actionPlan.interval}`
297
+ }
298
+ ),
299
+ !alreadyUnlocked && upgradeOptions.includes("addon") && /* @__PURE__ */ jsx2(
300
+ "button",
301
+ {
302
+ type: "button",
303
+ onClick: handleAddon,
304
+ disabled: busy !== null,
305
+ className: "inline-flex items-center justify-center gap-2 rounded-md border border-border px-5 py-2.5 text-sm font-medium text-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-60",
306
+ children: busy === "addon" ? "Starting\u2026" : addonLabel
307
+ }
308
+ ),
309
+ secondaryAction && /* @__PURE__ */ jsx2(
310
+ "button",
311
+ {
312
+ type: "button",
313
+ onClick: secondaryAction.onClick,
314
+ disabled: secondaryAction.disabled,
315
+ className: "inline-flex items-center justify-center gap-2 rounded-md border border-border/70 bg-transparent px-5 py-2.5 text-sm font-medium text-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-60",
316
+ children: secondaryAction.label
317
+ }
318
+ ),
319
+ alreadyUnlocked && onClose && /* @__PURE__ */ jsx2(
320
+ "button",
321
+ {
322
+ type: "button",
323
+ onClick: onClose,
324
+ className: "inline-flex items-center justify-center gap-2 rounded-md border border-border px-5 py-2.5 text-sm font-medium text-foreground",
325
+ children: "Close"
326
+ }
327
+ )
328
+ ] }),
329
+ error && /* @__PURE__ */ jsx2("p", { role: "alert", className: "mt-3 text-xs text-destructive", children: error })
330
+ ] });
331
+ }
332
+ function UpgradePaywall(props) {
333
+ const { variant = "modal", open = true, onClose, className, ...bodyProps } = props;
334
+ if (variant === "inline") {
335
+ return /* @__PURE__ */ jsx2(
336
+ "div",
337
+ {
338
+ "data-testid": "upgrade-paywall-inline",
339
+ className: "rounded-2xl border border-border bg-card p-6 " + (className ?? ""),
340
+ children: /* @__PURE__ */ jsx2(PaywallBody, { ...bodyProps, onClose })
341
+ }
342
+ );
343
+ }
344
+ return /* @__PURE__ */ jsx2(Dialog.Root, { open, onOpenChange: (next) => {
345
+ if (!next) onClose?.();
346
+ }, children: /* @__PURE__ */ jsxs2(Dialog.Portal, { children: [
347
+ /* @__PURE__ */ jsx2(Dialog.Overlay, { className: "fixed inset-0 z-50 bg-black/60 backdrop-blur-sm" }),
348
+ /* @__PURE__ */ jsxs2(
349
+ Dialog.Content,
350
+ {
351
+ className: "fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-card p-7 shadow-2xl " + (className ?? ""),
352
+ children: [
353
+ /* @__PURE__ */ jsx2(Dialog.Title, { className: "sr-only", children: bodyProps.title ?? "Upgrade" }),
354
+ /* @__PURE__ */ jsx2(
355
+ Dialog.Close,
356
+ {
357
+ "aria-label": "Close",
358
+ className: "absolute right-4 top-4 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
359
+ children: "\u2715"
360
+ }
361
+ ),
362
+ /* @__PURE__ */ jsx2(PaywallBody, { ...bodyProps, onClose, DescriptionTag: Dialog.Description })
363
+ ]
364
+ }
365
+ )
366
+ ] }) });
367
+ }
368
+
369
+ // src/AutoCheckout.tsx
370
+ import { useEffect, useRef } from "react";
371
+ function AutoCheckout({
372
+ packs = [],
373
+ plans = [],
374
+ onBuyPack,
375
+ onSelectPlan,
376
+ packParam = "credits",
377
+ planParam = "plan"
378
+ }) {
379
+ const firedRef = useRef(false);
380
+ useEffect(() => {
381
+ if (firedRef.current) return;
382
+ if (typeof window === "undefined") return;
383
+ const search = new URLSearchParams(window.location.search);
384
+ const packId = search.get(packParam);
385
+ const planId = search.get(planParam);
386
+ if (packId && onBuyPack && packs.some((p) => p.id === packId)) {
387
+ firedRef.current = true;
388
+ const pack = packs.find((p) => p.id === packId);
389
+ Promise.resolve(onBuyPack(pack)).catch(() => {
390
+ });
391
+ } else if (planId && onSelectPlan && plans.some((p) => p.id === planId)) {
392
+ firedRef.current = true;
393
+ const plan = plans.find((p) => p.id === planId);
394
+ Promise.resolve(onSelectPlan(plan)).catch(() => {
395
+ });
396
+ }
397
+ if (firedRef.current) {
398
+ search.delete(packParam);
399
+ search.delete(planParam);
400
+ const qs = search.toString();
401
+ const nextUrl = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
402
+ window.history.replaceState(window.history.state, "", nextUrl);
403
+ }
404
+ }, [packs, plans, onBuyPack, onSelectPlan]);
405
+ return null;
406
+ }
407
+
408
+ // src/PlanCard.tsx
409
+ import { useState as useState3 } from "react";
410
+ import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
411
+ function StatusPill({ status }) {
412
+ const dotColor = status === "active" || status === "trialing" ? "bg-emerald-500" : status === "past_due" ? "bg-amber-500" : status === "canceled" || status === "unpaid" ? "bg-destructive" : "bg-muted-foreground/60";
413
+ return /* @__PURE__ */ jsxs3("span", { "data-testid": "plan-card-status", className: "inline-flex items-center gap-2", children: [
414
+ /* @__PURE__ */ jsx3("span", { "aria-hidden": true, className: `h-2 w-2 rounded-full ${dotColor}` }),
415
+ /* @__PURE__ */ jsx3("span", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground", children: status })
416
+ ] });
417
+ }
418
+ function PlanCard2({
419
+ plan,
420
+ renewalDate,
421
+ status,
422
+ isFreePlan = false,
423
+ onManage,
424
+ manageLabel = "Manage billing",
425
+ onChangePlan,
426
+ changePlanLabel,
427
+ className
428
+ }) {
429
+ const [busy, setBusy] = useState3(null);
430
+ const [error, setError] = useState3(null);
431
+ const runAction = async (kind, action) => {
432
+ setBusy(kind);
433
+ setError(null);
434
+ try {
435
+ await action();
436
+ } catch (e) {
437
+ setError(e instanceof Error ? e.message : "Could not complete that action.");
438
+ } finally {
439
+ setBusy(null);
440
+ }
441
+ };
442
+ if (!plan) {
443
+ return /* @__PURE__ */ jsx3(
444
+ "div",
445
+ {
446
+ "data-testid": "plan-card",
447
+ "data-state": "empty",
448
+ className: "rounded-2xl border border-border bg-card p-6 " + (className ?? ""),
449
+ children: /* @__PURE__ */ jsx3("p", { className: "text-sm text-muted-foreground", children: "No plan on file yet." })
450
+ }
451
+ );
452
+ }
453
+ const showStatusPill = status && status !== "free";
454
+ return /* @__PURE__ */ jsxs3("div", { "data-testid": "plan-card", "data-plan-id": plan.id, className: "rounded-2xl border border-border bg-card p-6 " + (className ?? ""), children: [
455
+ /* @__PURE__ */ jsxs3("div", { className: "flex flex-wrap items-start justify-between gap-4", children: [
456
+ /* @__PURE__ */ jsxs3("div", { children: [
457
+ /* @__PURE__ */ jsxs3("div", { className: "flex items-center gap-3", children: [
458
+ /* @__PURE__ */ jsx3("h3", { className: "text-xl font-semibold text-foreground", children: plan.name }),
459
+ showStatusPill && /* @__PURE__ */ jsx3(StatusPill, { status })
460
+ ] }),
461
+ renewalDate && /* @__PURE__ */ jsxs3("p", { "data-testid": "plan-card-renewal", className: "mt-1 text-xs uppercase tracking-wide text-muted-foreground", children: [
462
+ "Renews ",
463
+ new Date(renewalDate).toLocaleDateString()
464
+ ] })
465
+ ] }),
466
+ /* @__PURE__ */ jsxs3("div", { className: "text-right", children: [
467
+ /* @__PURE__ */ jsx3("span", { className: "text-2xl font-semibold text-foreground", children: formatPrice(plan.price_cents, plan.currency) }),
468
+ plan.price_cents > 0 && /* @__PURE__ */ jsxs3("span", { className: "ml-1 text-xs uppercase tracking-wide text-muted-foreground", children: [
469
+ "/",
470
+ plan.interval
471
+ ] })
472
+ ] })
473
+ ] }),
474
+ /* @__PURE__ */ jsx3("div", { className: "mt-5 flex flex-wrap gap-3", children: isFreePlan ? onChangePlan && /* @__PURE__ */ jsx3(
475
+ "button",
476
+ {
477
+ type: "button",
478
+ onClick: () => runAction("change", onChangePlan),
479
+ disabled: busy !== null,
480
+ className: "rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-60",
481
+ children: busy === "change" ? "Loading\u2026" : changePlanLabel ?? "Upgrade"
482
+ }
483
+ ) : /* @__PURE__ */ jsxs3(Fragment, { children: [
484
+ onManage && /* @__PURE__ */ jsx3(
485
+ "button",
486
+ {
487
+ type: "button",
488
+ onClick: () => runAction("manage", onManage),
489
+ disabled: busy !== null,
490
+ className: "rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-60",
491
+ children: busy === "manage" ? "Loading\u2026" : manageLabel
492
+ }
493
+ ),
494
+ onChangePlan && /* @__PURE__ */ jsx3(
495
+ "button",
496
+ {
497
+ type: "button",
498
+ onClick: () => runAction("change", onChangePlan),
499
+ disabled: busy !== null,
500
+ className: "rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-60",
501
+ children: busy === "change" ? "Loading\u2026" : changePlanLabel ?? "Change plan"
502
+ }
503
+ )
504
+ ] }) }),
505
+ error && /* @__PURE__ */ jsx3("p", { role: "alert", className: "mt-2 text-xs text-destructive", children: error })
506
+ ] });
507
+ }
508
+
509
+ // src/CreditsMeter.tsx
510
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
511
+ function daysUntil(iso) {
512
+ const ms = new Date(iso).getTime() - Date.now();
513
+ return Math.ceil(ms / (1e3 * 60 * 60 * 24));
514
+ }
515
+ function ExpiryNote({ expireAt }) {
516
+ const days = daysUntil(expireAt);
517
+ const label = days < 0 ? "Base credits expired" : days === 0 ? "Base credits expire today" : `Base credits expire in ${days} day${days === 1 ? "" : "s"}`;
518
+ const tone = days <= 0 ? "text-destructive" : days <= 7 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground";
519
+ return /* @__PURE__ */ jsxs4("p", { "data-testid": "credits-meter-expiry", className: `mt-2 text-xs ${tone}`, children: [
520
+ label,
521
+ " (",
522
+ new Date(expireAt).toLocaleDateString(),
523
+ ")."
524
+ ] });
525
+ }
526
+ function CreditsMeter({ view, balance, className }) {
527
+ if (view.mode === "unavailable") {
528
+ return /* @__PURE__ */ jsx4("div", { "data-testid": "credits-meter", "data-mode": "unavailable", className, children: /* @__PURE__ */ jsxs4("p", { className: "text-sm text-muted-foreground", children: [
529
+ "Usage data isn't available yet",
530
+ view.reason ? ` (${view.reason})` : "",
531
+ "."
532
+ ] }) });
533
+ }
534
+ if (view.mode === "percentage") {
535
+ const pct = Math.max(0, Math.min(100, view.percentage_used ?? 0));
536
+ return /* @__PURE__ */ jsxs4("div", { "data-testid": "credits-meter", "data-mode": "percentage", className, children: [
537
+ /* @__PURE__ */ jsxs4("div", { className: "flex items-baseline justify-between gap-3", children: [
538
+ /* @__PURE__ */ jsx4("span", { className: "text-sm font-medium text-foreground", children: "Usage this period" }),
539
+ /* @__PURE__ */ jsxs4("span", { "data-testid": "credits-meter-percentage-value", className: "text-sm text-muted-foreground", children: [
540
+ Math.round(pct),
541
+ "%"
542
+ ] })
543
+ ] }),
544
+ /* @__PURE__ */ jsx4("div", { className: "mt-2 h-2 overflow-hidden rounded-full bg-muted", children: /* @__PURE__ */ jsx4(
545
+ "div",
546
+ {
547
+ className: "h-full rounded-full bg-primary transition-all",
548
+ style: { width: `${pct}%` }
549
+ }
550
+ ) }),
551
+ view.reset_at && /* @__PURE__ */ jsxs4("p", { "data-testid": "credits-meter-reset", className: "mt-2 text-xs text-muted-foreground", children: [
552
+ "Resets ",
553
+ new Date(view.reset_at).toLocaleDateString()
554
+ ] })
555
+ ] });
556
+ }
557
+ const total = balance?.total ?? view.balance ?? 0;
558
+ const expiringSoon = balance?.expiring_soon ?? view.expiring_soon;
559
+ const expireAt = balance?.base_credits_expire_at;
560
+ return /* @__PURE__ */ jsxs4("div", { "data-testid": "credits-meter", "data-mode": "balance", className, children: [
561
+ /* @__PURE__ */ jsxs4("div", { className: "flex flex-wrap items-baseline gap-2", children: [
562
+ /* @__PURE__ */ jsx4("span", { "data-testid": "credits-meter-total", className: "text-3xl font-semibold text-foreground", children: formatCredits(total) }),
563
+ /* @__PURE__ */ jsx4("span", { className: "text-xs uppercase tracking-wide text-muted-foreground", children: "credits available" }),
564
+ balance?.byok_available && /* @__PURE__ */ jsx4(
565
+ "span",
566
+ {
567
+ "data-testid": "credits-meter-byok-badge",
568
+ className: "rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary",
569
+ children: "BYOK"
570
+ }
571
+ )
572
+ ] }),
573
+ balance && /* @__PURE__ */ jsxs4("dl", { className: "mt-3 grid grid-cols-2 gap-x-6 gap-y-1 text-sm", children: [
574
+ /* @__PURE__ */ jsxs4("div", { children: [
575
+ /* @__PURE__ */ jsx4("dt", { className: "text-xs uppercase tracking-wide text-muted-foreground", children: "Plan credits" }),
576
+ /* @__PURE__ */ jsx4("dd", { "data-testid": "credits-meter-base", className: "text-foreground", children: formatCredits(balance.base_credits) })
577
+ ] }),
578
+ /* @__PURE__ */ jsxs4("div", { children: [
579
+ /* @__PURE__ */ jsx4("dt", { className: "text-xs uppercase tracking-wide text-muted-foreground", children: "Top-up credits" }),
580
+ /* @__PURE__ */ jsx4("dd", { "data-testid": "credits-meter-topup", className: "text-foreground", children: formatCredits(balance.top_up_credits) })
581
+ ] })
582
+ ] }),
583
+ typeof expiringSoon === "number" && expiringSoon > 0 && /* @__PURE__ */ jsxs4("p", { "data-testid": "credits-meter-expiring-soon", className: "mt-2 text-xs text-amber-600 dark:text-amber-400", children: [
584
+ formatCredits(expiringSoon),
585
+ " credits expiring soon."
586
+ ] }),
587
+ expireAt && /* @__PURE__ */ jsx4(ExpiryNote, { expireAt })
588
+ ] });
589
+ }
590
+
591
+ // src/BuyMorePacks.tsx
592
+ import { useEffect as useEffect2, useRef as useRef2, useState as useState4 } from "react";
593
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
594
+ var REPOLL_DELAYS_MS = [0, 2e3, 5e3];
595
+ function PackButton({
596
+ pack,
597
+ compact,
598
+ onPurchase
599
+ }) {
600
+ const [busy, setBusy] = useState4(false);
601
+ const [error, setError] = useState4(null);
602
+ const handleClick = async () => {
603
+ setBusy(true);
604
+ setError(null);
605
+ try {
606
+ await onPurchase(pack);
607
+ } catch (e) {
608
+ setError(e instanceof Error ? e.message : "Could not start checkout.");
609
+ } finally {
610
+ setBusy(false);
611
+ }
612
+ };
613
+ if (compact) {
614
+ return /* @__PURE__ */ jsxs5("div", { className: "inline-flex flex-col items-start", children: [
615
+ /* @__PURE__ */ jsx5(
616
+ "button",
617
+ {
618
+ type: "button",
619
+ "data-testid": `buy-more-packs-chip-${pack.id}`,
620
+ onClick: handleClick,
621
+ disabled: busy,
622
+ className: "rounded-full border border-border px-4 py-1.5 text-sm font-medium text-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-60",
623
+ children: busy ? "Starting\u2026" : `${formatCredits(pack.credits)} \xB7 ${formatPrice(pack.price_cents, pack.currency)}`
624
+ }
625
+ ),
626
+ error && /* @__PURE__ */ jsx5("p", { role: "alert", className: "mt-1 text-xs text-destructive", children: error })
627
+ ] });
628
+ }
629
+ const perCredit = pack.credits > 0 ? `$${(dollarsFromCents(pack.price_cents) / pack.credits).toFixed(3)}` : null;
630
+ return /* @__PURE__ */ jsxs5(
631
+ "div",
632
+ {
633
+ "data-testid": `buy-more-packs-card-${pack.id}`,
634
+ className: "flex flex-col gap-3 rounded-2xl border border-border bg-card p-5",
635
+ children: [
636
+ /* @__PURE__ */ jsxs5("div", { className: "flex items-start justify-between gap-4", children: [
637
+ /* @__PURE__ */ jsxs5("div", { children: [
638
+ /* @__PURE__ */ jsx5("h4", { className: "text-base font-semibold text-foreground", children: pack.name }),
639
+ /* @__PURE__ */ jsxs5("p", { className: "mt-1 text-sm text-muted-foreground", children: [
640
+ formatCredits(pack.credits),
641
+ " credits"
642
+ ] })
643
+ ] }),
644
+ /* @__PURE__ */ jsxs5("div", { className: "text-right", children: [
645
+ /* @__PURE__ */ jsx5("div", { className: "text-lg font-bold text-foreground", children: formatPrice(pack.price_cents, pack.currency) }),
646
+ perCredit && /* @__PURE__ */ jsxs5("div", { className: "text-[10px] uppercase tracking-wide text-muted-foreground", children: [
647
+ perCredit,
648
+ " / credit"
649
+ ] })
650
+ ] })
651
+ ] }),
652
+ /* @__PURE__ */ jsx5(
653
+ "button",
654
+ {
655
+ type: "button",
656
+ onClick: handleClick,
657
+ disabled: busy,
658
+ className: "w-full rounded-md border border-border px-4 py-2 text-sm font-semibold text-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-60",
659
+ children: busy ? "Starting\u2026" : `Buy ${pack.name}`
660
+ }
661
+ ),
662
+ error && /* @__PURE__ */ jsx5("p", { role: "alert", className: "text-xs text-destructive", children: error })
663
+ ]
664
+ }
665
+ );
666
+ }
667
+ function BuyMorePacks({
668
+ packs,
669
+ purchaseModel = "fixed-pack",
670
+ onPurchase,
671
+ successParam = "topup",
672
+ onTopupSuccess,
673
+ className
674
+ }) {
675
+ const firedRef = useRef2(false);
676
+ useEffect2(() => {
677
+ if (firedRef.current) return;
678
+ if (typeof window === "undefined") return;
679
+ const search = new URLSearchParams(window.location.search);
680
+ if (search.get(successParam) !== "success") return;
681
+ firedRef.current = true;
682
+ search.delete(successParam);
683
+ const qs = search.toString();
684
+ const nextUrl = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
685
+ window.history.replaceState(window.history.state, "", nextUrl);
686
+ if (!onTopupSuccess) return;
687
+ const timers = REPOLL_DELAYS_MS.map(
688
+ (delay) => setTimeout(() => {
689
+ Promise.resolve(onTopupSuccess()).catch(() => {
690
+ });
691
+ }, delay)
692
+ );
693
+ return () => timers.forEach(clearTimeout);
694
+ }, [successParam, onTopupSuccess]);
695
+ if (!packs.length) return null;
696
+ return /* @__PURE__ */ jsx5("div", { "data-testid": "buy-more-packs", "data-purchase-model": purchaseModel, className, children: purchaseModel === "presets" ? /* @__PURE__ */ jsx5("div", { className: "flex flex-wrap gap-3", children: packs.map((pack) => /* @__PURE__ */ jsx5(PackButton, { pack, compact: true, onPurchase }, pack.id)) }) : /* @__PURE__ */ jsx5("div", { className: "grid gap-4 md:grid-cols-2", children: packs.map((pack) => /* @__PURE__ */ jsx5(PackButton, { pack, compact: false, onPurchase }, pack.id)) }) });
697
+ }
698
+
699
+ // src/LowBalanceBanner.tsx
700
+ import { useEffect as useEffect3, useState as useState5 } from "react";
701
+ import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
702
+ function LowBalanceBanner({
703
+ balance,
704
+ burnRate,
705
+ threshold = 5,
706
+ variant = "default",
707
+ onAddCredits,
708
+ addCreditsLabel = "Add credits",
709
+ onAddByok,
710
+ addByokLabel = "Add a BYOK API key",
711
+ className
712
+ }) {
713
+ const [dismissed, setDismissed] = useState5(false);
714
+ useEffect3(() => {
715
+ if (balance.total <= 0 && dismissed) setDismissed(false);
716
+ }, [balance.total, dismissed]);
717
+ if (balance.byok_available) return null;
718
+ if (dismissed) return null;
719
+ const isEmpty = balance.total <= 0;
720
+ const isLow = balance.total > 0 && balance.total < threshold;
721
+ const isNearBurnout = !isEmpty && !isLow && burnRate != null && burnRate > 0 && balance.total / burnRate <= 1;
722
+ if (!isEmpty && !isLow && !isNearBurnout) return null;
723
+ const compactCls = variant === "compact" ? "text-xs" : "";
724
+ if (isEmpty) {
725
+ return /* @__PURE__ */ jsxs6(
726
+ "div",
727
+ {
728
+ "data-testid": "low-balance-banner",
729
+ "data-state": "empty",
730
+ className: `flex items-center gap-3 rounded-lg border border-primary/20 bg-primary/10 px-4 py-3 ${compactCls} ${className ?? ""}`,
731
+ children: [
732
+ /* @__PURE__ */ jsxs6("div", { className: "min-w-0 flex-1", children: [
733
+ /* @__PURE__ */ jsx6("p", { className: `font-medium text-primary ${variant === "compact" ? "text-xs" : "text-sm"}`, children: "Out of credits." }),
734
+ /* @__PURE__ */ jsxs6("p", { className: `text-primary/80 ${variant === "compact" ? "text-xs" : "text-sm"}`, children: [
735
+ onAddByok && /* @__PURE__ */ jsxs6(Fragment2, { children: [
736
+ /* @__PURE__ */ jsx6("button", { type: "button", onClick: onAddByok, className: "font-medium underline hover:text-primary", children: addByokLabel }),
737
+ " ",
738
+ "or",
739
+ " "
740
+ ] }),
741
+ onAddCredits && /* @__PURE__ */ jsx6("button", { type: "button", onClick: onAddCredits, className: "font-medium underline hover:text-primary", children: addCreditsLabel })
742
+ ] })
743
+ ] }),
744
+ onAddCredits && /* @__PURE__ */ jsx6(
745
+ "button",
746
+ {
747
+ type: "button",
748
+ onClick: onAddCredits,
749
+ className: "inline-flex flex-shrink-0 items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90",
750
+ children: addCreditsLabel
751
+ }
752
+ )
753
+ ]
754
+ }
755
+ );
756
+ }
757
+ return /* @__PURE__ */ jsxs6(
758
+ "div",
759
+ {
760
+ "data-testid": "low-balance-banner",
761
+ "data-state": "low",
762
+ className: `flex items-center gap-3 rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-3 ${compactCls} ${className ?? ""}`,
763
+ children: [
764
+ /* @__PURE__ */ jsx6("div", { className: "min-w-0 flex-1", children: /* @__PURE__ */ jsxs6("p", { className: `text-amber-700 dark:text-amber-400 ${variant === "compact" ? "text-xs" : "text-sm"}`, children: [
765
+ "Low credit balance (",
766
+ formatCredits(balance.total),
767
+ " remaining).",
768
+ " ",
769
+ onAddCredits && /* @__PURE__ */ jsx6("button", { type: "button", onClick: onAddCredits, className: "font-medium underline hover:text-amber-800 dark:hover:text-amber-300", children: addCreditsLabel })
770
+ ] }) }),
771
+ /* @__PURE__ */ jsx6(
772
+ "button",
773
+ {
774
+ type: "button",
775
+ onClick: () => setDismissed(true),
776
+ "aria-label": "Dismiss",
777
+ className: "flex-shrink-0 text-amber-500/50 transition-colors hover:text-amber-500",
778
+ children: "\u2715"
779
+ }
780
+ )
781
+ ]
782
+ }
783
+ );
784
+ }
785
+
786
+ // src/BillingSettingsPanel.tsx
787
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
788
+ function BillingSettingsPanel({
789
+ plan,
790
+ renewalDate,
791
+ planStatus,
792
+ isFreePlan = false,
793
+ onManagePlan,
794
+ manageLabel,
795
+ onChangePlan,
796
+ changePlanLabel,
797
+ creditsView,
798
+ creditsBalance,
799
+ showLowBalanceBanner = true,
800
+ lowBalanceThreshold,
801
+ burnRate,
802
+ onAddCredits,
803
+ addCreditsLabel,
804
+ onAddByok,
805
+ packs = [],
806
+ purchaseModel = "fixed-pack",
807
+ onBuyPack,
808
+ topupSuccessParam,
809
+ onTopupSuccess,
810
+ catalog,
811
+ showUpgradeSection,
812
+ upgradeVariant,
813
+ upgradePresentation,
814
+ onSelectPlan,
815
+ historySlot,
816
+ className
817
+ }) {
818
+ const renderUpgrade = showUpgradeSection ?? Boolean(catalog);
819
+ return /* @__PURE__ */ jsxs7("div", { "data-testid": "billing-settings-panel", className, children: [
820
+ /* @__PURE__ */ jsx7("section", { "data-testid": "billing-settings-panel-plan", className: "flex flex-col gap-3", children: /* @__PURE__ */ jsx7(
821
+ PlanCard2,
822
+ {
823
+ plan,
824
+ renewalDate,
825
+ status: planStatus,
826
+ isFreePlan,
827
+ onManage: onManagePlan,
828
+ manageLabel,
829
+ onChangePlan,
830
+ changePlanLabel
831
+ }
832
+ ) }),
833
+ /* @__PURE__ */ jsxs7("section", { "data-testid": "billing-settings-panel-credits", className: "mt-8 flex flex-col gap-3", children: [
834
+ showLowBalanceBanner && creditsBalance && /* @__PURE__ */ jsx7(
835
+ LowBalanceBanner,
836
+ {
837
+ balance: creditsBalance,
838
+ threshold: lowBalanceThreshold,
839
+ burnRate,
840
+ onAddCredits,
841
+ addCreditsLabel,
842
+ onAddByok
843
+ }
844
+ ),
845
+ /* @__PURE__ */ jsx7(CreditsMeter, { view: creditsView, balance: creditsBalance })
846
+ ] }),
847
+ packs.length > 0 && onBuyPack && /* @__PURE__ */ jsx7("section", { "data-testid": "billing-settings-panel-buy-more", className: "mt-8", children: /* @__PURE__ */ jsx7(
848
+ BuyMorePacks,
849
+ {
850
+ packs,
851
+ purchaseModel,
852
+ onPurchase: onBuyPack,
853
+ successParam: topupSuccessParam,
854
+ onTopupSuccess
855
+ }
856
+ ) }),
857
+ renderUpgrade && catalog && /* @__PURE__ */ jsx7("section", { "data-testid": "billing-settings-panel-upgrade", className: "mt-8", children: /* @__PURE__ */ jsx7(
858
+ PricingTable,
859
+ {
860
+ catalog,
861
+ variant: upgradeVariant,
862
+ currentPlanId: plan?.id ?? null,
863
+ presentation: upgradePresentation,
864
+ onSelectPlan,
865
+ showPacks: false
866
+ }
867
+ ) }),
868
+ historySlot && /* @__PURE__ */ jsx7("section", { "data-testid": "billing-settings-panel-history", className: "mt-8", children: historySlot })
869
+ ] });
870
+ }
871
+ export {
872
+ AutoCheckout,
873
+ BillingSettingsPanel,
874
+ BuyMorePacks,
875
+ CreditsMeter,
876
+ LowBalanceBanner,
877
+ PlanCard2 as PlanCard,
878
+ PricingTable,
879
+ UpgradePaywall,
880
+ dollarsFromCents,
881
+ formatCredits,
882
+ formatPrice,
883
+ isFeatureUnlocked,
884
+ isUnlimited,
885
+ resolveUnlockingPlan
886
+ };