@zuplo/zudoku-plugin-monetization 0.0.40 → 0.0.42

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 CHANGED
@@ -1,15 +1,15 @@
1
- import { _ as formatDuration, a as subscriptionTaxLegendSentence, c as getPriceFromPlan, f as categorizeRateCards, g as formatPrice, h as formatMinorCurrencyAmount, i as planHasDefaultTaxBehavior, l as PlanEntitlements, m as formatStaticEntitlementConfig, p as formatTieredPriceBreakdown, t as PricingTable, v as formatDurationAdjective, y as formatDurationInterval } from "./PricingTable-DNop2iX9.mjs";
1
+ import { S as formatDurationInterval, a as planHasDefaultTaxBehavior, b as formatDuration, c as formatPlanPrice, d as PlanEntitlements, f as EntitlementList, h as categorizeRateCards, o as subscriptionTaxLegendSentence, r as isCustomPlan, t as PricingTable, u as PlanPriceTag, v as formatMinorCurrencyAmount, x as formatDurationAdjective, y as formatPrice } from "./PricingTable-BlcXx4-5.mjs";
2
2
  import { cn, createPlugin, joinUrl, throwIfProblemJson } from "zudoku";
3
3
  import { AlertTriangleIcon, ArrowDownIcon, ArrowLeftRightIcon, ArrowUpIcon, CalendarIcon, CheckCheckIcon, CheckIcon, CircleSlashIcon, ClockIcon, CreditCardIcon, Grid2x2XIcon, InfoIcon, Loader2Icon, LockIcon, MoreVerticalIcon, RefreshCcw, RefreshCwIcon, Settings, ShieldIcon, StarsIcon, Trash2Icon, XIcon } from "zudoku/icons";
4
- import { Button, ClientOnly, Head, Heading, Link, Slot } from "zudoku/components";
4
+ import { Link, Outlet, useLocation, useNavigate, useSearchParams } from "zudoku/router";
5
5
  import { useAuth, useZudoku } from "zudoku/hooks";
6
6
  import { QueryClient, QueryClientProvider, queryOptions, useMutation, useQuery, useQueryClient, useSuspenseQuery } from "zudoku/react-query";
7
- import { Link as Link$1, Outlet, useLocation, useNavigate, useSearchParams } from "zudoku/router";
7
+ import { Button, ClientOnly, Head, Heading, Link as Link$1, Slot } from "zudoku/components";
8
+ import { createContext, use, useEffect, useMemo, useState } from "react";
9
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
10
  import { Alert, AlertAction, AlertDescription, AlertTitle } from "zudoku/ui/Alert";
9
11
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "zudoku/ui/Card";
10
12
  import { Separator } from "zudoku/ui/Separator";
11
- import { createContext, use, useEffect, useMemo, useState } from "react";
12
- import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
13
13
  import { Button as Button$1 } from "zudoku/ui/Button";
14
14
  import { DismissibleAlert, DismissibleAlertAction } from "zudoku/ui/DismissibleAlert";
15
15
  import { ActionButton } from "zudoku/ui/ActionButton";
@@ -22,6 +22,23 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
22
22
  import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "zudoku/ui/Dialog";
23
23
  import { Input } from "zudoku/ui/Input";
24
24
  import { Progress } from "zudoku/ui/Progress";
25
+ //#region src/utils/purchaseDetails.ts
26
+ const getPlanFromPurchaseDetails = (response) => {
27
+ return response;
28
+ };
29
+ const getTaxAmountFromPurchaseDetails = (response) => {
30
+ const taxAmount = response?.tax?.items[0]?.taxAmount;
31
+ const numericAmount = typeof taxAmount === "number" ? taxAmount : Number.parseFloat(taxAmount ?? "");
32
+ if (!Number.isFinite(numericAmount)) return;
33
+ return numericAmount;
34
+ };
35
+ const getTaxLabelFromPurchaseDetails = (response) => {
36
+ return (response.tax?.taxes ?? []).some((tax) => tax.taxType?.toLowerCase() === "vat") ? "VAT" : "tax";
37
+ };
38
+ const isTaxInclusiveFromPurchaseDetails = (response) => {
39
+ return response.tax?.taxInclusive === true;
40
+ };
41
+ //#endregion
25
42
  //#region src/hooks/useDeploymentName.ts
26
43
  const useDeploymentName = () => {
27
44
  const deploymentName = useZudoku().env.ZUPLO_PUBLIC_DEPLOYMENT_NAME;
@@ -38,36 +55,26 @@ const usePurchaseDetails = (planId) => {
38
55
  });
39
56
  };
40
57
  //#endregion
58
+ //#region src/hooks/usePurchaseSummary.ts
59
+ /**
60
+ * Resolve a plan's checkout summary — the selected plan plus its tax preview —
61
+ * from the purchase-details endpoint. Shared by the checkout and plan-change
62
+ * confirmation pages so both read the plan and tax the same way.
63
+ */
64
+ const usePurchaseSummary = (planId) => {
65
+ const { data } = usePurchaseDetails(planId);
66
+ return {
67
+ selectedPlan: getPlanFromPurchaseDetails(data),
68
+ taxAmount: getTaxAmountFromPurchaseDetails(data),
69
+ taxLabel: getTaxLabelFromPurchaseDetails(data),
70
+ taxInclusive: isTaxInclusiveFromPurchaseDetails(data)
71
+ };
72
+ };
73
+ //#endregion
41
74
  //#region src/MonetizationContext.tsx
42
75
  const MonetizationContext = createContext({});
43
76
  const useMonetizationConfig = () => use(MonetizationContext);
44
77
  //#endregion
45
- //#region src/utils/formatBillingCycle.ts
46
- const formatBillingCycle = (duration) => {
47
- if (duration === "month") return "monthly";
48
- if (duration === "year") return "annually";
49
- if (duration === "week") return "weekly";
50
- if (duration === "day") return "daily";
51
- return `every ${duration}`;
52
- };
53
- //#endregion
54
- //#region src/utils/purchaseDetails.ts
55
- const getPlanFromPurchaseDetails = (response) => {
56
- return response;
57
- };
58
- const getTaxAmountFromPurchaseDetails = (response) => {
59
- const taxAmount = response?.tax?.items[0]?.taxAmount;
60
- const numericAmount = typeof taxAmount === "number" ? taxAmount : Number.parseFloat(taxAmount ?? "");
61
- if (!Number.isFinite(numericAmount)) return;
62
- return numericAmount;
63
- };
64
- const getTaxLabelFromPurchaseDetails = (response) => {
65
- return (response.tax?.taxes ?? []).some((tax) => tax.taxType?.toLowerCase() === "vat") ? "VAT" : "tax";
66
- };
67
- const isTaxInclusiveFromPurchaseDetails = (response) => {
68
- return response.tax?.taxInclusive === true;
69
- };
70
- //#endregion
71
78
  //#region src/ZuploMonetizationWrapper.tsx
72
79
  const DEFAULT_GATEWAY_URL = "https://api.zuploedge.com";
73
80
  const getBaseUrl = (context) => {
@@ -139,24 +146,18 @@ const ZuploMonetizationWrapper = ({ options = {} }) => /* @__PURE__ */ jsx(Query
139
146
  })
140
147
  });
141
148
  //#endregion
