@zuplo/zudoku-plugin-monetization 0.0.42 → 0.0.44

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,6 +1,6 @@
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";
1
+ import { D as formatDurationAdjective, E as formatDuration, O as formatDurationInterval, T as formatPrice, _ as formatPlanPrice, a as planHasDefaultTaxBehavior, b as sameEntitlementSet, c as getPlanPriceSchedule, d as PlanEntitlements, f as PlanPhaseHeader, l as PlanPriceTag, o as subscriptionTaxLegendSentence, p as EntitlementList, r as isCustomPlan, t as PricingTable, u as PlanPriceSchedule, w as formatMinorCurrencyAmount, x as categorizeRateCards, y as comparePlanEntitlements } from "./PricingTable-WkG2n7V-.mjs";
2
2
  import { cn, createPlugin, joinUrl, throwIfProblemJson } from "zudoku";
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";
3
+ import { AlertTriangleIcon, ArrowDownIcon, ArrowLeftRightIcon, ArrowUpIcon, CalendarIcon, CheckCheckIcon, CheckIcon, ChevronDownIcon, CircleSlashIcon, ClockIcon, CreditCardIcon, Grid2x2XIcon, InfoIcon, Loader2Icon, LockIcon, MoreVerticalIcon, RefreshCcw, RefreshCwIcon, Settings, ShieldIcon, StarsIcon, Trash2Icon, XIcon } from "zudoku/icons";
4
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";
@@ -9,6 +9,7 @@ import { createContext, use, useEffect, useMemo, useState } from "react";
9
9
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
10
10
  import { Alert, AlertAction, AlertDescription, AlertTitle } from "zudoku/ui/Alert";
11
11
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "zudoku/ui/Card";
12
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "zudoku/ui/Collapsible";
12
13
  import { Separator } from "zudoku/ui/Separator";
13
14
  import { Button as Button$1 } from "zudoku/ui/Button";
14
15
  import { DismissibleAlert, DismissibleAlertAction } from "zudoku/ui/DismissibleAlert";
@@ -256,18 +257,34 @@ const formatBillingCycle = (duration) => {
256
257
  * Plan summary shown on the checkout and plan-change confirmation pages: an
257
258
  * avatar + name/description on the left and the headline price (or
258
259
  * "Free" / "Pay as you go") plus tax and billing cadence on the right,
259
- * followed by the plan's included entitlements.
260
+ * followed by the plan's included entitlements. Multi-phase ramp plans render
261
+ * a full-width per-phase price schedule beneath the title instead of the
262
+ * single right-column price.
260
263
  *
261
264
  * The price is derived from the plan's rate cards via {@link formatPlanPrice}
262
265
  * and rendered in the plan's own billing cadence, so it stays correct for any
263
266
  * cadence (e.g. `$2.99/hour`).
267
+ *
268
+ * Pass `collapsibleDetails` to hide the "What's included" section behind a
269
+ * collapsed-by-default toggle — used on the plan-change confirmation page where
270
+ * the current and new plan are stacked and the user opens each to compare. Pass
271
+ * `label` to render a small eyebrow above the name (e.g. "Current plan").
264
272
  */
265
- const PlanSummaryCard = ({ plan, descriptionFallback, taxAmount, taxLabel, taxInclusive, units, entitlementsItemClassName }) => {
273
+ const PlanSummaryCard = ({ plan, label, descriptionFallback, taxAmount, taxLabel, taxInclusive, units, entitlementsItemClassName, collapsibleDetails = false }) => {
266
274
  const priceLabel = formatPlanPrice(plan);
275
+ const schedule = getPlanPriceSchedule(plan);
267
276
  const billingCycle = plan.billingCadence ? formatDuration(plan.billingCadence) : null;
277
+ const taxLine = taxAmount != null && /* @__PURE__ */ jsx("div", {
278
+ className: "text-sm font-normal mt-1",
279
+ children: taxInclusive ? `${formatMinorCurrencyAmount(taxAmount, plan.currency)} ${taxLabel} included` : `+ ${formatMinorCurrencyAmount(taxAmount, plan.currency)} ${taxLabel}`
280
+ });
281
+ const billedLine = billingCycle && /* @__PURE__ */ jsxs("div", {
282
+ className: "text-sm text-muted-foreground font-normal",
283
+ children: ["Billed ", formatBillingCycle(billingCycle)]
284
+ });
268
285
  return /* @__PURE__ */ jsxs(Card, {
269
286
  className: "bg-muted/50",
270
- children: [/* @__PURE__ */ jsx(CardHeader, { children: /* @__PURE__ */ jsxs(CardTitle, {
287
+ children: [/* @__PURE__ */ jsxs(CardHeader, { children: [/* @__PURE__ */ jsxs(CardTitle, {
271
288
  className: "flex justify-between items-start",
272
289
  children: [/* @__PURE__ */ jsxs("div", {
273
290
  className: "flex items-center gap-3",
@@ -276,43 +293,62 @@ const PlanSummaryCard = ({ plan, descriptionFallback, taxAmount, taxLabel, taxIn
276
293
  children: plan.name.at(0)?.toUpperCase()
277
294
  }), /* @__PURE__ */ jsxs("div", {
278
295
  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
- })]
296
+ children: [
297
+ label && /* @__PURE__ */ jsx("span", {
298
+ className: "text-xs font-medium text-muted-foreground uppercase tracking-wide",
299
+ children: label
300
+ }),
301
+ /* @__PURE__ */ jsx("span", {
302
+ className: "text-lg font-bold",
303
+ children: plan.name
304
+ }),
305
+ /* @__PURE__ */ jsx("span", {
306
+ className: "text-sm font-normal text-muted-foreground",
307
+ children: plan.description || descriptionFallback
308
+ })
309
+ ]
286
310
  })]
287
- }), /* @__PURE__ */ jsxs("div", {
311
+ }), !schedule && /* @__PURE__ */ jsxs("div", {
288
312
  className: "text-right",
289
313
  children: [/* @__PURE__ */ jsx(PlanPriceTag, {
290
314
  label: priceLabel,
291
315
  currency: plan.currency,
292
316
  size: "lg",
293
317
  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
- })] })]
318
+ }), priceLabel.type === "priced" && /* @__PURE__ */ jsxs(Fragment$1, { children: [taxLine, billedLine] })]
301
319
  })]
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, {
320
+ }), schedule && /* @__PURE__ */ jsxs("div", {
321
+ className: "mt-3 font-normal",
322
+ children: [/* @__PURE__ */ jsx(PlanPriceSchedule, {
323
+ schedule,
324
+ currency: plan.currency,
325
+ billingCadence: plan.billingCadence
326
+ }), /* @__PURE__ */ jsxs("div", {
327
+ className: "text-right",
328
+ children: [taxLine, billedLine]
329
+ })]
330
+ })] }), /* @__PURE__ */ jsxs(CardContent, { children: [/* @__PURE__ */ jsx(Separator, {}), collapsibleDetails ? /* @__PURE__ */ jsxs(Collapsible, { children: [/* @__PURE__ */ jsxs(CollapsibleTrigger, {
331
+ className: "group flex w-full items-center justify-between text-sm font-medium mt-3",
332
+ children: ["Plan details", /* @__PURE__ */ jsx(ChevronDownIcon, { className: "size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" })]
333
+ }), /* @__PURE__ */ jsx(CollapsibleContent, {
334
+ className: "mt-3",
335
+ children: /* @__PURE__ */ jsx(PlanEntitlements, {
309
336
  phases: plan.phases,
310
337
  currency: plan.currency,
311
338
  billingCadence: plan.billingCadence,
312
339
  units,
313
340
  itemClassName: entitlementsItemClassName
314
341
  })
315
- ] })]
342
+ })] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", {
343
+ className: "text-sm font-medium mb-3 mt-3",
344
+ children: "What's included:"
345
+ }), /* @__PURE__ */ jsx(PlanEntitlements, {
346
+ phases: plan.phases,
347
+ currency: plan.currency,
348
+ billingCadence: plan.billingCadence,
349
+ units,
350
+ itemClassName: entitlementsItemClassName
351
+ })] })] })]
316
352
  });
317
353
  };
318
354
  //#endregion
@@ -787,76 +823,121 @@ const getSubscriptionPlanView = (subscription, options) => {
787
823
  };
788
824
  /** Whether a resolved view has any entitlements to render. */
789
825
  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