142
- //#region src/pages/CheckoutConfirmPage.tsx
143
- const CheckoutConfirmPage = () => {
144
- const [search] = useSearchParams();
145
- const planId = search.get("planId");
149
+ //#region src/hooks/useSubscriptionConfirmMutation.ts
150
+ /**
151
+ * Confirm a subscription purchase or plan change: POST `{ planId }` to the
152
+ * metering `endpoint`, invalidate cached queries, then navigate to the resulting
153
+ * subscription. Shared by the checkout and plan-change confirmation pages, which
154
+ * differ only by `endpoint` and the optional navigation `state`.
155
+ */
156
+ const useSubscriptionConfirmMutation = ({ endpoint, planId, navigateState }) => {
146
157
  const zudoku = useZudoku();
147
- const deploymentName = useDeploymentName();
148
158
  const navigate = useNavigate();
149
- const { pricing } = useMonetizationConfig();
150
- if (!planId) throw new Error("Parameter `planId` missing");
151
- const purchaseDetails = usePurchaseDetails(planId);
152
- const selectedPlan = getPlanFromPurchaseDetails(purchaseDetails.data);
153
- const taxAmount = getTaxAmountFromPurchaseDetails(purchaseDetails.data);
154
- const taxLabel = getTaxLabelFromPurchaseDetails(purchaseDetails.data);
155
- const taxInclusive = isTaxInclusiveFromPurchaseDetails(purchaseDetails.data);
156
- const price = selectedPlan ? getPriceFromPlan(selectedPlan) : null;
157
- const billingCycle = selectedPlan?.billingCadence ? formatDuration(selectedPlan.billingCadence) : null;
158
- const createSubscriptionMutation = useMutation({
159
- mutationKey: [`/v3/zudoku-metering/${deploymentName}/subscriptions`],
159
+ return useMutation({
160
+ mutationKey: [`/v3/zudoku-metering/${useDeploymentName()}/${endpoint}`],
160
161
  meta: {
161
162
  context: zudoku,
162
163
  request: {
@@ -166,18 +167,29 @@ const CheckoutConfirmPage = () => {
166
167
  },
167
168
  onSuccess: async (subscription) => {
168
169
  await queryClient.invalidateQueries();
169
- navigate(`/subscriptions?subscriptionId=${encodeURIComponent(subscription.id)}`);
170
+ navigate(`/subscriptions?subscriptionId=${encodeURIComponent(subscription.id)}`, navigateState ? { state: navigateState } : void 0);
170
171
  }
171
172
  });
173
+ };
174
+ //#endregion
175
+ //#region src/pages/components/ConfirmationScreen.tsx
176
+ /**
177
+ * Shared chrome for the checkout and plan-change confirmation pages: the
178
+ * centered card with a check-mark header, title/message, a summary slot
179
+ * (`children`, typically a {@link PlanSummaryCard}), confirm/cancel actions
180
+ * and a terms note. Keeping this in one place means both confirmation flows
181
+ * stay visually and behaviorally consistent.
182
+ */
183
+ const ConfirmationScreen = ({ title, message, errorMessage, children, confirmLabel, pendingLabel, onConfirm, isPending, confirmDisabled = false, cancelTo, cancelLabel = "Cancel", termsNote, footer }) => {
172
184
  return /* @__PURE__ */ jsx("div", {
173
185
  className: "w-full bg-muted min-h-screen flex items-center justify-center px-4 py-12 gap-4",
174
186
  children: /* @__PURE__ */ jsxs("div", {
175
187
  className: "max-w-2xl w-full",
176
188
  children: [
177
- createSubscriptionMutation.isError && /* @__PURE__ */ jsxs(Alert, {
189
+ errorMessage && /* @__PURE__ */ jsxs(Alert, {
178
190
  className: "mb-4",
179
191
  variant: "destructive",
180
- children: [/* @__PURE__ */ jsx(AlertTitle, { children: "Error" }), /* @__PURE__ */ jsx(AlertDescription, { children: createSubscriptionMutation.error.message })]
192
+ children: [/* @__PURE__ */ jsx(AlertTitle, { children: "Error" }), /* @__PURE__ */ jsx(AlertDescription, { children: errorMessage })]
181
193
  }),
182
194
  /* @__PURE__ */ jsxs(Card, {
183
195
  className: "p-8 w-full max-w-7xl",
@@ -193,85 +205,25 @@ const CheckoutConfirmPage = () => {
193
205
  className: "text-center mb-8",
194
206
  children: [/* @__PURE__ */ jsx("h1", {
195
207
  className: "text-2xl font-bold text-card-foreground mb-3",
196
- children: "Review your subscription"
197
- }), /* @__PURE__ */ jsx("p", {
198
- className: "text-muted-foreground text-base",
199
- children: "Please confirm the details below before completing your purchase."
200
- })]
201
- }),
202
- selectedPlan && /* @__PURE__ */ jsxs(Card, {
203
- className: "bg-muted/50",
204
- children: [/* @__PURE__ */ jsx(CardHeader, { children: /* @__PURE__ */ jsxs(CardTitle, {
205
- className: "flex justify-between items-start",
206
- children: [
207
- /* @__PURE__ */ jsxs("div", {
208
- className: "flex items-center gap-3",
209
- children: [/* @__PURE__ */ jsx("div", {
210
- className: "flex flex-col text-2xl font-bold bg-primary text-primary-foreground items-center justify-center rounded size-12",
211
- children: selectedPlan.name.at(0)?.toUpperCase()
212
- }), /* @__PURE__ */ jsxs("div", {
213
- className: "flex flex-col",
214
- children: [/* @__PURE__ */ jsx("span", {
215
- className: "text-lg font-bold",
216
- children: selectedPlan.name
217
- }), /* @__PURE__ */ jsx("span", {
218
- className: "text-sm font-normal text-muted-foreground",
219
- children: selectedPlan.description || "Selected plan"
220
- })]
221
- })]
222
- }),
223
- price && price.monthly > 0 && /* @__PURE__ */ jsxs("div", {
224
- className: "text-right",
225
- children: [
226
- /* @__PURE__ */ jsx("div", {
227
- className: "text-2xl font-bold",
228
- children: formatPrice(price.monthly, selectedPlan?.currency)
229
- }),
230
- taxAmount != null && /* @__PURE__ */ jsx("div", {
231
- className: "text-sm font-normal mt-1",
232
- children: taxInclusive ? `${formatMinorCurrencyAmount(taxAmount, selectedPlan?.currency)} ${taxLabel} included` : `+ ${formatMinorCurrencyAmount(taxAmount, selectedPlan?.currency)} ${taxLabel}`
233
- }),
234
- billingCycle && /* @__PURE__ */ jsxs("div", {
235
- className: "text-sm text-muted-foreground font-normal",
236
- children: ["Billed ", formatBillingCycle(billingCycle)]
237
- })
238
- ]
239
- }),
240
- price && price.monthly === 0 && /* @__PURE__ */ jsx("div", {
241
- className: "text-2xl text-muted-foreground font-bold",
242
- children: "Free"
243
- })
244
- ]
245
- }) }), /* @__PURE__ */ jsxs(CardContent, { children: [
246
- /* @__PURE__ */ jsx(Separator, {}),
247
- /* @__PURE__ */ jsx("div", {
248
- className: "text-sm font-medium mb-3 mt-3",
249
- children: "What's included:"
250
- }),
251
- /* @__PURE__ */ jsx(PlanEntitlements, {
252
- phases: selectedPlan.phases,
253
- currency: selectedPlan.currency,
254
- billingCadence: selectedPlan.billingCadence,
255
- units: pricing?.units,
256
- itemClassName: "text-muted-foreground"
257
- })
258
- ] })]
208
+ children: title
209
+ }), message]
259
210
  }),
211
+ children,
260
212
  /* @__PURE__ */ jsxs("div", {
261
213
  className: "space-y-3 mt-4",
262
214
  children: [/* @__PURE__ */ jsx(Button, {
263
215
  className: "w-full",
264
- onClick: () => createSubscriptionMutation.mutate(),
265
- disabled: createSubscriptionMutation.isPending || !selectedPlan,
266
- children: createSubscriptionMutation.isPending ? "Processing Payment..." : "Confirm & Subscribe"
216
+ onClick: onConfirm,
217
+ disabled: isPending || confirmDisabled,
218
+ children: isPending ? pendingLabel : confirmLabel
267
219
  }), /* @__PURE__ */ jsx(Button, {
268
220
  variant: "ghost",
269
221
  className: "w-full",
270
- disabled: createSubscriptionMutation.isPending,
271
- asChild: !createSubscriptionMutation.isPending,
272
- children: /* @__PURE__ */ jsx(Link$1, {
273
- to: "/pricing",
274
- children: "Cancel"
222
+ disabled: isPending,
223
+ asChild: !isPending,
224
+ children: isPending ? cancelLabel : /* @__PURE__ */ jsx(Link, {
225
+ to: cancelTo,
226
+ children: cancelLabel
275
227
  })
276
228
  })]
277
229
  }),
@@ -279,20 +231,132 @@ const CheckoutConfirmPage = () => {
279
231
  className: "mt-6 pt-6 border-t text-center",
280
232
  children: /* @__PURE__ */ jsx("p", {
281
233
  className: "text-xs text-muted-foreground",
282
- children: "By confirming, you agree to our Terms of Service and Privacy Policy. You can cancel anytime."
234
+ children: termsNote
283
235
  })
284
236
  })
285
237
  ]
286
238
  }),
287
- /* @__PURE__ */ jsxs("div", {
288
- className: "flex items-center gap-2 text-muted-foreground text-xs item-center justify-center pt-4",
289
- children: [/* @__PURE__ */ jsx(LockIcon, { className: "size-3" }), "Your payment is secured by Stripe"]
290
- })
239
+ footer
291
240
  ]
292
241
  })
293
242
  });
294
243
  };
295
244
  //#endregion
245
+ //#region src/utils/formatBillingCycle.ts
246
+ const formatBillingCycle = (duration) => {
247
+ if (duration === "month") return "monthly";
248
+ if (duration === "year") return "annually";
249
+ if (duration === "week") return "weekly";
250
+ if (duration === "day") return "daily";
251
+ return `every ${duration}`;
252
+ };
253
+ //#endregion
254
+ //#region src/pages/components/PlanSummaryCard.tsx
255
+ /**
256
+ * Plan summary shown on the checkout and plan-change confirmation pages: an
257
+ * avatar + name/description on the left and the headline price (or
258
+ * "Free" / "Pay as you go") plus tax and billing cadence on the right,
259
+ * followed by the plan's included entitlements.
260
+ *
261
+ * The price is derived from the plan's rate cards via {@link formatPlanPrice}
262
+ * and rendered in the plan's own billing cadence, so it stays correct for any
263
+ * cadence (e.g. `$2.99/hour`).
264
+ */
265
+ const PlanSummaryCard = ({ plan, descriptionFallback, taxAmount, taxLabel, taxInclusive, units, entitlementsItemClassName }) => {
266
+ const priceLabel = formatPlanPrice(plan);
267
+ const billingCycle = plan.billingCadence ? formatDuration(plan.billingCadence) : null;
268
+ return /* @__PURE__ */ jsxs(Card, {
269
+ className: "bg-muted/50",
270
+ children: [/* @__PURE__ */ jsx(CardHeader, { children: /* @__PURE__ */ jsxs(CardTitle, {
271
+ className: "flex justify-between items-start",
272
+ children: [/* @__PURE__ */ jsxs("div", {
273
+ className: "flex items-center gap-3",
274
+ children: [/* @__PURE__ */ jsx("div", {
275
+ className: "flex flex-col text-2xl font-bold bg-primary text-primary-foreground items-center justify-center rounded size-12",
276
+ children: plan.name.at(0)?.toUpperCase()
277
+ }), /* @__PURE__ */ jsxs("div", {
278
+ className: "flex flex-col",
279
+ children: [/* @__PURE__ */ jsx("span", {
280
+ className: "text-lg font-bold",
281
+ children: plan.name
282
+ }), /* @__PURE__ */ jsx("span", {
283
+ className: "text-sm font-normal text-muted-foreground",
284
+ children: plan.description || descriptionFallback
285
+ })]
286
+ })]
287
+ }), /* @__PURE__ */ jsxs("div", {
288
+ className: "text-right",
289
+ children: [/* @__PURE__ */ jsx(PlanPriceTag, {
290
+ label: priceLabel,
291
+ currency: plan.currency,
292
+ size: "lg",
293
+ description: true
294
+ }), priceLabel.type === "priced" && /* @__PURE__ */ jsxs(Fragment$1, { children: [taxAmount != null && /* @__PURE__ */ jsx("div", {
295
+ className: "text-sm font-normal mt-1",
296
+ children: taxInclusive ? `${formatMinorCurrencyAmount(taxAmount, plan.currency)} ${taxLabel} included` : `+ ${formatMinorCurrencyAmount(taxAmount, plan.currency)} ${taxLabel}`
297
+ }), billingCycle && /* @__PURE__ */ jsxs("div", {
298
+ className: "text-sm text-muted-foreground font-normal",
299
+ children: ["Billed ", formatBillingCycle(billingCycle)]
300
+ })] })]
301
+ })]
302
+ }) }), /* @__PURE__ */ jsxs(CardContent, { children: [
303
+ /* @__PURE__ */ jsx(Separator, {}),
304
+ /* @__PURE__ */ jsx("div", {
305
+ className: "text-sm font-medium mb-3 mt-3",
306
+ children: "What's included:"
307
+ }),
308
+ /* @__PURE__ */ jsx(PlanEntitlements, {
309
+ phases: plan.phases,
310
+ currency: plan.currency,
311
+ billingCadence: plan.billingCadence,
312
+ units,
313
+ itemClassName: entitlementsItemClassName
314
+ })
315
+ ] })]
316
+ });
317
+ };
318
+ //#endregion
319
+ //#region src/pages/CheckoutConfirmPage.tsx
320
+ const CheckoutConfirmPage = () => {
321
+ const [search] = useSearchParams();
322
+ const planId = search.get("planId");
323
+ const { pricing } = useMonetizationConfig();
324
+ if (!planId) throw new Error("Parameter `planId` missing");
325
+ const { selectedPlan, taxAmount, taxLabel, taxInclusive } = usePurchaseSummary(planId);
326
+ const createSubscriptionMutation = useSubscriptionConfirmMutation({
327
+ endpoint: "subscriptions",
328
+ planId
329
+ });
330
+ return /* @__PURE__ */ jsx(ConfirmationScreen, {
331
+ title: "Review your subscription",
332
+ message: /* @__PURE__ */ jsx("p", {
333
+ className: "text-muted-foreground text-base",
334
+ children: "Please confirm the details below before completing your purchase."
335
+ }),
336
+ errorMessage: createSubscriptionMutation.isError ? createSubscriptionMutation.error.message : void 0,
337
+ confirmLabel: "Confirm & Subscribe",
338
+ pendingLabel: "Processing Payment...",
339
+ onConfirm: () => createSubscriptionMutation.mutate(),
340
+ isPending: createSubscriptionMutation.isPending,
341
+ confirmDisabled: !selectedPlan,
342
+ cancelTo: "/pricing",
343
+ termsNote: "By confirming, you agree to our Terms of Service and Privacy Policy. You can cancel anytime.",
344
+ footer: /* @__PURE__ */ jsxs("div", {
345
+ className: "flex items-center gap-2 text-muted-foreground text-xs item-center justify-center pt-4",
346
+ children: [/* @__PURE__ */ jsx(LockIcon, { className: "size-3" }), "Your payment is secured by Stripe"]
347
+ }),
348
+ children: selectedPlan && /* @__PURE__ */ jsx(PlanSummaryCard, {
349
+ plan: selectedPlan,
350
+ descriptionFallback: "Selected plan",
351
+ taxAmount,
352
+ taxLabel,
353
+ taxInclusive,
354
+ units: pricing?.units,
355
+ entitlementsItemClassName: "text-muted-foreground"
356
+ })
357
+ });
358
+ };
359
+ //#endregion
296
360
  //#region src/components/RedirectPage.tsx
297
361
  const RedirectPage = ({ icon: Icon, title, description, url, children }) => {
298
362
  useEffect(() => {
@@ -391,7 +455,7 @@ const CheckoutPage = () => {
391
455
  variant: "outline",
392
456
  size: "xs",
393
457
  asChild: true,
394
- children: /* @__PURE__ */ jsx(Link$1, {
458
+ children: /* @__PURE__ */ jsx(Link, {
395
459
  to: "/subscriptions",
396
460
  children: "Back"
397
461
  })
@@ -433,7 +497,7 @@ const ManagePaymentPage = () => {
433
497
  variant: "outline",
434
498
  size: "xs",
435
499
  asChild: true,
436
- children: /* @__PURE__ */ jsx(Link, {
500
+ children: /* @__PURE__ */ jsx(Link$1, {
437
501
  to: "/subscriptions",
438
502
  children: "Back"
439
503
  })
@@ -494,19 +558,18 @@ const PricingPage = () => {
494
558
  }),
495
559
  /* @__PURE__ */ jsx(PricingTable, {
496
560
  plans: pricingTable.items,
497
- showYearlyPrice: pricing?.showYearlyPrice !== false,
498
561
  units: pricing?.units,
499
562
  renderAction: (plan, isPopular) => isSubscribed ? /* @__PURE__ */ jsx(Button, {
500
563
  variant: isPopular ? "default" : "outline",
501
564
  asChild: true,
502
- children: /* @__PURE__ */ jsx(Link$1, {
565
+ children: /* @__PURE__ */ jsx(Link, {
503
566
  to: `/subscriptions#manage`,
504
567
  children: "Manage Subscriptions"
505
568
  })
506
569
  }) : /* @__PURE__ */ jsx(Button, {
507
570
  variant: isPopular ? "default" : "outline",
508
571
  asChild: true,
509
- children: /* @__PURE__ */ jsx(Link$1, {
572
+ children: /* @__PURE__ */ jsx(Link, {
510
573
  to: `/checkout?planId=${encodeURIComponent(plan.id)}`,
511
574
  children: "Subscribe"
512
575
  })
@@ -517,6 +580,284 @@ const PricingPage = () => {
517
580
  });
518
581
  };
519
582
  //#endregion
583
+ //#region src/hooks/useChangeCreditEstimate.ts
584
+ /**
585
+ * Preview the proration credit for changing a subscription's plan, via the
586
+ * Zuplo metering `.../change/estimate-credit` endpoint. Failures are swallowed
587
+ * (`retry: false`, `throwOnError: false`) so the confirmation page never breaks
588
+ * when the preview is unavailable.
589
+ */
590
+ const useChangeCreditEstimate = (subscriptionId, timing) => {
591
+ const zudoku = useZudoku();
592
+ return useQuery({
593
+ queryKey: [`/v3/zudoku-metering/${useDeploymentName()}/subscriptions/${subscriptionId}/change/estimate-credit`, timing],
594
+ meta: {
595
+ context: zudoku,
596
+ request: {
597
+ method: "POST",
598
+ body: JSON.stringify({ timing })
599
+ }
600
+ },
601
+ retry: false,
602
+ throwOnError: false
603
+ });
604
+ };
605
+ /**
606
+ * Extract a positive credit amount from the estimate, or `undefined` when there
607
+ * isn't a usable one (so the UI only shows a credit when there actually is one).
608
+ */
609
+ const getEstimatedCreditAmount = (estimate) => {
610
+ if (!estimate) return void 0;
611
+ const amount = Number.parseFloat(estimate.creditAmount ?? "");
612
+ if (!Number.isFinite(amount) || amount <= 0) return void 0;
613
+ return {
614
+ amount,
615
+ currency: estimate.currency
616
+ };
617
+ };
618
+ //#endregion
619
+ //#region src/utils/formatDateTime.ts
620
+ /**
621
+ * Format an ISO timestamp with the time of day included, e.g.
622
+ * "Jun 3, 2026, 2:00 PM". Subscriptions can bill on sub-day cadences, so the
623
+ * time is what disambiguates a period's boundaries and the next renewal.
624
+ */
625
+ const formatDateTime = (dateString) => new Date(dateString).toLocaleString("en-US", {
626
+ month: "short",
627
+ day: "numeric",
628
+ year: "numeric",
629
+ hour: "numeric",
630
+ minute: "2-digit"
631
+ });
632
+ //#endregion
633
+ //#region src/utils/billables.ts
634
+ const getActivePhase = (sub) => {
635
+ const now = Date.now();
636
+ return sub.phases.filter((p) => new Date(p.activeFrom).getTime() <= now && (!p.activeTo || new Date(p.activeTo).getTime() >= now)).sort((a, b) => new Date(b.activeFrom).getTime() - new Date(a.activeFrom).getTime())[0];
637
+ };
638
+ const activePhaseHasBillables = (sub) => getActivePhase(sub)?.items.some((i) => i.price != null) ?? false;
639
+ const hasFutureBillables = (sub) => {
640
+ const now = Date.now();
641
+ return sub.phases.filter((p) => new Date(p.activeFrom).getTime() > now).some((p) => p.items.some((i) => i.price != null));
642
+ };
643
+ //#endregion
644
+ //#region src/utils/subscriptionEntitlements.ts
645
+ const toRateCardPrice = (price) => {
646
+ if (!price) return null;
647
+ if (price.type === "tiered" && price.tiers) return {
648
+ type: "tiered",
649
+ mode: price.mode === "volume" ? "volume" : "graduated",
650
+ tiers: price.tiers.map((t) => ({
651
+ upToAmount: t.upToAmount,
652
+ flatPrice: t.flatPrice ? { amount: t.flatPrice.amount } : void 0,
653
+ unitPrice: t.unitPrice ? { amount: t.unitPrice.amount } : void 0
654
+ }))
655
+ };
656
+ if (price.type === "unit" && price.amount != null) return {
657
+ type: "unit",
658
+ amount: price.amount
659
+ };
660
+ if (price.type === "flat" && price.amount != null) return {
661
+ type: "flat",
662
+ amount: price.amount,
663
+ paymentTerm: price.paymentTerm === "in_advance" || price.paymentTerm === "in_arrears" ? price.paymentTerm : void 0
664
+ };
665
+ return null;
666
+ };
667
+ const flatOrNull = (price) => price?.type === "flat" ? price : null;
668
+ /**
669
+ * Convert an actual provisioned subscription line item into the `RateCard`
670
+ * shape so it can run through the same {@link categorizeRateCards} logic the
671
+ * pricing card uses. This is the key to showing a subscription's *real*
672
+ * included quota (e.g. `10 / hour`) — the catalog plan reports `0` for a
673
+ * priced first tier, but the subscription item carries the true
674
+ * `issueAfterReset`.
675
+ */
676
+ const itemToRateCard = (item) => {
677
+ const ent = item.included?.entitlement;
678
+ const name = item.name ?? item.included?.feature?.name ?? item.featureKey ?? item.key;
679
+ const base = {
680
+ key: item.key,
681
+ name,
682
+ featureKey: item.featureKey
683
+ };
684
+ const price = toRateCardPrice(item.price);
685
+ const billingCadence = item.billingCadence ?? ent?.usagePeriod?.intervalISO ?? null;
686
+ if (ent?.type === "metered") {
687
+ const entitlementTemplate = {
688
+ type: "metered",
689
+ isSoftLimit: ent.isSoftLimit,
690
+ issueAfterReset: ent.issueAfterReset,
691
+ usagePeriod: ent.usagePeriod?.intervalISO
692
+ };
693
+ if (price?.type === "flat") return {
694
+ ...base,
695
+ type: "flat_fee",
696
+ billingCadence,
697
+ price,
698
+ entitlementTemplate
699
+ };
700
+ return {
701
+ ...base,
702
+ type: "usage_based",
703
+ billingCadence: billingCadence ?? "P1M",
704
+ price,
705
+ entitlementTemplate
706
+ };
707
+ }
708
+ if (ent?.type === "boolean") return {
709
+ ...base,
710
+ type: "flat_fee",
711
+ billingCadence,
712
+ price: flatOrNull(price),
713
+ entitlementTemplate: { type: "boolean" }
714
+ };
715
+ if (ent?.type === "static") return {
716
+ ...base,
717
+ type: "flat_fee",
718
+ billingCadence,
719
+ price: flatOrNull(price),
720
+ entitlementTemplate: {
721
+ type: "static",
722
+ config: ent.config ?? ""
723
+ }
724
+ };
725
+ return {
726
+ ...base,
727
+ type: "flat_fee",
728
+ billingCadence,
729
+ price: flatOrNull(price)
730
+ };
731
+ };
732
+ /**
733
+ * Categorize a subscription phase's actual items into `{ quotas, features }`,
734
+ * reusing {@link categorizeRateCards} so the subscription view stays
735
+ * consistent with the pricing card.
736
+ */
737
+ const categorizeSubscriptionItems = (items, options) => categorizeRateCards(items.map(itemToRateCard), options);
738
+ /** Map a subscription phase's items to rate cards (for price/entitlement derivation). */
739
+ const subscriptionItemsToRateCards = (items) => items.map(itemToRateCard);
740
+ /**
741
+ * Resolve a subscription's headline price and entitlements, preferring the
742
+ * active phase's ACTUAL provisioned items (the authoritative source — real
743
+ * included quotas and recurring fees) and falling back to the catalog plan's
744
+ * rate cards only when items aren't populated. Keeping both the price and the
745
+ * entitlements on the same source avoids showing "Free" for a paid plan whose
746
+ * embedded snapshot is incomplete.
747
+ */
748
+ const getSubscriptionPlanView = (subscription, options) => {
749
+ const plan = subscription.plan;
750
+ const currency = subscription.currency ?? plan.currency;
751
+ const billingCadence = subscription.billingCadence ?? plan.billingCadence;
752
+ const items = getActivePhase(subscription)?.items ?? [];
753
+ if (items.length > 0) {
754
+ const rateCards = subscriptionItemsToRateCards(items);
755
+ return {
756
+ priceLabel: formatPlanPrice({
757
+ ...plan,
758
+ billingCadence,
759
+ phases: [{
760
+ key: "active",
761
+ name: "Active",
762
+ rateCards
763
+ }]
764
+ }),
765
+ entitlements: categorizeRateCards(rateCards, {
766
+ currency,
767
+ units: options?.units,
768
+ planBillingCadence: billingCadence
769
+ }),
770
+ fallbackPhases: [],
771
+ usingItems: true,
772
+ billingCadence,
773
+ currency
774
+ };
775
+ }
776
+ return {
777
+ priceLabel: formatPlanPrice(plan),
778
+ entitlements: {
779
+ quotas: [],
780
+ features: []
781
+ },
782
+ fallbackPhases: plan.phases ?? [],
783
+ usingItems: false,
784
+ billingCadence,
785
+ currency
786
+ };
787
+ };
788
+ /** Whether a resolved view has any entitlements to render. */
789
+ const hasSubscriptionEntitlements = (view) => view.usingItems ? view.entitlements.quotas.length > 0 || view.entitlements.features.length > 0 : view.fallbackPhases.some((p) => p.rateCards?.some((rc) => rc.entitlementTemplate));
790
+ //#endregion
791
+ //#region src/pages/components/SubscriptionEntitlements.tsx
792
+ /**
793
+ * Render a subscription's entitlements from a resolved
794
+ * {@link SubscriptionPlanView}: the active phase's *provisioned* items when
795
+ * present (the real included quotas + per-unit prices), otherwise the catalog
796
+ * plan's phases. Centralizes the "items, else fall back to plan phases" branch
797
+ * shared by the subscription details page and the Switch Plan baseline.
798
+ *
799
+ * Renders `null` when there's nothing to show, so callers can wrap it with
800
+ * {@link hasSubscriptionEntitlements} (for a section header / border) without
801
+ * leaving an empty container behind.
802
+ */
803
+ const SubscriptionEntitlements = ({ view, currency, billingCadence, units, itemClassName }) => {
804
+ if (view.usingItems) {
805
+ const { quotas, features } = view.entitlements;
806
+ return /* @__PURE__ */ jsx(EntitlementList, {
807
+ quotas,
808
+ features,
809
+ itemClassName
810
+ });
811
+ }
812
+ if (view.fallbackPhases.length === 0) return null;
813
+ return /* @__PURE__ */ jsx(PlanEntitlements, {
814
+ phases: view.fallbackPhases,
815
+ currency,
816
+ billingCadence,
817
+ units,
818
+ itemClassName
819
+ });
820
+ };
821
+ //#endregion
822
+ //#region src/pages/components/CurrentPlanBaseline.tsx
823
+ /**
824
+ * Baseline shown at the top of the Switch Plan modal: the current plan's price
825
+ * and what it actually includes, so users have a concrete reference to compare
826
+ * targets against. Both the price and the entitlements come from the
827
+ * subscription's *provisioned* items (real included quotas + recurring fees),
828
+ * falling back to the plan's rate cards only when items aren't present — see
829
+ * {@link getSubscriptionPlanView}.
830
+ */
831
+ const CurrentPlanBaseline = ({ subscription, units }) => {
832
+ const plan = subscription.plan;
833
+ const view = getSubscriptionPlanView(subscription, { units });
834
+ return /* @__PURE__ */ jsxs("div", {
835
+ className: "border rounded-lg p-4",
836
+ children: [/* @__PURE__ */ jsxs("div", {
837
+ className: "flex items-start justify-between gap-3 flex-wrap",
838
+ children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("div", {
839
+ className: "text-xs font-medium text-muted-foreground uppercase tracking-wide",
840
+ children: "Current Plan"
841
+ }), /* @__PURE__ */ jsx("div", {
842
+ className: "text-lg font-bold text-foreground",
843
+ children: plan.name
844
+ })] }), /* @__PURE__ */ jsx(PlanPriceTag, {
845
+ label: view.priceLabel,
846
+ currency: view.currency,
847
+ billingCadence: view.billingCadence
848
+ })]
849
+ }), hasSubscriptionEntitlements(view) && /* @__PURE__ */ jsx("div", {
850
+ className: "mt-3 pt-3 border-t",
851
+ children: /* @__PURE__ */ jsx(SubscriptionEntitlements, {
852
+ view,
853
+ currency: view.currency,
854
+ billingCadence: view.billingCadence,
855
+ units
856
+ })
857
+ })]
858
+ });
859
+ };
860
+ //#endregion
520
861
  //#region src/pages/SubscriptionChangeConfirmPage.tsx
521
862
  const SubscriptionChangeConfirmPage = () => {
522
863
  const [search] = useSearchParams();
@@ -524,152 +865,68 @@ const SubscriptionChangeConfirmPage = () => {
524
865
  const subscriptionId = search.get("subscriptionId");
525
866
  const mode = search.get("mode");
526
867
  const zudoku = useZudoku();
527
- const deploymentName = useDeploymentName();
528
- const navigate = useNavigate();
529
868
  const { pricing } = useMonetizationConfig();
530
869
  if (!planId) throw new Error("Parameter `planId` missing");
531
870
  if (!subscriptionId) throw new Error("Parameter `subscriptionId` missing");
532
- const purchaseDetails = usePurchaseDetails(planId);
533
- const selectedPlan = getPlanFromPurchaseDetails(purchaseDetails.data);
534
- const taxAmount = getTaxAmountFromPurchaseDetails(purchaseDetails.data);
535
- const taxLabel = getTaxLabelFromPurchaseDetails(purchaseDetails.data);
536
- const taxInclusive = isTaxInclusiveFromPurchaseDetails(purchaseDetails.data);
537
- const price = selectedPlan ? getPriceFromPlan(selectedPlan) : null;
538
- const billingCycle = selectedPlan?.billingCadence ? formatDuration(selectedPlan.billingCadence) : null;
539
- const effectiveChangeMessage = mode === "downgrade" ? "This change will take effect at the start of your next billing cycle." : "This change will take effect immediately.";
540
- const changeMutation = useMutation({
541
- mutationKey: [`/v3/zudoku-metering/${deploymentName}/subscriptions/${subscriptionId}/change`],
542
- meta: {
543
- context: zudoku,
544
- request: {
545
- method: "POST",
546
- body: JSON.stringify({ planId })
547
- }
548
- },
549
- onSuccess: async (subscription) => {
550
- await queryClient.invalidateQueries();
551
- navigate(`/subscriptions?subscriptionId=${encodeURIComponent(subscription.id)}`, { state: { planSwitched: { newPlanName: selectedPlan?.name } } });
552
- }
871
+ const { selectedPlan, taxAmount, taxLabel, taxInclusive } = usePurchaseSummary(planId);
872
+ const { data: subscriptionsData } = useQuery(subscriptionsQuery(zudoku));
873
+ const currentSubscription = subscriptionsData?.items.find((s) => s.id === subscriptionId);
874
+ const isDowngrade = mode === "downgrade";
875
+ const credit = getEstimatedCreditAmount(useChangeCreditEstimate(subscriptionId, isDowngrade ? "next_billing_cycle" : "immediate").data);
876
+ const nextCycleEnd = currentSubscription?.alignment?.currentAlignedBillingPeriod?.to;
877
+ const effectiveText = isDowngrade ? nextCycleEnd ? `Takes effect ${formatDateTime(nextCycleEnd)}, at the start of your next billing cycle` : "Takes effect at the start of your next billing cycle" : "Takes effect immediately";
878
+ const changeMutation = useSubscriptionConfirmMutation({
879
+ endpoint: `subscriptions/${subscriptionId}/change`,
880
+ planId,
881
+ navigateState: { planSwitched: { newPlanName: selectedPlan?.name } }
553
882
  });
554
- return /* @__PURE__ */ jsx("div", {
555
- className: "w-full bg-muted min-h-screen flex items-center justify-center px-4 py-12 gap-4",
883
+ return /* @__PURE__ */ jsx(ConfirmationScreen, {
884
+ title: "Confirm plan change",
885
+ message: /* @__PURE__ */ jsx("p", {
886
+ className: "text-muted-foreground text-base",
887
+ children: "Review the change below before confirming."
888
+ }),
889
+ errorMessage: changeMutation.isError ? changeMutation.error.message : void 0,
890
+ confirmLabel: "Confirm & Change Plan",
891
+ pendingLabel: "Changing plan...",
892
+ onConfirm: () => changeMutation.mutate(),
893
+ isPending: changeMutation.isPending,
894
+ cancelTo: `/subscriptions?${new URLSearchParams({ subscriptionId })}`,
895
+ termsNote: "By confirming, you agree to our Terms of Service and Privacy Policy.",
556
896
  children: /* @__PURE__ */ jsxs("div", {
557
- className: "max-w-2xl w-full",
558
- children: [changeMutation.isError && /* @__PURE__ */ jsxs(Alert, {
559
- className: "mb-4",
560
- variant: "destructive",
561
- children: [/* @__PURE__ */ jsx(AlertTitle, { children: "Error" }), /* @__PURE__ */ jsx(AlertDescription, { children: changeMutation.error.message })]
562
- }), /* @__PURE__ */ jsxs(Card, {
563
- className: "p-8 w-full max-w-7xl",
564
- children: [
565
- /* @__PURE__ */ jsx("div", {
566
- className: "flex justify-center mb-6",
567
- children: /* @__PURE__ */ jsx("div", {
568
- className: "rounded-full bg-primary/10 p-3",
569
- children: /* @__PURE__ */ jsx(CheckIcon, { className: "size-9 text-primary" })
570
- })
571
- }),
572
- /* @__PURE__ */ jsxs("div", {
573
- className: "text-center mb-8",
897
+ className: "space-y-3",
898
+ children: [
899
+ currentSubscription && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(CurrentPlanBaseline, {
900
+ subscription: currentSubscription,
901
+ units: pricing?.units
902
+ }), /* @__PURE__ */ jsxs("div", {
903
+ className: "flex items-center justify-center gap-1.5 text-sm text-muted-foreground",
904
+ children: [/* @__PURE__ */ jsx(ArrowDownIcon, { className: "size-4" }), " Changing to"]
905
+ })] }),
906
+ selectedPlan && /* @__PURE__ */ jsx(PlanSummaryCard, {
907
+ plan: selectedPlan,
908
+ descriptionFallback: "New plan",
909
+ taxAmount,
910
+ taxLabel,
911
+ taxInclusive,
912
+ units: pricing?.units
913
+ }),
914
+ /* @__PURE__ */ jsxs("div", {
915
+ className: "rounded-lg bg-muted/50 p-3 text-sm space-y-1",
916
+ children: [/* @__PURE__ */ jsx("div", {
917
+ className: "font-medium text-card-foreground",
918
+ children: effectiveText
919
+ }), credit && /* @__PURE__ */ jsxs("div", {
920
+ className: "text-muted-foreground",
574
921
  children: [
575
- /* @__PURE__ */ jsx("h1", {
576
- className: "text-2xl font-bold text-card-foreground mb-3",
577
- children: "Confirm plan change"
578
- }),
579
- /* @__PURE__ */ jsx("p", {
580
- className: "text-muted-foreground text-base",
581
- children: effectiveChangeMessage
582
- }),
583
- /* @__PURE__ */ jsx("p", {
584
- className: "text-muted-foreground text-base",
585
- children: "Please confirm the details below to change your subscription."
586
- })
922
+ "You'll be credited ",
923
+ formatPrice(credit.amount, credit.currency),
924
+ " ",
925
+ "for unused time on your current plan."
587
926
  ]
588
- }),
589
- selectedPlan && /* @__PURE__ */ jsxs(Card, {
590
- className: "bg-muted/50",
591
- children: [/* @__PURE__ */ jsx(CardHeader, { children: /* @__PURE__ */ jsxs(CardTitle, {
592
- className: "flex justify-between items-start",
593
- children: [
594
- /* @__PURE__ */ jsxs("div", {
595
- className: "flex items-center gap-3",
596
- children: [/* @__PURE__ */ jsx("div", {
597
- className: "flex flex-col text-2xl font-bold bg-primary text-primary-foreground items-center justify-center rounded size-12",
598
- children: selectedPlan.name.at(0)?.toUpperCase()
599
- }), /* @__PURE__ */ jsxs("div", {
600
- className: "flex flex-col",
601
- children: [/* @__PURE__ */ jsx("span", {
602
- className: "text-lg font-bold",
603
- children: selectedPlan.name
604
- }), /* @__PURE__ */ jsx("span", {
605
- className: "text-sm font-normal text-muted-foreground",
606
- children: selectedPlan.description || "New plan"
607
- })]
608
- })]
609
- }),
610
- price && price.monthly > 0 && /* @__PURE__ */ jsxs("div", {
611
- className: "text-right",
612
- children: [
613
- /* @__PURE__ */ jsx("div", {
614
- className: "text-2xl font-bold",
615
- children: formatPrice(price.monthly, selectedPlan?.currency)
616
- }),
617
- taxAmount != null && /* @__PURE__ */ jsx("div", {
618
- className: "text-sm font-normal mt-1",
619
- children: taxInclusive ? `${formatMinorCurrencyAmount(taxAmount, selectedPlan?.currency)} ${taxLabel} included` : `+ ${formatMinorCurrencyAmount(taxAmount, selectedPlan?.currency)} ${taxLabel}`
620
- }),
621
- billingCycle && /* @__PURE__ */ jsxs("div", {
622
- className: "text-sm text-muted-foreground font-normal",
623
- children: ["Billed ", formatBillingCycle(billingCycle)]
624
- })
625
- ]
626
- }),
627
- price && price.monthly === 0 && /* @__PURE__ */ jsx("div", {
628
- className: "text-2xl text-muted-foreground font-bold",
629
- children: "Free"
630
- })
631
- ]
632
- }) }), /* @__PURE__ */ jsxs(CardContent, { children: [
633
- /* @__PURE__ */ jsx(Separator, {}),
634
- /* @__PURE__ */ jsx("div", {
635
- className: "text-sm font-medium mb-3 mt-3",
636
- children: "What's included:"
637
- }),
638
- /* @__PURE__ */ jsx(PlanEntitlements, {
639
- phases: selectedPlan.phases,
640
- currency: selectedPlan.currency,
641
- billingCadence: selectedPlan.billingCadence,
642
- units: pricing?.units
643
- })
644
- ] })]
645
- }),
646
- /* @__PURE__ */ jsxs("div", {
647
- className: "space-y-3 mt-4",
648
- children: [/* @__PURE__ */ jsx(Button, {
649
- className: "w-full",
650
- onClick: () => changeMutation.mutate(),
651
- disabled: changeMutation.isPending,
652
- children: changeMutation.isPending ? "Changing plan..." : "Confirm & Change Plan"
653
- }), /* @__PURE__ */ jsx(Button, {
654
- variant: "ghost",
655
- className: "w-full",
656
- disabled: changeMutation.isPending,
657
- asChild: !changeMutation.isPending,
658
- children: /* @__PURE__ */ jsx(Link$1, {
659
- to: `/subscriptions?${new URLSearchParams({ subscriptionId: subscriptionId ?? "" })}`,
660
- children: "Cancel"
661
- })
662
- })]
663
- }),
664
- /* @__PURE__ */ jsx("div", {
665
- className: "mt-6 pt-6 border-t text-center",
666
- children: /* @__PURE__ */ jsx("p", {
667
- className: "text-xs text-muted-foreground",
668
- children: "By confirming, you agree to our Terms of Service and Privacy Policy."
669
- })
670
- })
671
- ]
672
- })]
927
+ })]
928
+ })
929
+ ]
673
930
  })
674
931
  });
675
932
  };
@@ -689,17 +946,6 @@ const useSubscriptions = () => {
689
946
  });
690
947
  };
691
948
  //#endregion
692
- //#region src/utils/billables.ts
693
- const getActivePhase = (sub) => {
694
- const now = Date.now();
695
- return sub.phases.filter((p) => new Date(p.activeFrom).getTime() <= now && (!p.activeTo || new Date(p.activeTo).getTime() >= now)).sort((a, b) => new Date(b.activeFrom).getTime() - new Date(a.activeFrom).getTime())[0];
696
- };
697
- const activePhaseHasBillables = (sub) => getActivePhase(sub)?.items.some((i) => i.price != null) ?? false;
698
- const hasFutureBillables = (sub) => {
699
- const now = Date.now();
700
- return sub.phases.filter((p) => new Date(p.activeFrom).getTime() > now).some((p) => p.items.some((i) => i.price != null));
701
- };
702
- //#endregion
703
949
  //#region src/pages/subscriptions/ConfirmDeleteKeyAlert.tsx
704
950
  const ConfirmDeleteKeyAlert = ({ children, onDelete }) => {
705
951
  return /* @__PURE__ */ jsxs(AlertDialog, { children: [/* @__PURE__ */ jsx(AlertDialogTrigger, {
@@ -712,7 +958,7 @@ const ConfirmDeleteKeyAlert = ({ children, onDelete }) => {
712
958
  };
713
959
  //#endregion
714
960
  //#region src/pages/subscriptions/ApiKey.tsx
715
- const formatDate$2 = (dateString) => {
961
+ const formatDate$1 = (dateString) => {
716
962
  if (!dateString) return "";
717
963
  return new Date(dateString).toLocaleDateString("en-US", {
718
964
  month: "short",
@@ -731,7 +977,7 @@ const getTimeAgo = (dateString) => {
731
977
  const diffInDays = Math.floor(diffInHours / 24);
732
978
  if (diffInDays === 1) return "1 day ago";
733
979
  if (diffInDays < 30) return `${diffInDays} days ago`;
734
- return formatDate$2(dateString);
980
+ return formatDate$1(dateString);
735
981
  };
736
982
  const ApiKey = ({ apiKey, createdAt, lastUsed, expiresOn, isActive = true, label, onDelete }) => {
737
983
  const isExpiring = expiresOn && new Date(expiresOn) < new Date(Date.now() + 720 * 60 * 60 * 1e3);
@@ -788,7 +1034,7 @@ const ApiKey = ({ apiKey, createdAt, lastUsed, expiresOn, isActive = true, label
788
1034
  children: [
789
1035
  /* @__PURE__ */ jsxs("div", {
790
1036
  className: "flex items-center gap-1.5",
791
- children: [/* @__PURE__ */ jsx(ClockIcon, { className: "size-3" }), /* @__PURE__ */ jsxs("span", { children: ["Created ", formatDate$2(createdAt)] })]
1037
+ children: [/* @__PURE__ */ jsx(ClockIcon, { className: "size-3" }), /* @__PURE__ */ jsxs("span", { children: ["Created ", formatDate$1(createdAt)] })]
792
1038
  }),
793
1039
  /* @__PURE__ */ jsx("span", {
794
1040
  className: "text-muted-foreground/40",
@@ -803,7 +1049,7 @@ const ApiKey = ({ apiKey, createdAt, lastUsed, expiresOn, isActive = true, label
803
1049
  children: [
804
1050
  isExpired ? "Expired" : "Expires",
805
1051
  " on ",
806
- formatDate$2(expiresOn)
1052
+ formatDate$1(expiresOn)
807
1053
  ]
808
1054
  })] })
809
1055
  ]
@@ -918,7 +1164,7 @@ const ApiKeysList = ({ isPendingFirstPayment, apiKeys, deploymentName, consumerI
918
1164
  /* @__PURE__ */ jsx(AlertTitle, { children: "API key was deleted" }),
919
1165
  /* @__PURE__ */ jsx(AlertDescription, { children: (() => {
920
1166
  const deletedKey = apiKeys.find((k) => k.id === deleteKeyMutation.variables?.keyId);
921
- return deletedKey ? `API key created ${formatDate$2(deletedKey.createdOn)} has been removed.` : "The API key has been deleted.";
1167
+ return deletedKey ? `API key created ${formatDate$1(deletedKey.createdOn)} has been removed.` : "The API key has been deleted.";
922
1168
  })() }),
923
1169
  /* @__PURE__ */ jsx(DismissibleAlertAction, {})
924
1170
  ]
@@ -1041,7 +1287,7 @@ const CancelSubscriptionDialog = ({ open, onOpenChange, planName, subscriptionId
1041
1287
  " subscription?"
1042
1288
  ] }), /* @__PURE__ */ jsx(AlertDescription, { children: "Your subscription will end immediately. You'll lose access to its entitlements right away." })] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(AlertTitle, { children: "Your plan will be canceled at the end of your billing cycle." }), /* @__PURE__ */ jsxs(AlertDescription, { children: [
1043
1289
  "You'll retain access until ",
1044
- formatDate$2(billingPeriodEnd),
1290
+ formatDate$1(billingPeriodEnd),
1045
1291
  ". After your billing period ends, this plan will not renew and you would need to subscribe again to continue."
1046
1292
  ] })] })]
1047
1293
  }),
@@ -1050,7 +1296,7 @@ const CancelSubscriptionDialog = ({ open, onOpenChange, planName, subscriptionId
1050
1296
  children: [/* @__PURE__ */ jsx(InfoIcon, { className: "size-4" }), isImmediateCancel ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(AlertTitle, { children: "You can subscribe again at any time" }), /* @__PURE__ */ jsx(AlertDescription, { children: "After canceling, you can return to the pricing page and start a new subscription whenever you're ready." })] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(AlertTitle, { children: "You can still resume before then" }), /* @__PURE__ */ jsxs(AlertDescription, { children: [
1051
1297
  "If you change your mind you have until",
1052
1298
  " ",
1053
- formatDate$2(billingPeriodEnd),
1299
+ formatDate$1(billingPeriodEnd),
1054
1300
  " to remove this cancellation from Manage subscription."
1055
1301
  ] })] })]
1056
1302
  }),
@@ -1152,7 +1398,7 @@ const RestoreSubscriptionDialog = ({ open, onOpenChange, planName, subscriptionI
1152
1398
  className: "space-y-2",
1153
1399
  children: [/* @__PURE__ */ jsxs("p", { children: [
1154
1400
  "Your access stays in place until ",
1155
- formatDate$2(billingPeriodEnd),
1401
+ formatDate$1(billingPeriodEnd),
1156
1402
  " ",
1157
1403
  "either way."
1158
1404
  ] }), /* @__PURE__ */ jsx("p", { children: "Confirming will remove the pending cancellation. Your subscription will remain active and continue to renew on your normal billing schedule, and charges will apply as usual." })]
@@ -1185,326 +1431,315 @@ const RestoreSubscriptionDialog = ({ open, onOpenChange, planName, subscriptionI
1185
1431
  });
1186
1432
  };
1187
1433
  //#endregion
1188
- //#region src/pages/subscriptions/SwitchPlanModal.tsx
1189
- const getAllKeysAcrossPhases = (plan, units) => {
1190
- const quotaKeys = /* @__PURE__ */ new Set();
1191
- const featureKeys = /* @__PURE__ */ new Set();
1192
- for (const phase of plan.phases) {
1193
- const { quotas, features } = categorizeRateCards(phase.rateCards, {
1194
- currency: plan.currency,
1195
- units,
1196
- planBillingCadence: plan.billingCadence
1197
- });
1198
- for (const q of quotas) quotaKeys.add(q.key);
1199
- for (const f of features) featureKeys.add(f.key);
1200
- }
1201
- return {
1202
- quotaKeys,
1203
- featureKeys
1204
- };
1434
+ //#region src/utils/comparePlanEntitlements.ts
1435
+ /** Compact, human-readable value for a quota row. */
1436
+ const quotaValueLabel = (q) => {
1437
+ if (q.unitPrice) return q.unitPrice;
1438
+ if (q.tierPrices && q.tierPrices.length > 0) return "Tiered pricing";
1439
+ if (q.isPayg) return "Usage-based";
1440
+ return `${q.limit.toLocaleString("en-US")} / ${q.period}`;
1205
1441
  };
1206
- const comparePlans = (currentPlan, targetPlan, currentIndex, targetIndex, units) => {
1207
- const isUpgrade = targetIndex > currentIndex;
1208
- const currentPhase = currentPlan?.phases.at(-1);
1209
- const targetPhase = targetPlan.phases.at(-1);
1210
- const { quotas: currentQuotas, features: currentFeatures } = currentPhase ? categorizeRateCards(currentPhase.rateCards, {
1211
- currency: currentPlan?.currency,
1212
- units,
1213
- planBillingCadence: currentPlan?.billingCadence
1214
- }) : {
1215
- quotas: [],
1216
- features: []
1217
- };
1218
- const { quotas: targetQuotas, features: targetFeatures } = targetPhase ? categorizeRateCards(targetPhase.rateCards, {
1219
- currency: targetPlan.currency,
1220
- units,
1221
- planBillingCadence: targetPlan.billingCadence
1222
- }) : {
1223
- quotas: [],
1224
- features: []
1225
- };
1226
- const currentAllKeys = currentPlan ? getAllKeysAcrossPhases(currentPlan, units) : {
1227
- quotaKeys: /* @__PURE__ */ new Set(),
1228
- featureKeys: /* @__PURE__ */ new Set()
1229
- };
1230
- const targetAllKeys = getAllKeysAcrossPhases(targetPlan, units);
1231
- const quotaChanges = [];
1232
- const allQuotaKeys = new Set([...currentQuotas.map((q) => q.key), ...targetQuotas.map((q) => q.key)]);
1233
- for (const key of allQuotaKeys) {
1234
- const current = currentQuotas.find((q) => q.key === key);
1235
- const target = targetQuotas.find((q) => q.key === key);
1236
- if (current && target) {
1442
+ const featureValueLabel = (f) => f.value ?? "Included";
1443
+ const isPlainNumericQuota = (q) => !q.isPayg && !q.unitPrice && (!q.tierPrices || q.tierPrices.length === 0);
1444
+ const sameTierSchedule = (a, b) => (a ?? []).join("\n") === (b ?? []).join("\n");
1445
+ /**
1446
+ * Compare two plans' entitlements, matching strictly by feature key (never by
1447
+ * display name). Each key yields exactly one change row, so a key that exists
1448
+ * on one side and a differently-keyed feature that merely shares a display
1449
+ * name can never read as a contradictory "added" + "removed" of the same
1450
+ * thing. Labels are disambiguated afterwards when they would collide.
1451
+ */
1452
+ const comparePlanEntitlements = (current, target) => {
1453
+ const changes = [];
1454
+ const curQuota = new Map(current.quotas.map((q) => [q.key, q]));
1455
+ const tgtQuota = new Map(target.quotas.map((q) => [q.key, q]));
1456
+ const curFeat = new Map(current.features.map((f) => [f.key, f]));
1457
+ const tgtFeat = new Map(target.features.map((f) => [f.key, f]));
1458
+ for (const key of new Set([...curQuota.keys(), ...tgtQuota.keys()])) {
1459
+ const c = curQuota.get(key);
1460
+ const t = tgtQuota.get(key);
1461
+ if (c && t) {
1462
+ const currentValue = quotaValueLabel(c);
1463
+ const targetValue = quotaValueLabel(t);
1237
1464
  let change = "same";
1238
- if (target.limit > current.limit) change = "increase";
1239
- else if (target.limit < current.limit) change = "decrease";
1240
- quotaChanges.push({
1241
- key: key ?? "",
1242
- name: target.name,
1243
- currentValue: current.limit,
1244
- newValue: target.limit,
1245
- period: target.period,
1246
- change
1247
- });
1248
- } else if (target && !current) {
1249
- if (currentAllKeys.featureKeys.has(key)) {
1250
- quotaChanges.push({
1251
- key: key ?? "",
1252
- name: target.name,
1253
- currentValue: null,
1254
- newValue: target.limit,
1255
- period: target.period,
1256
- change: "same"
1257
- });
1258
- continue;
1259
- }
1260
- quotaChanges.push({
1261
- key: key ?? "",
1262
- name: target.name,
1263
- currentValue: null,
1264
- newValue: target.limit,
1265
- period: target.period,
1266
- change: "added"
1267
- });
1268
- } else if (current && !target) {
1269
- if (targetAllKeys.featureKeys.has(key)) continue;
1270
- quotaChanges.push({
1271
- key: key ?? "",
1272
- name: current.name,
1273
- currentValue: current.limit,
1274
- newValue: null,
1275
- period: current.period,
1276
- change: "removed"
1465
+ if (isPlainNumericQuota(c) && isPlainNumericQuota(t) && c.period === t.period) {
1466
+ if (t.limit > c.limit) change = "increase";
1467
+ else if (t.limit < c.limit) change = "decrease";
1468
+ } else if (currentValue !== targetValue || !sameTierSchedule(c.tierPrices, t.tierPrices)) change = "changed";
1469
+ changes.push({
1470
+ key,
1471
+ label: t.name,
1472
+ kind: "quota",
1473
+ change,
1474
+ currentValue,
1475
+ targetValue,
1476
+ tierPrices: t.tierPrices,
1477
+ period: t.period
1277
1478
  });
1278
- }
1479
+ } else if (t) changes.push({
1480
+ key,
1481
+ label: t.name,
1482
+ kind: "quota",
1483
+ change: "added",
1484
+ targetValue: quotaValueLabel(t),
1485
+ tierPrices: t.tierPrices,
1486
+ period: t.period
1487
+ });
1488
+ else if (c) changes.push({
1489
+ key,
1490
+ label: c.name,
1491
+ kind: "quota",
1492
+ change: "removed",
1493
+ currentValue: quotaValueLabel(c),
1494
+ period: c.period
1495
+ });
1279
1496
  }
1280
- const featureChanges = [];
1281
- const allFeatureKeys = new Set([...currentFeatures.map((f) => f.key), ...targetFeatures.map((f) => f.key)]);
1282
- for (const key of allFeatureKeys) {
1283
- const current = currentFeatures.find((f) => f.key === key);
1284
- const target = targetFeatures.find((f) => f.key === key);
1285
- if (current && target) {
1286
- let change = "same";
1287
- if (current.value !== void 0 && target.value !== void 0 && current.value !== target.value) change = isUpgrade ? "upgraded" : "downgraded";
1288
- featureChanges.push({
1289
- key: key ?? "",
1290
- name: target.name,
1291
- currentValue: current.value ?? true,
1292
- newValue: target.value ?? true,
1293
- change
1294
- });
1295
- } else if (target && !current) {
1296
- if (currentAllKeys.quotaKeys.has(key)) {
1297
- featureChanges.push({
1298
- key: key ?? "",
1299
- name: target.name,
1300
- currentValue: true,
1301
- newValue: target.value ?? true,
1302
- change: "same"
1303
- });
1304
- continue;
1305
- }
1306
- featureChanges.push({
1307
- key: key ?? "",
1308
- name: target.name,
1309
- currentValue: null,
1310
- newValue: target.value ?? true,
1311
- change: "added"
1312
- });
1313
- } else if (current && !target) {
1314
- if (targetAllKeys.quotaKeys.has(key)) continue;
1315
- featureChanges.push({
1316
- key: key ?? "",
1317
- name: current.name,
1318
- currentValue: current.value ?? true,
1319
- newValue: null,
1320
- change: "removed"
1497
+ for (const key of new Set([...curFeat.keys(), ...tgtFeat.keys()])) {
1498
+ const c = curFeat.get(key);
1499
+ const t = tgtFeat.get(key);
1500
+ if (c && t) {
1501
+ const currentValue = featureValueLabel(c);
1502
+ const targetValue = featureValueLabel(t);
1503
+ changes.push({
1504
+ key,
1505
+ label: t.name,
1506
+ kind: "feature",
1507
+ change: currentValue === targetValue ? "same" : "changed",
1508
+ currentValue,
1509
+ targetValue
1321
1510
  });
1322
- }
1511
+ } else if (t) changes.push({
1512
+ key,
1513
+ label: t.name,
1514
+ kind: "feature",
1515
+ change: "added",
1516
+ targetValue: featureValueLabel(t)
1517
+ });
1518
+ else if (c) changes.push({
1519
+ key,
1520
+ label: c.name,
1521
+ kind: "feature",
1522
+ change: "removed",
1523
+ currentValue: featureValueLabel(c)
1524
+ });
1323
1525
  }
1324
- return {
1325
- plan: targetPlan,
1326
- isUpgrade,
1327
- isNewerVersion: false,
1328
- quotaChanges,
1329
- featureChanges
1330
- };
1526
+ const labelCounts = /* @__PURE__ */ new Map();
1527
+ for (const ch of changes) labelCounts.set(ch.label, (labelCounts.get(ch.label) ?? 0) + 1);
1528
+ return changes.map(({ period, ...ch }) => {
1529
+ if ((labelCounts.get(ch.label) ?? 0) > 1 && ch.kind === "quota" && period) return {
1530
+ ...ch,
1531
+ label: `${ch.label} (${period})`
1532
+ };
1533
+ return ch;
1534
+ });
1331
1535
  };
1332
- const ChangeIndicator = ({ change }) => {
1333
- if (change === "increase" || change === "added" || change === "upgraded") return /* @__PURE__ */ jsx(ArrowUpIcon, { className: "w-4 h-4 text-primary shrink-0" });
1334
- if (change === "decrease" || change === "removed" || change === "downgraded") return /* @__PURE__ */ jsx(ArrowDownIcon, { className: "w-4 h-4 text-amber-600 shrink-0" });
1335
- return /* @__PURE__ */ jsx(CheckIcon, { className: "w-4 h-4 text-green-600 shrink-0" });
1536
+ //#endregion
1537
+ //#region src/utils/formatPhaseRampSummary.ts
1538
+ const durationWithCount = (iso) => {
1539
+ const text = formatDuration(iso);
1540
+ return /^\d/.test(text) ? text : `1 ${text}`;
1336
1541
  };
1337
- const isPrivatePlan = (plan) => plan.metadata?.zuplo_private_plan === "true";
1338
- const planVersion = (plan) => plan.version ?? 1;
1339
- const isNewerPlanVersion = (subscribedPlan, target) => target.key === subscribedPlan.key && planVersion(target) > planVersion(subscribedPlan);
1340
- /** Baseline for comparisons: catalog entry when present, else subscription plan. */
1341
- const resolvePlanForComparison = (subscribedPlan, catalogItems) => catalogItems?.find((p) => p.id === subscribedPlan.id) ?? subscribedPlan;
1342
- const resolveIsUpgrade = ({ target, targetIndex, subscribedPlan, currentIndex }) => {
1343
- if (target.key === subscribedPlan.key) return planVersion(target) > planVersion(subscribedPlan);
1344
- return targetIndex > currentIndex;
1542
+ const steadyStateLabel = (plan) => {
1543
+ const label = formatPlanPrice(plan);
1544
+ if (label.type === "priced") return `${formatPrice(label.amount, plan.currency)} / ${formatDuration(plan.billingCadence)}`;
1545
+ return label.type === "payg" ? "Pay as you go" : "Free";
1345
1546
  };
1346
- const modeLabelMap = {
1547
+ /**
1548
+ * One-line summary of a multi-phase plan's progression for compact UI, e.g.
1549
+ * `"Free Trial (1 week), then $2.99 / month"`. Returns `undefined` for
1550
+ * single-phase plans (nothing to summarize).
1551
+ */
1552
+ const formatPhaseRampSummary = (plan) => {
1553
+ if (!plan.phases || plan.phases.length <= 1) return void 0;
1554
+ const first = plan.phases[0];
1555
+ return `${first.duration ? `${first.name} (${durationWithCount(first.duration)})` : first.name}, then ${steadyStateLabel(plan)}`;
1556
+ };
1557
+ //#endregion
1558
+ //#region src/pages/components/PlanChangeCard.tsx
1559
+ const MODE_LABEL = {
1347
1560
  upgrade: "Upgrade",
1348
1561
  downgrade: "Downgrade",
1349
1562
  private: "Switch"
1350
1563
  };
1351
- const isSwitchPlanTarget = (value) => {
1352
- if (typeof value !== "object" || value === null) return false;
1353
- if (!("subscriptionId" in value) || !("plan" in value) || !("mode" in value)) return false;
1354
- return true;
1564
+ const ChangeRow = ({ change }) => {
1565
+ const arrow = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
1566
+ className: "text-muted-foreground",
1567
+ children: change.currentValue
1568
+ }), /* @__PURE__ */ jsx("span", {
1569
+ className: "text-muted-foreground",
1570
+ children: "→"
1571
+ })] });
1572
+ let icon;
1573
+ let body;
1574
+ switch (change.change) {
1575
+ case "added":
1576
+ icon = /* @__PURE__ */ jsx(CheckIcon, { className: "size-4 text-green-600 shrink-0 mt-0.5" });
1577
+ body = /* @__PURE__ */ jsxs(Fragment$1, { children: [
1578
+ /* @__PURE__ */ jsx("span", {
1579
+ className: "font-medium",
1580
+ children: change.label
1581
+ }),
1582
+ change.targetValue && change.targetValue !== "Included" ? /* @__PURE__ */ jsxs("span", {
1583
+ className: "text-muted-foreground",
1584
+ children: [": ", change.targetValue]
1585
+ }) : null,
1586
+ /* @__PURE__ */ jsx("span", {
1587
+ className: "text-green-600",
1588
+ children: " — now included"
1589
+ })
1590
+ ] });
1591
+ break;
1592
+ case "removed":
1593
+ icon = /* @__PURE__ */ jsx(XIcon, { className: "size-4 text-destructive shrink-0 mt-0.5" });
1594
+ body = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
1595
+ className: "font-medium",
1596
+ children: change.label
1597
+ }), /* @__PURE__ */ jsx("span", {
1598
+ className: "text-destructive",
1599
+ children: " — no longer included"
1600
+ })] });
1601
+ break;
1602
+ case "increase":
1603
+ icon = /* @__PURE__ */ jsx(ArrowUpIcon, { className: "size-4 text-primary shrink-0 mt-0.5" });
1604
+ body = /* @__PURE__ */ jsxs(Fragment$1, { children: [
1605
+ /* @__PURE__ */ jsxs("span", {
1606
+ className: "font-medium",
1607
+ children: [change.label, ":"]
1608
+ }),
1609
+ " ",
1610
+ arrow,
1611
+ /* @__PURE__ */ jsx("span", {
1612
+ className: "font-medium text-primary",
1613
+ children: change.targetValue
1614
+ })
1615
+ ] });
1616
+ break;
1617
+ case "decrease":
1618
+ icon = /* @__PURE__ */ jsx(ArrowDownIcon, { className: "size-4 text-amber-600 shrink-0 mt-0.5" });
1619
+ body = /* @__PURE__ */ jsxs(Fragment$1, { children: [
1620
+ /* @__PURE__ */ jsxs("span", {
1621
+ className: "font-medium",
1622
+ children: [change.label, ":"]
1623
+ }),
1624
+ " ",
1625
+ arrow,
1626
+ /* @__PURE__ */ jsx("span", {
1627
+ className: "font-medium text-amber-600",
1628
+ children: change.targetValue
1629
+ })
1630
+ ] });
1631
+ break;
1632
+ case "same":
1633
+ icon = /* @__PURE__ */ jsx(CheckIcon, { className: "size-4 text-muted-foreground shrink-0 mt-0.5" });
1634
+ body = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", { children: change.label }), change.targetValue && change.targetValue !== "Included" && /* @__PURE__ */ jsxs("span", {
1635
+ className: "text-muted-foreground",
1636
+ children: [": ", change.targetValue]
1637
+ })] });
1638
+ break;
1639
+ default:
1640
+ icon = /* @__PURE__ */ jsx(ArrowLeftRightIcon, { className: "size-4 text-muted-foreground shrink-0 mt-0.5" });
1641
+ body = /* @__PURE__ */ jsxs(Fragment$1, { children: [
1642
+ /* @__PURE__ */ jsxs("span", {
1643
+ className: "font-medium",
1644
+ children: [change.label, ":"]
1645
+ }),
1646
+ " ",
1647
+ change.currentValue !== change.targetValue && arrow,
1648
+ /* @__PURE__ */ jsx("span", {
1649
+ className: "font-medium",
1650
+ children: change.targetValue
1651
+ })
1652
+ ] });
1653
+ }
1654
+ return /* @__PURE__ */ jsxs("div", {
1655
+ className: "flex items-start gap-2 text-sm",
1656
+ children: [icon, /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("div", {
1657
+ className: "flex flex-wrap items-baseline gap-1",
1658
+ children: body
1659
+ }), change.tierPrices && change.tierPrices.length > 0 && /* @__PURE__ */ jsx("ul", {
1660
+ className: "text-xs text-muted-foreground mt-1 space-y-0.5",
1661
+ children: change.tierPrices.map((line) => /* @__PURE__ */ jsx("li", { children: line }, line))
1662
+ })] })]
1663
+ });
1355
1664
  };
1356
- const PlanComparisonItem = ({ comparison, subscriptionId, mode, onRequestChange, isSwitching }) => {
1357
- const price = getPriceFromPlan(comparison.plan);
1358
- const isCustom = comparison.plan.key === "enterprise";
1359
- const displayPrice = price.monthly;
1360
- const hasChanges = comparison.quotaChanges.length > 0 || comparison.featureChanges.length > 0;
1665
+ const PlanChangeCard = ({ plan, mode, currentEntitlements, isNewerVersion, isSwitching, units, onSwitch }) => {
1666
+ const isCustom = isCustomPlan(plan);
1667
+ const priceLabel = formatPlanPrice(plan);
1668
+ const ramp = formatPhaseRampSummary(plan);
1669
+ const entitlementChanges = useMemo(() => {
1670
+ const steadyPhase = plan.phases.at(-1);
1671
+ const changes = comparePlanEntitlements(currentEntitlements, steadyPhase ? categorizeRateCards(steadyPhase.rateCards, {
1672
+ currency: plan.currency,
1673
+ units,
1674
+ planBillingCadence: plan.billingCadence
1675
+ }) : {
1676
+ quotas: [],
1677
+ features: []
1678
+ });
1679
+ return [...changes.filter((c) => c.change !== "removed"), ...changes.filter((c) => c.change === "removed")];
1680
+ }, [
1681
+ plan,
1682
+ currentEntitlements,
1683
+ units
1684
+ ]);
1361
1685
  return /* @__PURE__ */ jsxs("div", {
1362
1686
  className: "border rounded-lg p-4",
1363
- children: [/* @__PURE__ */ jsxs("div", {
1364
- className: "flex items-center justify-between mb-3",
1365
- children: [/* @__PURE__ */ jsxs("div", {
1366
- className: "flex items-baseline gap-2 flex-wrap",
1687
+ children: [
1688
+ /* @__PURE__ */ jsxs("div", {
1689
+ className: "flex items-center justify-between gap-3 mb-2",
1367
1690
  children: [/* @__PURE__ */ jsxs("div", {
1368
- className: "flex items-center gap-2",
1369
- children: [/* @__PURE__ */ jsx("h4", {
1370
- className: "font-semibold text-foreground",
1371
- children: comparison.plan.name
1372
- }), comparison.isNewerVersion && /* @__PURE__ */ jsx(Badge, {
1373
- variant: "outline",
1374
- className: "rounded-full border-primary/30 bg-primary/10 text-primary font-medium",
1375
- children: "New version"
1691
+ className: "flex items-baseline gap-2 flex-wrap",
1692
+ children: [/* @__PURE__ */ jsxs("div", {
1693
+ className: "flex items-center gap-2",
1694
+ children: [/* @__PURE__ */ jsx("h4", {
1695
+ className: "font-semibold text-foreground",
1696
+ children: plan.name
1697
+ }), isNewerVersion && /* @__PURE__ */ jsx(Badge, {
1698
+ variant: "outline",
1699
+ className: "rounded-full border-primary/30 bg-primary/10 text-primary font-medium",
1700
+ children: "New version"
1701
+ })]
1702
+ }), isCustom ? /* @__PURE__ */ jsx("span", {
1703
+ className: "text-primary font-medium",
1704
+ children: "Custom"
1705
+ }) : /* @__PURE__ */ jsx(PlanPriceTag, {
1706
+ label: priceLabel,
1707
+ currency: plan.currency,
1708
+ billingCadence: plan.billingCadence
1376
1709
  })]
1377
- }), isCustom ? /* @__PURE__ */ jsx("span", {
1378
- className: "text-primary font-medium",
1379
- children: "Custom"
1380
- }) : displayPrice === 0 ? /* @__PURE__ */ jsx("span", {
1381
- className: "text-primary font-medium",
1382
- children: "Free"
1383
- }) : /* @__PURE__ */ jsxs("span", {
1384
- className: "text-primary font-medium text-lg",
1385
- children: [
1386
- formatPrice(displayPrice, comparison.plan.currency),
1387
- "/",
1388
- formatDuration(comparison.plan.billingCadence)
1389
- ]
1710
+ }), isCustom ? /* @__PURE__ */ jsx(Button$1, {
1711
+ variant: "default",
1712
+ size: "sm",
1713
+ children: "Contact Sales"
1714
+ }) : /* @__PURE__ */ jsx(Button$1, {
1715
+ variant: mode === "upgrade" ? "default" : "outline",
1716
+ onClick: onSwitch,
1717
+ size: "sm",
1718
+ disabled: isSwitching,
1719
+ children: MODE_LABEL[mode]
1390
1720
  })]
1391
- }), isCustom ? /* @__PURE__ */ jsx(Button$1, {
1392
- variant: "default",
1393
- size: "sm",
1394
- children: "Contact Sales"
1395
- }) : /* @__PURE__ */ jsx(Button$1, {
1396
- variant: mode === "upgrade" ? "default" : "outline",
1397
- onClick: () => onRequestChange({
1398
- subscriptionId,
1399
- plan: comparison.plan,
1400
- mode
1401
- }),
1402
- size: "sm",
1403
- disabled: isSwitching,
1404
- children: modeLabelMap[mode]
1405
- })]
1406
- }), hasChanges && /* @__PURE__ */ jsxs("div", {
1407
- className: "space-y-1.5",
1408
- children: [comparison.quotaChanges.map((quota) => /* @__PURE__ */ jsxs("div", {
1409
- className: "flex items-center gap-2 text-sm",
1410
- children: [
1411
- /* @__PURE__ */ jsx(ChangeIndicator, { change: quota.change }),
1412
- /* @__PURE__ */ jsxs("span", {
1413
- className: "font-medium",
1414
- children: [quota.name, ":"]
1415
- }),
1416
- quota.change === "same" ? /* @__PURE__ */ jsxs("span", {
1417
- className: "text-muted-foreground",
1418
- children: [
1419
- (quota.newValue ?? quota.currentValue)?.toLocaleString(),
1420
- "/",
1421
- quota.period
1422
- ]
1423
- }) : quota.change === "added" ? /* @__PURE__ */ jsx("span", {
1424
- className: "text-green-600",
1425
- children: "Now included"
1426
- }) : quota.change === "removed" ? /* @__PURE__ */ jsx("span", {
1427
- className: "text-destructive",
1428
- children: "No longer included"
1429
- }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
1430
- /* @__PURE__ */ jsxs("span", {
1431
- className: "text-muted-foreground",
1432
- children: [
1433
- quota.currentValue?.toLocaleString(),
1434
- "/",
1435
- quota.period
1436
- ]
1437
- }),
1438
- /* @__PURE__ */ jsx("span", {
1439
- className: "text-muted-foreground",
1440
- children: "→"
1441
- }),
1442
- /* @__PURE__ */ jsxs("span", {
1443
- className: cn("font-medium", quota.change === "increase" ? "text-primary" : "text-amber-600"),
1444
- children: [
1445
- quota.newValue?.toLocaleString(),
1446
- "/",
1447
- quota.period
1448
- ]
1449
- })
1450
- ] })
1451
- ]
1452
- }, quota.key)), comparison.featureChanges.map((feature) => /* @__PURE__ */ jsx("div", {
1453
- className: "flex items-center gap-2 text-sm",
1454
- children: feature.change === "same" ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(CheckIcon, { className: "w-4 h-4 text-green-600 shrink-0" }), /* @__PURE__ */ jsxs("span", {
1455
- className: "text-muted-foreground",
1456
- children: [feature.name, typeof feature.newValue === "string" ? `: ${feature.newValue}` : typeof feature.currentValue === "string" ? `: ${feature.currentValue}` : ""]
1457
- })] }) : feature.change === "added" ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
1458
- /* @__PURE__ */ jsx(CheckIcon, { className: "w-4 h-4 text-green-600 shrink-0" }),
1459
- /* @__PURE__ */ jsx("span", {
1460
- className: "text-muted-foreground font-medium",
1461
- children: feature.name
1462
- }),
1463
- /* @__PURE__ */ jsx("span", {
1464
- className: "text-green-600",
1465
- children: "—"
1466
- }),
1467
- /* @__PURE__ */ jsx("span", {
1468
- className: "text-green-600",
1469
- children: "Now included"
1470
- })
1471
- ] }) : feature.change === "removed" ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
1472
- /* @__PURE__ */ jsx(XIcon, { className: "w-4 h-4 text-destructive shrink-0" }),
1473
- /* @__PURE__ */ jsx("span", {
1474
- className: "font-medium",
1475
- children: feature.name
1476
- }),
1477
- /* @__PURE__ */ jsx("span", {
1478
- className: "text-destructive",
1479
- children: "—"
1480
- }),
1481
- /* @__PURE__ */ jsx("span", {
1482
- className: "text-destructive",
1483
- children: "No longer included"
1484
- })
1485
- ] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
1486
- /* @__PURE__ */ jsx(ChangeIndicator, { change: feature.change }),
1487
- /* @__PURE__ */ jsxs("span", {
1488
- className: "",
1489
- children: [feature.name, ":"]
1490
- }),
1491
- /* @__PURE__ */ jsx("span", {
1492
- className: "text-muted-foreground",
1493
- children: typeof feature.currentValue === "string" ? feature.currentValue : "Included"
1494
- }),
1495
- /* @__PURE__ */ jsx("span", {
1496
- className: "text-muted-foreground",
1497
- children: "→"
1498
- }),
1499
- /* @__PURE__ */ jsx("span", {
1500
- className: cn(feature.change === "upgraded" ? "text-green-600" : "text-destructive"),
1501
- children: typeof feature.newValue === "string" ? feature.newValue : "Included"
1502
- })
1503
- ] })
1504
- }, feature.key))]
1505
- })]
1721
+ }),
1722
+ ramp && /* @__PURE__ */ jsx("p", {
1723
+ className: "text-sm text-muted-foreground mb-2",
1724
+ children: ramp
1725
+ }),
1726
+ entitlementChanges.length > 0 && /* @__PURE__ */ jsx("div", {
1727
+ className: "space-y-1.5",
1728
+ children: entitlementChanges.map((change) => /* @__PURE__ */ jsx(ChangeRow, { change }, `${change.kind}:${change.key}`))
1729
+ })
1730
+ ]
1506
1731
  });
1507
1732
  };
1733
+ //#endregion
1734
+ //#region src/pages/subscriptions/SwitchPlanModal.tsx
1735
+ const isPrivatePlan = (plan) => plan.metadata?.zuplo_private_plan === "true";
1736
+ const planVersion = (plan) => plan.version ?? 1;
1737
+ const isNewerPlanVersion = (subscribedPlan, target) => target.key === subscribedPlan.key && planVersion(target) > planVersion(subscribedPlan);
1738
+ const resolveIsUpgrade = ({ target, targetIndex, subscribedPlan, currentIndex }) => {
1739
+ if (target.key === subscribedPlan.key) return planVersion(target) > planVersion(subscribedPlan);
1740
+ return targetIndex >= currentIndex;
1741
+ };
1742
+ const isSwitchPlanTarget = (value) => typeof value === "object" && value !== null && "subscriptionId" in value && "plan" in value && "mode" in value;
1508
1743
  const SwitchPlanModal = ({ subscription, children }) => {
1509
1744
  const [open, setOpen] = useState(false);
1510
1745
  const { data: plansData } = usePlans();
@@ -1539,6 +1774,27 @@ const SwitchPlanModal = ({ subscription, children }) => {
1539
1774
  }
1540
1775
  });