826
+ const phaseStatus = (phase, now) => {
827
+ if (phase.activeTo && new Date(phase.activeTo).getTime() < now) return "past";
828
+ if (new Date(phase.activeFrom).getTime() > now) return "future";
829
+ return "current";
830
+ };
792
831
  /**
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.
832
+ * Rate cards for one subscription phase: its own provisioned items (the
833
+ * authoritative quotas/fees), falling back to the catalog plan phase with the
834
+ * same `key` when a phase (typically a future one) hasn't been provisioned yet.
802
835
  */
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
- });
836
+ const phaseRateCards = (plan, phase) => phase.items.length > 0 ? subscriptionItemsToRateCards(phase.items) : plan.phases?.find((p) => p.key === phase.key)?.rateCards ?? [];
837
+ const phasesByActiveFrom = (subscription) => [...subscription.phases].sort((a, b) => new Date(a.activeFrom).getTime() - new Date(b.activeFrom).getTime());
838
+ /**
839
+ * A clean ISO-8601 duration between two instants, preferring whole calendar
840
+ * units (years/months/weeks) over raw days so it formats the same way catalog
841
+ * plan durations do (e.g. `P1W` → "week", `P3M` → "3 months") rather than as
842
+ * "7 days" / "90 days". Returns `undefined` for a non-positive span.
843
+ */
844
+ const isoDurationBetween = (from, to) => {
845
+ const a = new Date(from);
846
+ const b = new Date(to);
847
+ const ms = b.getTime() - a.getTime();
848
+ if (!(ms > 0)) return void 0;
849
+ let months = (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
850
+ const anchor = new Date(a);
851
+ anchor.setMonth(a.getMonth() + months);
852
+ if (anchor.getTime() > b.getTime()) months -= 1;
853
+ const exact = new Date(a);
854
+ exact.setMonth(a.getMonth() + months);
855
+ if (months >= 1 && exact.getTime() === b.getTime()) return months % 12 === 0 ? `P${months / 12}Y` : `P${months}M`;
856
+ const days = Math.round(ms / 864e5);
857
+ if (days >= 7 && days % 7 === 0) return `P${days / 7}W`;
858
+ return `P${days}D`;
820
859
  };
821
- //#endregion
822
- //#region src/pages/components/CurrentPlanBaseline.tsx
823
860
  /**
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}.
861
+ * The intended length of a subscription phase: the matching catalog plan
862
+ * phase's authored `duration` when available (the cleanest source), otherwise
863
+ * computed from the phase's own `activeFrom`/`activeTo`. `undefined` for an
864
+ * open-ended (final) phase the price schedule labels that one "After that".
830
865
  */
831
- const CurrentPlanBaseline = ({ subscription, units }) => {
866
+ const phaseDuration = (plan, phase) => {
867
+ const catalog = plan.phases?.find((p) => p.key === phase.key)?.duration;
868
+ if (catalog) return catalog;
869
+ if (!phase.activeTo) return void 0;
870
+ return isoDurationBetween(phase.activeFrom, phase.activeTo);
871
+ };
872
+ /**
873
+ * Resolve EVERY phase of a subscription (not just the active one) to a
874
+ * {@link SubscriptionPhaseView}, ordered by `activeFrom` and tagged
875
+ * past/current/future. Generalizes {@link getSubscriptionPlanView}: each phase's
876
+ * price and entitlements come from its own provisioned items (the authoritative
877
+ * source), falling back to the catalog plan phase with the same `key` when a
878
+ * phase (typically a future one) hasn't been provisioned yet. Powers the
879
+ * subscription details page's current + upcoming + previous phase timeline.
880
+ */
881
+ const getSubscriptionPhaseViews = (subscription, options) => {
832
882
  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
- })]
883
+ const currency = subscription.currency ?? plan.currency;
884
+ const billingCadence = subscription.billingCadence ?? plan.billingCadence;
885
+ const now = Date.now();
886
+ return phasesByActiveFrom(subscription).map((phase) => {
887
+ const rateCards = phaseRateCards(plan, phase);
888
+ return {
889
+ id: phase.id,
890
+ name: phase.name,
891
+ status: phaseStatus(phase, now),
892
+ activeFrom: phase.activeFrom,
893
+ activeTo: phase.activeTo,
894
+ priceLabel: formatPlanPrice({
895
+ ...plan,
896
+ billingCadence,
897
+ phases: [{
898
+ key: phase.key,
899
+ name: phase.name,
900
+ rateCards
901
+ }]
902
+ }),
903
+ entitlements: categorizeRateCards(rateCards, {
904
+ currency,
905
+ units: options?.units,
906
+ planBillingCadence: billingCadence
907
+ }),
908
+ billingCadence,
909
+ currency
910
+ };
858
911
  });
859
912
  };