1541
1776
  const subscribedPlan = subscription.plan;
1777
+ const currentEntitlements = useMemo(() => {
1778
+ const currency = subscription.currency ?? subscribedPlan.currency;
1779
+ const activePhase = getActivePhase(subscription);
1780
+ if (activePhase && (activePhase.items?.length ?? 0) > 0) return categorizeSubscriptionItems(activePhase.items, {
1781
+ currency,
1782
+ units: pricing?.units
1783
+ });
1784
+ const lastPhase = subscribedPlan.phases?.at(-1);
1785
+ return lastPhase ? categorizeRateCards(lastPhase.rateCards, {
1786
+ currency,
1787
+ units: pricing?.units,
1788
+ planBillingCadence: subscribedPlan.billingCadence
1789
+ }) : {
1790
+ quotas: [],
1791
+ features: []
1792
+ };
1793
+ }, [
1794
+ subscription,
1795
+ subscribedPlan,
1796
+ pricing?.units
1797
+ ]);
1542
1798
  const { upgrades, downgrades, privatePlans } = useMemo(() => {
1543
1799
  const catalogItems = plansData?.items;
1544
1800
  if (!catalogItems?.length) return {
@@ -1546,13 +1802,12 @@ const SwitchPlanModal = ({ subscription, children }) => {
1546
1802
  downgrades: [],
1547
1803
  privatePlans: []
1548
1804
  };
1549
- const planForComparison = resolvePlanForComparison(subscribedPlan, catalogItems);
1550
1805
  const currentIndex = catalogItems.some((p) => p.id === subscribedPlan.id) ? catalogItems.findIndex((p) => p.id === subscribedPlan.id) : -1;
1551
1806
  const subscribedIsPrivate = isPrivatePlan(subscribedPlan);
1552
- const allComparisons = catalogItems.flatMap((plan, targetIndex) => {
1807
+ const entries = catalogItems.flatMap((plan, targetIndex) => {
1553
1808
  if (plan.id === subscribedPlan.id) return [];
1554
1809
  return [{
1555
- ...comparePlans(planForComparison, plan, currentIndex, targetIndex, pricing?.units),
1810
+ plan,
1556
1811
  isUpgrade: resolveIsUpgrade({
1557
1812
  target: plan,
1558
1813
  targetIndex,
@@ -1563,20 +1818,32 @@ const SwitchPlanModal = ({ subscription, children }) => {
1563
1818
  }];
1564
1819
  });
1565
1820
  if (subscribedIsPrivate) return {
1566
- upgrades: allComparisons.filter((c) => !isPrivatePlan(c.plan)),
1821
+ upgrades: entries.filter((c) => !isPrivatePlan(c.plan)),
1567
1822
  downgrades: [],
1568
- privatePlans: allComparisons.filter((c) => isPrivatePlan(c.plan))
1823
+ privatePlans: entries.filter((c) => isPrivatePlan(c.plan))
1569
1824
  };
1570
1825
  return {
1571
- upgrades: allComparisons.filter((c) => c.isUpgrade && !isPrivatePlan(c.plan)),
1572
- downgrades: allComparisons.filter((c) => !c.isUpgrade && !isPrivatePlan(c.plan)),
1573
- privatePlans: allComparisons.filter((c) => isPrivatePlan(c.plan))
1826
+ upgrades: entries.filter((c) => c.isUpgrade && !isPrivatePlan(c.plan)),
1827
+ downgrades: entries.filter((c) => !c.isUpgrade && !isPrivatePlan(c.plan)),
1828
+ privatePlans: entries.filter((c) => isPrivatePlan(c.plan))
1574
1829
  };
1575
- }, [
1576
- plansData?.items,
1577
- subscribedPlan,
1578
- pricing?.units
1579
- ]);
1830
+ }, [plansData?.items, subscribedPlan]);
1831
+ const renderCards = (entries, mode) => /* @__PURE__ */ jsx("div", {
1832
+ className: "space-y-3",
1833
+ children: entries.map(({ plan, isNewerVersion }) => /* @__PURE__ */ jsx(PlanChangeCard, {
1834
+ plan,
1835
+ mode,
1836
+ currentEntitlements,
1837
+ isNewerVersion,
1838
+ isSwitching: switchPlanMutation.isPending,
1839
+ units: pricing?.units,
1840
+ onSwitch: () => switchPlanMutation.mutate({
1841
+ subscriptionId: subscription.id,
1842
+ plan,
1843
+ mode
1844
+ })
1845
+ }, plan.id))
1846
+ });
1580
1847
  return /* @__PURE__ */ jsxs(Dialog, {
1581
1848
  open,
1582
1849
  onOpenChange: setOpen,
@@ -1605,12 +1872,9 @@ const SwitchPlanModal = ({ subscription, children }) => {
1605
1872
  children: switchPlanMutation.error.message
1606
1873
  })
1607
1874
  }),
1608
- /* @__PURE__ */ jsx(Item, {
1609
- variant: "outline",
1610
- children: /* @__PURE__ */ jsxs(ItemContent, { children: [/* @__PURE__ */ jsx(ItemTitle, { children: "Current Plan" }), /* @__PURE__ */ jsx(ItemDescription, {
1611
- className: "text-lg font-bold",
1612
- children: subscribedPlan.name
1613
- })] })
1875
+ /* @__PURE__ */ jsx(CurrentPlanBaseline, {
1876
+ subscription,
1877
+ units: pricing?.units
1614
1878
  }),
1615
1879
  upgrades.length > 0 && /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("div", {
1616
1880
  className: "flex items-center justify-between mb-3",
@@ -1624,16 +1888,7 @@ const SwitchPlanModal = ({ subscription, children }) => {
1624
1888
  className: "text-sm text-muted-foreground",
1625
1889
  children: "Takes effect immediately"
1626
1890
  })]
1627
- }), /* @__PURE__ */ jsx("div", {
1628
- className: "space-y-3",
1629
- children: upgrades.map((comparison) => /* @__PURE__ */ jsx(PlanComparisonItem, {
1630
- comparison,
1631
- subscriptionId: subscription.id,
1632
- mode: "upgrade",
1633
- onRequestChange: (target) => switchPlanMutation.mutate(target),
1634
- isSwitching: switchPlanMutation.isPending
1635
- }, comparison.plan.id))
1636
- })] }),
1891
+ }), renderCards(upgrades, "upgrade")] }),
1637
1892
  downgrades.length > 0 && /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("div", {
1638
1893
  className: "flex items-center justify-between mb-3",
1639
1894
  children: [/* @__PURE__ */ jsxs("div", {
@@ -1644,18 +1899,9 @@ const SwitchPlanModal = ({ subscription, children }) => {
1644
1899
  })]
1645
1900
  }), /* @__PURE__ */ jsx("span", {
1646
1901
  className: "text-sm text-muted-foreground",
1647
- children: "Takes effect next billing cycle"
1902
+ children: "Takes effect at your next billing cycle"
1648
1903
  })]