913
+ /**
914
+ * Build a catalog-shaped {@link Plan} from a subscription's *own* current and
915
+ * future phases (past phases dropped), so the plan-change confirmation page can
916
+ * render the current subscription with the exact same pricing-table card
917
+ * ({@link formatPlanPrice} / `getPlanPriceSchedule` / `PlanEntitlements`) used
918
+ * for the new plan. Each phase's rate cards come from its provisioned items
919
+ * (the authoritative source) — the embedded `subscription.plan` snapshot is
920
+ * unreliable here (it can arrive with `phases: []`), so we never read its
921
+ * phases for pricing. Each phase's `duration` is carried so the price schedule
922
+ * labels read "First 3 months" / "After that" exactly like the new plan, rather
923
+ * than falling back to the phase name (see {@link phaseDuration}).
924
+ */
925
+ const subscriptionToCurrentPlan = (subscription) => {
926
+ const plan = subscription.plan;
927
+ const now = Date.now();
928
+ const phases = phasesByActiveFrom(subscription).filter((p) => !(p.activeTo && new Date(p.activeTo).getTime() < now)).map((phase) => ({
929
+ key: phase.key,
930
+ name: phase.name,
931
+ duration: phaseDuration(plan, phase),
932
+ rateCards: phaseRateCards(plan, phase)
933
+ }));
934
+ return {
935
+ ...plan,
936
+ currency: subscription.currency ?? plan.currency,
937
+ billingCadence: subscription.billingCadence ?? plan.billingCadence,
938
+ phases
939
+ };
940
+ };
860
941
  //#endregion
861
942
  //#region src/pages/SubscriptionChangeConfirmPage.tsx
862
943
  const SubscriptionChangeConfirmPage = () => {
@@ -896,20 +977,27 @@ const SubscriptionChangeConfirmPage = () => {
896
977
  children: /* @__PURE__ */ jsxs("div", {
897
978
  className: "space-y-3",
898
979
  children: [
899
- currentSubscription && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(CurrentPlanBaseline, {
900
- subscription: currentSubscription,
901
- units: pricing?.units
980
+ currentSubscription && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(PlanSummaryCard, {
981
+ plan: subscriptionToCurrentPlan(currentSubscription),
982
+ label: "Current plan",
983
+ descriptionFallback: "",
984
+ taxLabel: "",
985
+ taxInclusive: false,
986
+ units: pricing?.units,
987
+ collapsibleDetails: true
902
988
  }), /* @__PURE__ */ jsxs("div", {
903
989
  className: "flex items-center justify-center gap-1.5 text-sm text-muted-foreground",
904
990
  children: [/* @__PURE__ */ jsx(ArrowDownIcon, { className: "size-4" }), " Changing to"]
905
991
  })] }),
906
992
  selectedPlan && /* @__PURE__ */ jsx(PlanSummaryCard, {
907
993
  plan: selectedPlan,
908
- descriptionFallback: "New plan",
994
+ label: "New plan",
995
+ descriptionFallback: "",
909
996
  taxAmount,
910
997
  taxLabel,
911
998
  taxInclusive,
912
- units: pricing?.units
999
+ units: pricing?.units,
1000
+ collapsibleDetails: true
913
1001
  }),
914
1002
  /* @__PURE__ */ jsxs("div", {
915
1003
  className: "rounded-lg bg-muted/50 p-3 text-sm space-y-1",
@@ -1431,128 +1519,74 @@ const RestoreSubscriptionDialog = ({ open, onOpenChange, planName, subscriptionI
1431
1519
  });
1432
1520
  };
1433
1521
  //#endregion
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}`;
1441
- };
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");
1522
+ //#region src/pages/components/SubscriptionEntitlements.tsx
1445
1523
  /**
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.
1524
+ * Render a subscription's entitlements from a resolved
1525
+ * {@link SubscriptionPlanView}: the active phase's *provisioned* items when
1526
+ * present (the real included quotas + per-unit prices), otherwise the catalog
1527
+ * plan's phases. Centralizes the "items, else fall back to plan phases" branch
1528
+ * shared by the subscription details page and the Switch Plan baseline.
1529
+ *
1530
+ * Renders `null` when there's nothing to show, so callers can wrap it with
1531
+ * {@link hasSubscriptionEntitlements} (for a section header / border) without
1532
+ * leaving an empty container behind.
1451
1533
  */
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);
1464
- let change = "same";
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
1478
- });
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
- });
1496
- }
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
1510
- });
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)
1534
+ const SubscriptionEntitlements = ({ view, currency, billingCadence, units, itemClassName }) => {
1535
+ if (view.usingItems) {
1536
+ const { quotas, features } = view.entitlements;
1537
+ return /* @__PURE__ */ jsx(EntitlementList, {
1538
+ quotas,
1539
+ features,
1540
+ itemClassName
1524
1541
  });
1525
1542
  }
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;
1543
+ if (view.fallbackPhases.length === 0) return null;
1544
+ return /* @__PURE__ */ jsx(PlanEntitlements, {
1545
+ phases: view.fallbackPhases,
1546
+ currency,
1547
+ billingCadence,
1548
+ units,
1549
+ itemClassName
1534
1550
  });
1535
1551
  };
1536
1552
  //#endregion
1537
- //#region src/utils/formatPhaseRampSummary.ts
1538
- const durationWithCount = (iso) => {
1539
- const text = formatDuration(iso);
1540
- return /^\d/.test(text) ? text : `1 ${text}`;
1541
- };
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";
1546
- };
1553
+ //#region src/pages/components/CurrentPlanBaseline.tsx
1547
1554
  /**
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).
1555
+ * Baseline shown at the top of the Switch Plan modal: the current plan's price
1556
+ * and what it actually includes, so users have a concrete reference to compare
1557
+ * targets against. Both the price and the entitlements come from the
1558
+ * subscription's *provisioned* items (real included quotas + recurring fees),
1559
+ * falling back to the plan's rate cards only when items aren't present — see
1560
+ * {@link getSubscriptionPlanView}.
1551
1561
  */
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)}`;
1562
+ const CurrentPlanBaseline = ({ subscription, units }) => {
1563
+ const plan = subscription.plan;
1564
+ const view = getSubscriptionPlanView(subscription, { units });
1565
+ return /* @__PURE__ */ jsxs("div", {
1566
+ className: "border rounded-lg p-4",
1567
+ children: [/* @__PURE__ */ jsxs("div", {
1568
+ className: "flex items-start justify-between gap-3 flex-wrap",
1569
+ children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("div", {
1570
+ className: "text-xs font-medium text-muted-foreground uppercase tracking-wide",
1571
+ children: "Current Plan"
1572
+ }), /* @__PURE__ */ jsx("div", {
1573
+ className: "text-lg font-bold text-foreground",
1574
+ children: plan.name
1575
+ })] }), /* @__PURE__ */ jsx(PlanPriceTag, {
1576
+ label: view.priceLabel,
1577
+ currency: view.currency,
1578
+ billingCadence: view.billingCadence
1579
+ })]
1580
+ }), hasSubscriptionEntitlements(view) && /* @__PURE__ */ jsx("div", {
1581
+ className: "mt-3 pt-3 border-t",
1582
+ children: /* @__PURE__ */ jsx(SubscriptionEntitlements, {
1583
+ view,
1584
+ currency: view.currency,
1585
+ billingCadence: view.billingCadence,
1586
+ units
1587
+ })
1588
+ })]
1589
+ });
1556
1590
  };
1557
1591
  //#endregion
1558
1592
  //#region src/pages/components/PlanChangeCard.tsx
@@ -1665,18 +1699,28 @@ const ChangeRow = ({ change }) => {
1665
1699
  const PlanChangeCard = ({ plan, mode, currentEntitlements, isNewerVersion, isSwitching, units, onSwitch }) => {
1666
1700
  const isCustom = isCustomPlan(plan);
1667
1701
  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, {
1702
+ const schedule = isCustom ? void 0 : getPlanPriceSchedule(plan);
1703
+ const phaseChangeGroups = useMemo(() => {
1704
+ const diff = (target) => {
1705
+ const changes = comparePlanEntitlements(currentEntitlements, target);
1706
+ return [...changes.filter((c) => c.change !== "removed"), ...changes.filter((c) => c.change === "removed")];
1707
+ };
1708
+ const sets = plan.phases.map((phase) => categorizeRateCards(phase.rateCards, {
1672
1709
  currency: plan.currency,
1673
1710
  units,
1674
1711
  planBillingCadence: plan.billingCadence
1675
- }) : {
1712
+ }));
1713
+ return (plan.phases.length <= 1 || sets.every((set) => sameEntitlementSet(set, sets[0])) ? [{ changes: diff(sets.at(-1) ?? {
1676
1714
  quotas: [],
1677
1715
  features: []
1678
- });
1679
- return [...changes.filter((c) => c.change !== "removed"), ...changes.filter((c) => c.change === "removed")];
1716
+ }) }] : plan.phases.flatMap((phase, idx) => {
1717
+ const set = sets[idx];
1718
+ if (set.quotas.length === 0 && set.features.length === 0) return [];
1719
+ return [{
1720
+ phase,
1721
+ changes: diff(set)
1722
+ }];
1723
+ })).filter((group) => group.changes.length > 0);
1680
1724
  }, [
1681
1725
  plan,
1682
1726
  currentEntitlements,
@@ -1702,7 +1746,7 @@ const PlanChangeCard = ({ plan, mode, currentEntitlements, isNewerVersion, isSwi
1702
1746
  }), isCustom ? /* @__PURE__ */ jsx("span", {
1703
1747
  className: "text-primary font-medium",
1704
1748
  children: "Custom"
1705
- }) : /* @__PURE__ */ jsx(PlanPriceTag, {
1749
+ }) : !schedule && /* @__PURE__ */ jsx(PlanPriceTag, {
1706
1750
  label: priceLabel,
1707
1751
  currency: plan.currency,
1708
1752
  billingCadence: plan.billingCadence
@@ -1719,13 +1763,22 @@ const PlanChangeCard = ({ plan, mode, currentEntitlements, isNewerVersion, isSwi
1719
1763
  children: MODE_LABEL[mode]
1720
1764
  })]
1721
1765
  }),
1722
- ramp && /* @__PURE__ */ jsx("p", {
1723
- className: "text-sm text-muted-foreground mb-2",
1724
- children: ramp
1766
+ schedule && /* @__PURE__ */ jsx(PlanPriceSchedule, {
1767
+ schedule,
1768
+ currency: plan.currency,
1769
+ billingCadence: plan.billingCadence,
1770
+ className: "mb-2"
1725
1771
  }),
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}`))
1772
+ phaseChangeGroups.length > 0 && /* @__PURE__ */ jsx("div", {
1773
+ className: "space-y-3",
1774
+ children: phaseChangeGroups.map((group, idx) => /* @__PURE__ */ jsxs("div", {
1775
+ className: "space-y-1.5",
1776
+ children: [group.phase && /* @__PURE__ */ jsx(PlanPhaseHeader, {
1777
+ phase: group.phase,
1778
+ currency: plan.currency,
1779
+ billingCadence: plan.billingCadence
1780
+ }), group.changes.map((change) => /* @__PURE__ */ jsx(ChangeRow, { change }, `${change.kind}:${change.key}`))]
1781
+ }, group.phase?.key ?? String(idx)))
1729
1782
  })
1730
1783
  ]
1731
1784
  });
@@ -2014,6 +2067,75 @@ const ManageSubscription = ({ subscription, planName }) => {
2014
2067
  ] });
2015
2068
  };
2016
2069
  //#endregion
2070
+ //#region src/pages/components/SubscriptionPhases.tsx
2071
+ const phaseDateLabel = (view) => {
2072
+ if (view.status === "current") return "Current phase";
2073
+ if (view.status === "future") return `Starts ${formatDateTime(view.activeFrom)}`;
2074
+ return view.activeTo ? `${formatDateTime(view.activeFrom)} – ${formatDateTime(view.activeTo)}` : formatDateTime(view.activeFrom);
2075
+ };
2076
+ /**
2077
+ * One phase of a subscription: a header with the phase name, its timing
2078
+ * sub-label, and the phase's own price, followed by that phase's included
2079
+ * quotas/features. The header always renders (even when the phase has no
2080
+ * entitlements) so the price/timing is never hidden.
2081
+ */
2082
+ const SubscriptionPhaseSection = ({ view }) => {
2083
+ const dateLabel = phaseDateLabel(view);
2084
+ return /* @__PURE__ */ jsxs("div", {
2085
+ className: "space-y-2",
2086
+ children: [/* @__PURE__ */ jsxs("div", {
2087
+ className: "flex items-baseline justify-between gap-3",
2088
+ children: [/* @__PURE__ */ jsxs("div", {
2089
+ className: "text-sm font-medium text-card-foreground",
2090
+ children: [view.name, dateLabel && /* @__PURE__ */ jsxs("span", {
2091
+ className: "text-muted-foreground font-normal",
2092
+ children: [
2093
+ " ",
2094
+ "· ",
2095
+ dateLabel
2096
+ ]
2097
+ })]
2098
+ }), /* @__PURE__ */ jsx(PlanPriceTag, {
2099
+ label: view.priceLabel,
2100
+ currency: view.currency,
2101
+ billingCadence: view.billingCadence
2102
+ })]
2103
+ }), /* @__PURE__ */ jsx(EntitlementList, {
2104
+ quotas: view.entitlements.quotas,
2105
+ features: view.entitlements.features
2106
+ })]
2107
+ });
2108
+ };
2109
+ /**
2110
+ * The subscription's future phases (each with its start date, price, and
2111
+ * entitlements), shown after the current phase on the details page and on the
2112
+ * Switch Plan baseline. Renders nothing when there are no upcoming phases.
2113
+ */
2114
+ const UpcomingPhases = ({ subscription, units }) => {
2115
+ const future = getSubscriptionPhaseViews(subscription, { units }).filter((v) => v.status === "future");
2116
+ if (future.length === 0) return null;
2117
+ return /* @__PURE__ */ jsx("div", {
2118
+ className: "space-y-4",
2119
+ children: future.map((view) => /* @__PURE__ */ jsx(SubscriptionPhaseSection, { view }, view.id))
2120
+ });
2121
+ };
2122
+ /**
2123
+ * The subscription's already-ended phases, collapsed by default, each labelled
2124
+ * with the exact dates it ran. Lets users review what they previously paid for
2125
+ * without cluttering the page. Renders nothing when there are no past phases.
2126
+ */
2127
+ const PreviousPhases = ({ subscription, units }) => {
2128
+ const past = getSubscriptionPhaseViews(subscription, { units }).filter((v) => v.status === "past");
2129
+ if (past.length === 0) return null;
2130
+ return /* @__PURE__ */ jsxs(Collapsible, { children: [/* @__PURE__ */ jsxs(CollapsibleTrigger, {
2131
+ className: "group flex w-full items-center justify-between text-sm font-medium",
2132
+ children: ["Previous phases", /* @__PURE__ */ jsx(ChevronDownIcon, { className: "size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" })]
2133
+ }), /* @__PURE__ */ jsx(CollapsibleContent, {
2134
+ className: "mt-3 space-y-4",
2135
+ children: past.map((view) => /* @__PURE__ */ jsx(SubscriptionPhaseSection, { view }, view.id))
2136
+ })] });
2137
+ };
2138
+ //#endregion
2017
2139
  //#region src/pages/subscriptions/SubscriptionPlanDetails.tsx
2018
2140
  const detailLabelClassName = "text-sm font-semibold tracking-wide mb-1";
2019
2141
  const sectionLabelClassName = "text-base font-semibold tracking-wide mb-3 mt-2";
@@ -2025,6 +2147,9 @@ const SubscriptionPlanDetails = ({ subscription }) => {
2025
2147
  const { priceLabel } = view;
2026
2148
  const taxLegendSentence = planHasDefaultTaxBehavior(plan) ? subscriptionTaxLegendSentence(plan.defaultTaxConfig?.behavior ?? "") : void 0;
2027
2149
  const hasEntitlements = hasSubscriptionEntitlements(view);
2150
+ const phaseViews = getSubscriptionPhaseViews(subscription, { units: pricing?.units });
2151
+ const hasUpcomingPhases = phaseViews.some((p) => p.status === "future");
2152
+ const hasPreviousPhases = phaseViews.some((p) => p.status === "past");
2028
2153
  return /* @__PURE__ */ jsxs("div", {
2029
2154
  className: "space-y-4",
2030
2155
  children: [/* @__PURE__ */ jsx(Heading, {
@@ -2035,58 +2160,78 @@ const SubscriptionPlanDetails = ({ subscription }) => {
2035
2160
  children: plan.name
2036
2161
  }), plan.description ? /* @__PURE__ */ jsx(CardDescription, { children: plan.description }) : null] }), /* @__PURE__ */ jsxs(CardContent, {
2037
2162
  className: "space-y-6",
2038
- children: [/* @__PURE__ */ jsxs("dl", {
2039
- className: "grid gap-4 sm:grid-cols-2 text-sm",
2040
- children: [
2041
- /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
2042
- className: detailLabelClassName,
2043
- children: "Subscription ID"
2044
- }), /* @__PURE__ */ jsx("dd", {
2045
- className: "text-foreground font-mono text-xs break-all",
2046
- children: subscription.id
2047
- })] }),
2048
- /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
2049
- className: detailLabelClassName,
2050
- children: "Active since"
2051
- }), /* @__PURE__ */ jsx("dd", {
2052
- className: "text-foreground",
2053
- children: formatDateTime(subscription.activeFrom)
2054
- })] }),
2055
- /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
2056
- className: detailLabelClassName,
2057
- children: "Price"
2058
- }), /* @__PURE__ */ jsxs("dd", { children: [/* @__PURE__ */ jsx("div", {
2059
- className: "flex flex-wrap items-baseline gap-1",
2060
- children: /* @__PURE__ */ jsx(PlanPriceTag, {
2061
- label: priceLabel,
2062
- currency: view.currency,
2063
- billingCadence: view.billingCadence,
2064
- description: true
2065
- })
2066
- }), taxLegendSentence ? /* @__PURE__ */ jsx("p", {
2067
- className: "text-xs text-muted-foreground mt-1",
2068
- children: taxLegendSentence
2069
- }) : null] })] }),
2070
- /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
2071
- className: detailLabelClassName,
2072
- children: "Current period"
2073
- }), /* @__PURE__ */ jsx("dd", {
2074
- className: "text-foreground",
2075
- children: subscription.alignment?.currentAlignedBillingPeriod ? formatDateTimeRange(subscription.alignment.currentAlignedBillingPeriod.from, subscription.alignment.currentAlignedBillingPeriod.to) : ""
2076
- })] })
2077
- ]
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
- })]
2089
- }) : null]
2163
+ children: [
2164
+ /* @__PURE__ */ jsxs("dl", {
2165
+ className: "grid gap-4 sm:grid-cols-2 text-sm",
2166
+ children: [
2167
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
2168
+ className: detailLabelClassName,
2169
+ children: "Subscription ID"
2170
+ }), /* @__PURE__ */ jsx("dd", {
2171
+ className: "text-foreground font-mono text-xs break-all",
2172
+ children: subscription.id
2173
+ })] }),
2174
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
2175
+ className: detailLabelClassName,
2176
+ children: "Active since"
2177
+ }), /* @__PURE__ */ jsx("dd", {
2178
+ className: "text-foreground",
2179
+ children: formatDateTime(subscription.activeFrom)
2180
+ })] }),
2181
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
2182
+ className: detailLabelClassName,
2183
+ children: "Price"
2184
+ }), /* @__PURE__ */ jsxs("dd", { children: [/* @__PURE__ */ jsx("div", {
2185
+ className: "flex flex-wrap items-baseline gap-1",
2186
+ children: /* @__PURE__ */ jsx(PlanPriceTag, {
2187
+ label: priceLabel,
2188
+ currency: view.currency,
2189
+ billingCadence: view.billingCadence,
2190
+ description: true
2191
+ })
2192
+ }), taxLegendSentence ? /* @__PURE__ */ jsx("p", {
2193
+ className: "text-xs text-muted-foreground mt-1",
2194
+ children: taxLegendSentence
2195
+ }) : null] })] }),
2196
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
2197
+ className: detailLabelClassName,
2198
+ children: "Current period"
2199
+ }), /* @__PURE__ */ jsx("dd", {
2200
+ className: "text-foreground",
2201
+ children: subscription.alignment?.currentAlignedBillingPeriod ? formatDateTimeRange(subscription.alignment.currentAlignedBillingPeriod.from, subscription.alignment.currentAlignedBillingPeriod.to) : "—"
2202
+ })] })
2203
+ ]
2204
+ }),
2205
+ hasEntitlements ? /* @__PURE__ */ jsxs("div", {
2206
+ className: "space-y-2 pt-2 border-t border-border",
2207
+ children: [/* @__PURE__ */ jsx("p", {
2208
+ className: sectionLabelClassName,
2209
+ children: "What's included"
2210
+ }), /* @__PURE__ */ jsx(SubscriptionEntitlements, {
2211
+ view,
2212
+ currency: view.currency,
2213
+ billingCadence: view.billingCadence,
2214
+ units: pricing?.units
2215
+ })]
2216
+ }) : null,
2217
+ hasUpcomingPhases ? /* @__PURE__ */ jsxs("div", {
2218
+ className: "space-y-3 pt-2 border-t border-border",
2219
+ children: [/* @__PURE__ */ jsx("p", {
2220
+ className: sectionLabelClassName,
2221
+ children: "Upcoming phases"
2222
+ }), /* @__PURE__ */ jsx(UpcomingPhases, {
2223
+ subscription,
2224
+ units: pricing?.units
2225
+ })]
2226
+ }) : null,
2227
+ hasPreviousPhases ? /* @__PURE__ */ jsx("div", {
2228
+ className: "pt-2 border-t border-border",
2229
+ children: /* @__PURE__ */ jsx(PreviousPhases, {
2230
+ subscription,
2231
+ units: pricing?.units
2232
+ })
2233
+ }) : null
2234
+ ]
2090
2235
  })] })]
2091
2236
  });
2092
2237
  };