1649
- }), /* @__PURE__ */ jsx("div", {
1650
- className: "space-y-3",
1651
- children: downgrades.map((comparison) => /* @__PURE__ */ jsx(PlanComparisonItem, {
1652
- comparison,
1653
- subscriptionId: subscription.id,
1654
- mode: "downgrade",
1655
- onRequestChange: (target) => switchPlanMutation.mutate(target),
1656
- isSwitching: switchPlanMutation.isPending
1657
- }, comparison.plan.id))
1658
- })] }),
1904
+ }), renderCards(downgrades, "downgrade")] }),
1659
1905
  privatePlans.length > 0 && /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("div", {
1660
1906
  className: "flex items-center justify-between mb-3",
1661
1907
  children: [/* @__PURE__ */ jsxs("div", {
@@ -1668,16 +1914,7 @@ const SwitchPlanModal = ({ subscription, children }) => {
1668
1914
  className: "text-sm text-muted-foreground",
1669
1915
  children: "Takes effect immediately"
1670
1916
  })]
1671
- }), /* @__PURE__ */ jsx("div", {
1672
- className: "space-y-3",
1673
- children: privatePlans.map((comparison) => /* @__PURE__ */ jsx(PlanComparisonItem, {
1674
- comparison,
1675
- subscriptionId: subscription.id,
1676
- mode: "private",
1677
- onRequestChange: (target) => switchPlanMutation.mutate(target),
1678
- isSwitching: switchPlanMutation.isPending
1679
- }, comparison.plan.id))
1680
- })] })
1917
+ }), renderCards(privatePlans, "private")] })
1681
1918
  ]
1682
1919
  })]
1683
1920
  }) })]
@@ -1733,7 +1970,7 @@ const ManageSubscription = ({ subscription, planName }) => {
1733
1970
  variant: "outline",
1734
1971
  size: "sm",
1735
1972
  asChild: true,
1736
- children: /* @__PURE__ */ jsxs(Link, {
1973
+ children: /* @__PURE__ */ jsxs(Link$1, {
1737
1974
  to: "/pricing",
1738
1975
  children: [/* @__PURE__ */ jsx(RefreshCcw, { className: "w-4 h-4 mr-2" }), "New subscription"]
1739
1976
  })
@@ -1755,7 +1992,7 @@ const ManageSubscription = ({ subscription, planName }) => {
1755
1992
  asChild: true,
1756
1993
  size: "sm",
1757
1994
  variant: "secondary",
1758
- children: /* @__PURE__ */ jsx(Link, {
1995
+ children: /* @__PURE__ */ jsx(Link$1, {
1759
1996
  to: "/manage-payment",
1760
1997
  children: /* @__PURE__ */ jsxs("div", {
1761
1998
  className: "flex items-center gap-2",
@@ -1780,140 +2017,14 @@ const ManageSubscription = ({ subscription, planName }) => {
1780
2017
  //#region src/pages/subscriptions/SubscriptionPlanDetails.tsx
1781
2018
  const detailLabelClassName = "text-sm font-semibold tracking-wide mb-1";
1782
2019
  const sectionLabelClassName = "text-base font-semibold tracking-wide mb-3 mt-2";
1783
- const formatDate$1 = (dateString) => {
1784
- return new Date(dateString).toLocaleDateString("en-US", {
1785
- month: "short",
1786
- day: "numeric",
1787
- year: "numeric"
1788
- });
1789
- };
1790
- const formatDateRange = (from, to) => `${formatDate$1(from)} – ${formatDate$1(to)}`;
1791
- const formatNumber = (value) => value.toLocaleString("en-US");
1792
- const getTierPricesFromItem = (item, currency, units) => {
1793
- if (item.price?.type !== "tiered") return;
1794
- const tiers = item.price.tiers;
1795
- if (!tiers || tiers.length <= 1) return;
1796
- const unitLabel = units?.[item.key] ?? units?.[item.featureKey] ?? "unit";
1797
- return formatTieredPriceBreakdown({
1798
- tiers: tiers.map((t) => ({
1799
- upToAmount: t.upToAmount,
1800
- unitPriceAmount: t.unitPrice?.amount,
1801
- flatPriceAmount: t.flatPrice?.amount
1802
- })),
1803
- currency,
1804
- unitLabel,
1805
- includedLabel: "Included"
1806
- });
1807
- };
1808
- const hasPricedFirstTier = (item) => {
1809
- const firstTier = item.price?.tiers?.[0];
1810
- if (!firstTier) return false;
1811
- const flat = parseFloat(firstTier.flatPrice?.amount ?? "0");
1812
- const unit = parseFloat(firstTier.unitPrice?.amount ?? "0");
1813
- return flat > 0 || unit > 0;
1814
- };
1815
- const getEntitlementsFromItems = (items, currency, units, fallbackBillingCadence) => {
1816
- const features = [];
1817
- for (const item of items) {
1818
- const entitlement = item.included?.entitlement;
1819
- if (!entitlement) continue;
1820
- if (entitlement.type === "metered" && entitlement.issueAfterReset != null) {
1821
- const cadence = entitlement.usagePeriod?.intervalISO ?? item.billingCadence ?? fallbackBillingCadence;
1822
- const tierPrices = getTierPricesFromItem(item, currency, units);
1823
- const suppressLimit = hasPricedFirstTier(item) && !!tierPrices && tierPrices.length > 0;
1824
- features.push({
1825
- entitlementType: "metered",
1826
- key: item.featureKey ?? item.key,
1827
- name: item.name ?? item.featureKey ?? item.key,
1828
- limit: suppressLimit ? void 0 : entitlement.issueAfterReset,
1829
- period: suppressLimit ? void 0 : cadence ? formatDuration(cadence) : "month",
1830
- tierPrices
1831
- });
1832
- continue;
1833
- }
1834
- if (entitlement.type === "boolean") {
1835
- features.push({
1836
- entitlementType: "boolean",
1837
- key: item.featureKey ?? item.key,
1838
- name: item.name ?? item.featureKey ?? item.key
1839
- });
1840
- continue;
1841
- }
1842
- if (entitlement.type === "static") {
1843
- const base = {
1844
- key: item.featureKey ?? item.key,
1845
- name: item.name ?? item.featureKey ?? item.key
1846
- };
1847
- if (!entitlement.config) {
1848
- features.push({
1849
- entitlementType: "static",
1850
- ...base
1851
- });
1852
- continue;
1853
- }
1854
- features.push({
1855
- entitlementType: "static",
1856
- ...base,
1857
- value: formatStaticEntitlementConfig(entitlement.config)
1858
- });
1859
- }
1860
- }
1861
- return { features };
1862
- };
1863
- const getPhaseRows = (opts) => {
1864
- const { subscription, currency, units } = opts;
1865
- const phases = [...subscription.phases].sort((a, b) => new Date(a.activeFrom).getTime() - new Date(b.activeFrom).getTime());
1866
- const phaseGroups = [];
1867
- for (const phase of phases) {
1868
- const { features } = getEntitlementsFromItems(phase.items ?? [], currency, units, subscription.billingCadence);
1869
- const rows = [];
1870
- for (const f of features) rows.push({
1871
- key: f.key,
1872
- name: f.name,
1873
- entitlementType: f.entitlementType,
1874
- limit: f.entitlementType === "metered" ? f.limit : void 0,
1875
- period: f.entitlementType === "metered" ? f.period : void 0,
1876
- tierPrices: f.entitlementType === "metered" ? f.tierPrices : void 0,
1877
- value: f.entitlementType === "static" ? f.value : void 0,
1878
- phaseId: phase.id,
1879
- activeFrom: phase.activeFrom,
1880
- activeTo: phase.activeTo
1881
- });
1882
- if (rows.length > 0) phaseGroups.push({
1883
- id: phase.id,
1884
- name: phase.name,
1885
- activeFrom: phase.activeFrom,
1886
- activeTo: phase.activeTo,
1887
- rows
1888
- });
1889
- }
1890
- return { phaseGroups };
1891
- };
1892
- const formatActiveRange = (activeFrom, activeTo) => {
1893
- if (!activeTo) return `Starts ${formatDate$1(activeFrom)}`;
1894
- return `${formatDate$1(activeFrom)} – ${formatDate$1(activeTo)}`;
1895
- };
2020
+ const formatDateTimeRange = (from, to) => `${formatDateTime(from)} – ${formatDateTime(to)}`;
1896
2021
  const SubscriptionPlanDetails = ({ subscription }) => {
1897
2022
  const { pricing } = useMonetizationConfig();
1898
2023
  const plan = subscription.plan;
1899
- const currency = subscription.currency ?? plan.currency;
1900
- const priceInfo = getPriceFromPlan(plan);
2024
+ const view = getSubscriptionPlanView(subscription, { units: pricing?.units });
2025
+ const { priceLabel } = view;
1901
2026
  const taxLegendSentence = planHasDefaultTaxBehavior(plan) ? subscriptionTaxLegendSentence(plan.defaultTaxConfig?.behavior ?? "") : void 0;
1902
- const primaryPrice = priceInfo.monthly === 0 && priceInfo.yearly === 0 ? /* @__PURE__ */ jsx("span", {
1903
- className: "text-primary font-medium",
1904
- children: "Free"
1905
- }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
1906
- className: "text-primary font-medium text-lg",
1907
- children: formatPrice(priceInfo.monthly, currency)
1908
- }), /* @__PURE__ */ jsxs("span", {
1909
- className: "text-muted-foreground",
1910
- children: [" / ", formatDuration(plan.billingCadence)]
1911
- })] });
1912
- const { phaseGroups } = getPhaseRows({
1913
- subscription,
1914
- currency,
1915
- units: pricing?.units
1916
- });
2027
+ const hasEntitlements = hasSubscriptionEntitlements(view);
1917
2028
  return /* @__PURE__ */ jsxs("div", {
1918
2029
  className: "space-y-4",
1919
2030
  children: [/* @__PURE__ */ jsx(Heading, {
@@ -1939,14 +2050,19 @@ const SubscriptionPlanDetails = ({ subscription }) => {
1939
2050
  children: "Active since"
1940
2051
  }), /* @__PURE__ */ jsx("dd", {
1941
2052
  className: "text-foreground",
1942
- children: formatDate$1(subscription.activeFrom)
2053
+ children: formatDateTime(subscription.activeFrom)
1943
2054
  })] }),
1944
2055
  /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
1945
2056
  className: detailLabelClassName,
1946
2057
  children: "Price"
1947
2058
  }), /* @__PURE__ */ jsxs("dd", { children: [/* @__PURE__ */ jsx("div", {
1948
2059
  className: "flex flex-wrap items-baseline gap-1",
1949
- children: primaryPrice
2060
+ children: /* @__PURE__ */ jsx(PlanPriceTag, {
2061
+ label: priceLabel,
2062
+ currency: view.currency,
2063
+ billingCadence: view.billingCadence,
2064
+ description: true
2065
+ })
1950
2066
  }), taxLegendSentence ? /* @__PURE__ */ jsx("p", {
1951
2067
  className: "text-xs text-muted-foreground mt-1",
1952
2068
  children: taxLegendSentence
@@ -1956,53 +2072,20 @@ const SubscriptionPlanDetails = ({ subscription }) => {
1956
2072
  children: "Current period"
1957
2073
  }), /* @__PURE__ */ jsx("dd", {
1958
2074
  className: "text-foreground",
1959
- children: subscription.alignment?.currentAlignedBillingPeriod ? formatDateRange(subscription.alignment.currentAlignedBillingPeriod.from, subscription.alignment.currentAlignedBillingPeriod.to) : "—"
2075
+ children: subscription.alignment?.currentAlignedBillingPeriod ? formatDateTimeRange(subscription.alignment.currentAlignedBillingPeriod.from, subscription.alignment.currentAlignedBillingPeriod.to) : "—"
1960
2076
  })] })
1961
2077
  ]
1962
- }), phaseGroups.length > 0 ? /* @__PURE__ */ jsx("div", {
1963
- className: "space-y-5 pt-2 border-t border-border",
1964
- children: /* @__PURE__ */ jsxs("div", {
1965
- className: "space-y-2",
1966
- children: [/* @__PURE__ */ jsx("p", {
1967
- className: cn(sectionLabelClassName, "mb-5"),
1968
- children: "Entitlements"
1969
- }), /* @__PURE__ */ jsx("div", {
1970
- className: "space-y-5",
1971
- children: phaseGroups.map((phase) => /* @__PURE__ */ jsxs("div", {
1972
- className: "space-y-3",
1973
- children: [phaseGroups.length > 1 ? /* @__PURE__ */ jsxs("div", {
1974
- className: "text-sm font-medium text-card-foreground",
1975
- children: [phase.name, /* @__PURE__ */ jsxs("span", {
1976
- className: "text-muted-foreground font-normal",
1977
- children: [
1978
- " ",
1979
- "—",
1980
- " ",
1981
- formatActiveRange(phase.activeFrom, phase.activeTo)
1982
- ]
1983
- })]
1984
- }) : null, /* @__PURE__ */ jsx("ul", {
1985
- className: "space-y-3",
1986
- children: phase.rows.map((row) => /* @__PURE__ */ jsx("li", {
1987
- className: "text-sm",
1988
- children: /* @__PURE__ */ jsxs("div", {
1989
- className: "flex flex-col gap-1",
1990
- children: [/* @__PURE__ */ jsxs("div", {
1991
- className: "text-foreground font-medium",
1992
- children: [row.name, row.entitlementType === "static" && row.value !== void 0 ? `: ${row.value}` : ""]
1993
- }), /* @__PURE__ */ jsx("div", {
1994
- className: "text-muted-foreground",
1995
- children: row.entitlementType === "metered" && (row.limit != null || row.tierPrices && row.tierPrices.length > 0) ? /* @__PURE__ */ jsxs(Fragment$1, { children: [row.limit != null && (!row.tierPrices || row.tierPrices.length === 0) ? /* @__PURE__ */ jsxs(Fragment$1, { children: [formatNumber(row.limit), row.period ? ` / ${row.period}` : ""] }) : null, row.tierPrices && row.tierPrices.length > 0 ? /* @__PURE__ */ jsx("ul", {
1996
- className: "text-xs space-y-0.5",
1997
- children: row.tierPrices.map((line) => /* @__PURE__ */ jsx("li", { children: line }, line))
1998
- }) : null] }) : row.entitlementType === "static" && row.value !== void 0 ? null : "Included"
1999
- })]
2000
- })
2001
- }, `${row.key}:${row.phaseId}`))
2002
- })]
2003
- }, phase.id))
2004
- })]
2005
- })
2078
+ }), hasEntitlements ? /* @__PURE__ */ jsxs("div", {
2079
+ className: "space-y-2 pt-2 border-t border-border",
2080
+ children: [/* @__PURE__ */ jsx("p", {
2081
+ className: sectionLabelClassName,
2082
+ children: "What's included"
2083
+ }), /* @__PURE__ */ jsx(SubscriptionEntitlements, {
2084
+ view,
2085
+ currency: view.currency,
2086
+ billingCadence: view.billingCadence,
2087
+ units: pricing?.units
2088
+ })]
2006
2089
  }) : null]
2007
2090
  })] })]
2008
2091
  });
@@ -2070,11 +2153,7 @@ const UsageItem = ({ meter, item, subscription, featureKey }) => {
2070
2153
  }) })
2071
2154
  ]
2072
2155
  }),
2073
- /* @__PURE__ */ jsxs(CardTitle, { children: [
2074
- item?.name ?? featureKey,
2075
- " ",
2076
- item?.price?.amount
2077
- ] })
2156
+ /* @__PURE__ */ jsx(CardTitle, { children: item?.name ?? featureKey })
2078
2157
  ] }), /* @__PURE__ */ jsxs(CardContent, {
2079
2158
  className: "space-y-2",
2080
2159
  children: [
@@ -2142,7 +2221,7 @@ const Usage = ({ usage, isFetching, currentItems, subscription, isPendingFirstPa
2142
2221
  variant: "destructive",
2143
2222
  size: "xs",
2144
2223
  asChild: true,
2145
- children: /* @__PURE__ */ jsx(Link, {
2224
+ children: /* @__PURE__ */ jsx(Link$1, {
2146
2225
  to: "/manage-payment",
2147
2226
  target: "_blank",
2148
2227
  children: "Manage billing"
@@ -2254,7 +2333,7 @@ const SubscriptionsList = ({ subscriptions, activeSubscriptionId }) => {
2254
2333
  };
2255
2334
  const SubscriptionItem = ({ subscription, isSelected, isExpired }) => {
2256
2335
  const willExpire = subscription.activeTo && new Date(subscription.activeTo) > /* @__PURE__ */ new Date();
2257
- return /* @__PURE__ */ jsx(Link$1, {
2336
+ return /* @__PURE__ */ jsx(Link, {
2258
2337
  to: `/subscriptions?subscriptionId=${encodeURIComponent(subscription.id)}`,
2259
2338
  children: /* @__PURE__ */ jsx(Item, {
2260
2339
  size: "sm",