@zuplo/zudoku-plugin-monetization 0.0.41 → 0.0.43

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.
@@ -1,6 +1,6 @@
1
1
  import { Fragment } from "react";
2
- import { parse } from "tinyduration";
3
2
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
3
+ import { parse } from "tinyduration";
4
4
  import { clsx } from "clsx";
5
5
  import { twMerge } from "tailwind-merge";
6
6
  //#region src/utils/formatDuration.ts
@@ -136,6 +136,17 @@ const formatTieredPriceBreakdown = (opts) => {
136
136
  return lines.length > 0 ? lines : void 0;
137
137
  };
138
138
  //#endregion
139
+ //#region src/utils/tierHasPositivePrice.ts
140
+ /**
141
+ * Whether a price tier charges anything — a non-zero flat or per-unit amount.
142
+ * Amounts are decimal strings and a missing part counts as zero, so this
143
+ * distinguishes a genuinely priced tier from an all-zero ("Included") tier.
144
+ * Shared by the pricing-label path ({@link formatPlanPrice}) and rate-card
145
+ * categorization ({@link categorizeRateCards}) so both decide "is this tier
146
+ * free?" the same way.
147
+ */
148
+ const tierHasPositivePrice = (tier) => parseFloat(tier.flatPrice?.amount ?? "0") > 0 || parseFloat(tier.unitPrice?.amount ?? "0") > 0;
149
+ //#endregion
139
150
  //#region src/utils/categorizeRateCards.ts
140
151
  const categorizeRateCards = (rateCards, options) => {
141
152
  const { currency, units, planBillingCadence } = options ?? {};
@@ -152,7 +163,7 @@ const categorizeRateCards = (rateCards, options) => {
152
163
  return "month";
153
164
  };
154
165
  const firstTier = rc.price?.type === "tiered" && rc.price.tiers.length > 0 ? rc.price.tiers[0] : void 0;
155
- const firstTierIsPriced = !!firstTier && (parseFloat(firstTier.flatPrice?.amount ?? "0") > 0 || parseFloat(firstTier.unitPrice?.amount ?? "0") > 0);
166
+ const firstTierIsPriced = !!firstTier && tierHasPositivePrice(firstTier);
156
167
  if (et.type === "metered" && et.issueAfterReset != null && !firstTierIsPriced) {
157
168
  let tierPrices;
158
169
  if (rc.price?.type === "tiered" && rc.price.tiers) tierPrices = formatTieredPriceBreakdown({
@@ -176,7 +187,7 @@ const categorizeRateCards = (rateCards, options) => {
176
187
  const unitLabel = unitLabelFor(rc);
177
188
  if (rc.price.type === "tiered" && rc.price.tiers.length > 0) {
178
189
  const tiers = rc.price.tiers;
179
- if (!tiers.some((t) => parseFloat(t.flatPrice?.amount ?? "0") > 0 || parseFloat(t.unitPrice?.amount ?? "0") > 0)) {
190
+ if (!tiers.some(tierHasPositivePrice)) {
180
191
  features.push({
181
192
  key: rc.featureKey ?? rc.key,
182
193
  name: rc.name
@@ -254,6 +265,216 @@ const categorizeRateCards = (rateCards, options) => {
254
265
  };
255
266
  };
256
267
  //#endregion
268
+ //#region src/utils/comparePlanEntitlements.ts
269
+ /** Compact, human-readable value for a quota row. */
270
+ const quotaValueLabel = (q) => {
271
+ if (q.unitPrice) return q.unitPrice;
272
+ if (q.tierPrices && q.tierPrices.length > 0) return "Tiered pricing";
273
+ if (q.isPayg) return "Usage-based";
274
+ return `${q.limit.toLocaleString("en-US")} / ${q.period}`;
275
+ };
276
+ const featureValueLabel = (f) => f.value ?? "Included";
277
+ const isPlainNumericQuota = (q) => !q.isPayg && !q.unitPrice && (!q.tierPrices || q.tierPrices.length === 0);
278
+ const sameTierSchedule = (a, b) => (a ?? []).join("\n") === (b ?? []).join("\n");
279
+ const sameQuota = (a, b) => a.name === b.name && a.limit === b.limit && a.period === b.period && a.isPayg === b.isPayg && a.unitPrice === b.unitPrice && sameTierSchedule(a.tierPrices, b.tierPrices);
280
+ const sameFeature = (a, b) => a.name === b.name && a.value === b.value;
281
+ /**
282
+ * Whether two entitlement sets render identically: the same quota and feature
283
+ * keys, each with identical display fields. Order-insensitive (matched by
284
+ * key), so two phases whose rate cards merely differ in order still compare
285
+ * equal. Used to collapse per-phase entitlement lists that would repeat the
286
+ * exact same rows.
287
+ */
288
+ const sameEntitlementSet = (a, b) => {
289
+ if (a.quotas.length !== b.quotas.length || a.features.length !== b.features.length) return false;
290
+ const bQuotas = new Map(b.quotas.map((q) => [q.key, q]));
291
+ const bFeatures = new Map(b.features.map((f) => [f.key, f]));
292
+ return a.quotas.every((q) => {
293
+ const other = bQuotas.get(q.key);
294
+ return other !== void 0 && sameQuota(q, other);
295
+ }) && a.features.every((f) => {
296
+ const other = bFeatures.get(f.key);
297
+ return other !== void 0 && sameFeature(f, other);
298
+ });
299
+ };
300
+ /**
301
+ * Compare two plans' entitlements, matching strictly by feature key (never by
302
+ * display name). Each key yields exactly one change row, so a key that exists
303
+ * on one side and a differently-keyed feature that merely shares a display
304
+ * name can never read as a contradictory "added" + "removed" of the same
305
+ * thing. Labels are disambiguated afterwards when they would collide.
306
+ */
307
+ const comparePlanEntitlements = (current, target) => {
308
+ const changes = [];
309
+ const curQuota = new Map(current.quotas.map((q) => [q.key, q]));
310
+ const tgtQuota = new Map(target.quotas.map((q) => [q.key, q]));
311
+ const curFeat = new Map(current.features.map((f) => [f.key, f]));
312
+ const tgtFeat = new Map(target.features.map((f) => [f.key, f]));
313
+ for (const key of new Set([...curQuota.keys(), ...tgtQuota.keys()])) {
314
+ const c = curQuota.get(key);
315
+ const t = tgtQuota.get(key);
316
+ if (c && t) {
317
+ const currentValue = quotaValueLabel(c);
318
+ const targetValue = quotaValueLabel(t);
319
+ let change = "same";
320
+ if (isPlainNumericQuota(c) && isPlainNumericQuota(t) && c.period === t.period) {
321
+ if (t.limit > c.limit) change = "increase";
322
+ else if (t.limit < c.limit) change = "decrease";
323
+ } else if (currentValue !== targetValue || !sameTierSchedule(c.tierPrices, t.tierPrices)) change = "changed";
324
+ changes.push({
325
+ key,
326
+ label: t.name,
327
+ kind: "quota",
328
+ change,
329
+ currentValue,
330
+ targetValue,
331
+ tierPrices: t.tierPrices,
332
+ period: t.period
333
+ });
334
+ } else if (t) changes.push({
335
+ key,
336
+ label: t.name,
337
+ kind: "quota",
338
+ change: "added",
339
+ targetValue: quotaValueLabel(t),
340
+ tierPrices: t.tierPrices,
341
+ period: t.period
342
+ });
343
+ else if (c) changes.push({
344
+ key,
345
+ label: c.name,
346
+ kind: "quota",
347
+ change: "removed",
348
+ currentValue: quotaValueLabel(c),
349
+ period: c.period
350
+ });
351
+ }
352
+ for (const key of new Set([...curFeat.keys(), ...tgtFeat.keys()])) {
353
+ const c = curFeat.get(key);
354
+ const t = tgtFeat.get(key);
355
+ if (c && t) {
356
+ const currentValue = featureValueLabel(c);
357
+ const targetValue = featureValueLabel(t);
358
+ changes.push({
359
+ key,
360
+ label: t.name,
361
+ kind: "feature",
362
+ change: currentValue === targetValue ? "same" : "changed",
363
+ currentValue,
364
+ targetValue
365
+ });
366
+ } else if (t) changes.push({
367
+ key,
368
+ label: t.name,
369
+ kind: "feature",
370
+ change: "added",
371
+ targetValue: featureValueLabel(t)
372
+ });
373
+ else if (c) changes.push({
374
+ key,
375
+ label: c.name,
376
+ kind: "feature",
377
+ change: "removed",
378
+ currentValue: featureValueLabel(c)
379
+ });
380
+ }
381
+ const labelCounts = /* @__PURE__ */ new Map();
382
+ for (const ch of changes) labelCounts.set(ch.label, (labelCounts.get(ch.label) ?? 0) + 1);
383
+ return changes.map(({ period, ...ch }) => {
384
+ if ((labelCounts.get(ch.label) ?? 0) > 1 && ch.kind === "quota" && period) return {
385
+ ...ch,
386
+ label: `${ch.label} (${period})`
387
+ };
388
+ return ch;
389
+ });
390
+ };
391
+ //#endregion
392
+ //#region src/utils/getPlanPrice.ts
393
+ const sumFlatFeeAmounts = (rateCards) => {
394
+ let total = 0;
395
+ for (const rc of rateCards) if (rc.type === "flat_fee" && rc.price && rc.billingCadence !== null) {
396
+ const amount = Number(rc.price.amount);
397
+ if (Number.isFinite(amount)) total += amount;
398
+ }
399
+ return total;
400
+ };
401
+ /**
402
+ * The plan's headline recurring price: the sum of every recurring `flat_fee`
403
+ * rate-card amount on the plan's steady-state (last) phase, expressed in the
404
+ * plan's own `billingCadence`. One-time fees (`flat_fee` with
405
+ * `billingCadence: null`, e.g. a setup fee) are excluded.
406
+ *
407
+ * This is derived entirely from the plan's rate cards. It deliberately does
408
+ * NOT read any server-provided `monthlyPrice` / `yearlyPrice` and performs no
409
+ * cadence conversion, so it stays correct for any billing cadence — hourly
410
+ * (`PT1H`), weekly, monthly, yearly, etc. Callers pair the returned amount
411
+ * with `formatDuration(plan.billingCadence)` to render e.g. `$2.99/hour`.
412
+ *
413
+ * Returns `0` when there are no phases or no recurring flat fee, which callers
414
+ * render as "Free" (or "Pay as you go" when the plan bills on usage — see
415
+ * {@link formatPlanPrice}).
416
+ */
417
+ const getPlanPrice = (plan) => {
418
+ const lastPhase = plan.phases?.at(-1);
419
+ if (!lastPhase) return 0;
420
+ return sumFlatFeeAmounts(lastPhase.rateCards ?? []);
421
+ };
422
+ //#endregion
423
+ //#region src/utils/formatPlanPrice.ts
424
+ const isPricedUsageRateCard = (rc) => {
425
+ if (rc.type !== "usage_based" || !rc.price) return false;
426
+ const p = rc.price;
427
+ if (p.type === "unit") return parseFloat(p.amount) > 0;
428
+ if (p.type === "tiered") return p.tiers.some(tierHasPositivePrice);
429
+ return true;
430
+ };
431
+ const hasPricedUsageRateCard = (plan) => plan.phases.some((phase) => phase.rateCards.some(isPricedUsageRateCard));
432
+ /**
433
+ * Headline pricing for plan cards. Centralizes the "Pay as you go" detection:
434
+ * plans whose flat-fee total is zero but that bill on usage shouldn't render
435
+ * as "Free" - they're charged per-unit.
436
+ */
437
+ const formatPlanPrice = (plan) => {
438
+ if (!plan.phases || plan.phases.length === 0) return { type: "free" };
439
+ const amount = getPlanPrice(plan);
440
+ if (amount > 0) return {
441
+ type: "priced",
442
+ amount
443
+ };
444
+ if (hasPricedUsageRateCard(plan)) return {
445
+ type: "payg",
446
+ main: "Pay as you go",
447
+ sub: "Usage-based pricing"
448
+ };
449
+ return { type: "free" };
450
+ };
451
+ //#endregion
452
+ //#region src/utils/getPhasePriceLabel.ts
453
+ /**
454
+ * Headline price for a SINGLE phase, derived only from that phase's own rate
455
+ * cards. Mirrors {@link formatPlanPrice}'s rules, but scoped to the phase:
456
+ * a positive recurring flat-fee total is `priced`; otherwise a priced
457
+ * `usage_based` card in this phase makes it `payg`; otherwise it's `free`.
458
+ *
459
+ * Like {@link getPlanPrice}, one-time fees (`flat_fee` with
460
+ * `billingCadence: null`) and `price: null` rate cards contribute nothing —
461
+ * an intro phase whose fees all have `price: null` derives as `free`.
462
+ */
463
+ const getPhasePriceLabel = (phase) => {
464
+ const rateCards = phase.rateCards ?? [];
465
+ const amount = sumFlatFeeAmounts(rateCards);
466
+ if (amount > 0) return {
467
+ type: "priced",
468
+ amount
469
+ };
470
+ if (rateCards.some(isPricedUsageRateCard)) return {
471
+ type: "payg",
472
+ main: "Pay as you go",
473
+ sub: "Usage-based pricing"
474
+ };
475
+ return { type: "free" };
476
+ };
477
+ //#endregion
257
478
  //#region src/pricing-ui/CheckIcon.tsx
258
479
  /**
259
480
  * Inline `Check` icon, visually identical to `lucide-react`'s `CheckIcon`
@@ -331,28 +552,23 @@ const QuotaItem = ({ quota, className }) => {
331
552
  });
332
553
  };
333
554
  //#endregion
334
- //#region src/pricing-ui/PlanEntitlements.tsx
335
- const PhaseSection = ({ phase, currency, showName, billingCadence, units, itemClassName }) => {
336
- const { quotas, features } = categorizeRateCards(phase.rateCards, {
337
- currency,
338
- units,
339
- planBillingCadence: billingCadence
340
- });
555
+ //#region src/pricing-ui/EntitlementList.tsx
556
+ /**
557
+ * Vertical list of a resolved entitlement set — quotas first, then features —
558
+ * with an optional leading `header` (e.g. a phase name) rendered inside the same
559
+ * spacing container. Returns `null` when there are no quotas or features, so
560
+ * callers can gate a section header / border without leaving an empty container
561
+ * behind.
562
+ *
563
+ * Shared by {@link PlanEntitlements} (one list per phase) and the subscription
564
+ * entitlement views so quotas and features always render identically.
565
+ */
566
+ const EntitlementList = ({ quotas, features, header, itemClassName }) => {
341
567
  if (quotas.length === 0 && features.length === 0) return null;
342
568
  return /* @__PURE__ */ jsxs("div", {
343
569
  className: "space-y-2",
344
570
  children: [
345
- showName && /* @__PURE__ */ jsxs("div", {
346
- className: "text-sm font-medium text-card-foreground",
347
- children: [phase.name, phase.duration && /* @__PURE__ */ jsxs("span", {
348
- className: "text-muted-foreground font-normal",
349
- children: [
350
- " ",
351
- "— ",
352
- formatDuration(phase.duration)
353
- ]
354
- })]
355
- }),
571
+ header,
356
572
  quotas.map((quota) => /* @__PURE__ */ jsx(QuotaItem, {
357
573
  quota,
358
574
  className: itemClassName
@@ -364,101 +580,198 @@ const PhaseSection = ({ phase, currency, showName, billingCadence, units, itemCl
364
580
  ]
365
581
  });
366
582
  };
583
+ //#endregion
584
+ //#region src/pricing-ui/PlanEntitlements.tsx
585
+ const priceLabelText = (label, currency, billingCadence) => {
586
+ if (label.type === "payg") return label.main;
587
+ if (label.type === "free") return "Free";
588
+ const amount = formatPrice(label.amount, currency);
589
+ return billingCadence ? `${amount}/${formatDuration(billingCadence)}` : amount;
590
+ };
591
+ /**
592
+ * Section header for one phase of a multi-phase plan: the phase name, its
593
+ * duration, and the phase's own price. Shared by {@link PlanEntitlements} and
594
+ * the plan-change card so per-phase sections read identically everywhere.
595
+ */
596
+ const PlanPhaseHeader = ({ phase, currency, billingCadence }) => /* @__PURE__ */ jsxs("div", {
597
+ className: "text-sm font-medium text-card-foreground",
598
+ children: [
599
+ phase.name,
600
+ phase.duration && /* @__PURE__ */ jsxs("span", {
601
+ className: "text-muted-foreground font-normal",
602
+ children: [
603
+ " ",
604
+ "— ",
605
+ formatDuration(phase.duration)
606
+ ]
607
+ }),
608
+ /* @__PURE__ */ jsxs("span", {
609
+ className: "text-muted-foreground font-normal",
610
+ children: [
611
+ " ",
612
+ "·",
613
+ " ",
614
+ priceLabelText(getPhasePriceLabel(phase), currency, billingCadence)
615
+ ]
616
+ })
617
+ ]
618
+ });
619
+ const PhaseSection = ({ phase, set, currency, billingCadence, itemClassName }) => /* @__PURE__ */ jsx(EntitlementList, {
620
+ quotas: set.quotas,
621
+ features: set.features,
622
+ itemClassName,
623
+ header: /* @__PURE__ */ jsx(PlanPhaseHeader, {
624
+ phase,
625
+ currency,
626
+ billingCadence
627
+ })
628
+ });
629
+ /**
630
+ * A plan's entitlements, phase by phase. Multi-phase plans whose phases all
631
+ * resolve to the same entitlements collapse into a single list (the phases
632
+ * only differ in price, which the price schedule already tells); phases with
633
+ * genuinely different entitlements render as separate sections headed by the
634
+ * phase name, duration, and that phase's own price.
635
+ */
367
636
  const PlanEntitlements = ({ phases, currency, billingCadence, units, itemClassName }) => {
637
+ const sets = phases.map((phase) => categorizeRateCards(phase.rateCards, {
638
+ currency,
639
+ units,
640
+ planBillingCadence: billingCadence
641
+ }));
642
+ if (phases.length <= 1 || sets.every((set) => sameEntitlementSet(set, sets[0]))) {
643
+ const steady = sets.at(-1);
644
+ return /* @__PURE__ */ jsx("div", {
645
+ className: "space-y-4",
646
+ children: steady && /* @__PURE__ */ jsx(EntitlementList, {
647
+ quotas: steady.quotas,
648
+ features: steady.features,
649
+ itemClassName
650
+ })
651
+ });
652
+ }
368
653
  return /* @__PURE__ */ jsx("div", {
369
654
  className: "space-y-4",
370
655
  children: phases.map((phase, idx) => /* @__PURE__ */ jsx(PhaseSection, {
371
656
  phase,
657
+ set: sets[idx],
372
658
  currency,
373
- showName: phases.length > 1,
374
659
  billingCadence,
375
- units,
376
660
  itemClassName
377
661
  }, phase.key ?? String(idx)))
378
662
  });
379
663
  };
380
664
  //#endregion
381
- //#region src/utils/getPriceFromPlan.ts
382
- const sumFlatFeeAmounts = (rateCards) => {
383
- let total = 0;
384
- for (const rc of rateCards) if (rc.type === "flat_fee" && rc.price) {
385
- const amount = Number(rc.price.amount);
386
- if (Number.isFinite(amount)) total += amount;
387
- }
388
- return total;
665
+ //#region src/pricing-ui/PlanPriceSchedule.tsx
666
+ const RowPrice = ({ price, currency, billingCadence, className }) => {
667
+ if (price.type === "priced") return /* @__PURE__ */ jsxs("span", {
668
+ className: cn("font-semibold text-card-foreground", className),
669
+ children: [formatPrice(price.amount, currency), billingCadence && /* @__PURE__ */ jsxs("span", {
670
+ className: "text-muted-foreground font-normal text-sm",
671
+ children: ["/", formatDuration(billingCadence)]
672
+ })]
673
+ });
674
+ return /* @__PURE__ */ jsx("span", {
675
+ className: cn("font-semibold text-card-foreground", className),
676
+ children: price.type === "payg" ? price.main : "Free"
677
+ });
389
678
  };
390
679
  /**
391
- * Convert an ISO 8601 duration to an approximate number of months.
392
- * Years and months contribute exactly; weeks use 12/52 months/week and
393
- * days use 1/30 months/day. Sub-day units (hours, minutes, seconds) do
394
- * not contribute and a duration consisting only of those returns
395
- * `undefined` because there is no sensible monthly equivalent.
680
+ * Stacked per-phase price rows for a multi-phase plan, replacing the single
681
+ * headline price (which only reflects the steady-state phase): each row pairs
682
+ * a phase label ("First 3 months", "After that") with that phase's own price.
683
+ * Every row gets equal visual weight the intro price is part of the plan's
684
+ * price, not a footnote.
685
+ *
686
+ * Callers derive the rows via {@link getPlanPriceSchedule} and fall back to
687
+ * the single-price rendering when it returns `undefined`. `size` picks the
688
+ * typographic treatment: `"lg"` for a card's headline area, `"sm"` for
689
+ * compact contexts (plan-change rows, summary cards).
396
690
  */
397
- const cadenceToMonths = (iso) => {
398
- try {
399
- const d = parse(iso);
400
- let months = 0;
401
- if (d.years) months += d.years * 12;
402
- if (d.months) months += d.months;
403
- if (d.weeks) months += d.weeks * (12 / 52);
404
- if (d.days) months += d.days * (1 / 30);
405
- return months > 0 ? months : void 0;
406
- } catch {
407
- return;
408
- }
409
- };
691
+ const PlanPriceSchedule = ({ schedule, currency, billingCadence, size = "sm", className }) => /* @__PURE__ */ jsx("div", {
692
+ className: cn("space-y-1 text-sm", className),
693
+ children: schedule.map((row) => /* @__PURE__ */ jsxs("div", {
694
+ className: "flex items-baseline justify-between gap-3",
695
+ children: [/* @__PURE__ */ jsx("span", {
696
+ className: "text-muted-foreground",
697
+ children: row.label
698
+ }), /* @__PURE__ */ jsx(RowPrice, {
699
+ price: row.price,
700
+ currency,
701
+ billingCadence,
702
+ className: size === "lg" ? "text-lg" : void 0
703
+ })]
704
+ }, row.key))
705
+ });
706
+ //#endregion
707
+ //#region src/pricing-ui/PlanPriceTag.tsx
410
708
  /**
411
- * Derive a (monthly, yearly) headline price from a plan's last phase by
412
- * summing all `flat_fee` rate-card amounts and converting from the plan's
413
- * `billingCadence` to a monthly equivalent.
709
+ * Headline price for a plan/subscription: `$X/cadence`, "Pay as you go", or
710
+ * "Free", from a {@link PlanPriceLabel}. Shared by the subscription details
711
+ * page, the Switch Plan baseline, each plan-change card, and the checkout /
712
+ * plan-change summary cards so they all render the price identically.
414
713
  *
415
- * Returns `null` for either field when no value can be derived (no
416
- * phases, or an unparseable / sub-day cadence). A flat-fee sum of 0
417
- * returns `{ monthly: 0, yearly: 0 }` (Free).
714
+ * `size` selects the typographic treatment:
715
+ * - `"inline"` (default): compact, primary-colored text for use beside a name.
716
+ * - `"lg"`: a large foreground headline for a summary card's price column.
418
717
  *
419
- * Useful for consumers whose source data doesn't already include
420
- * pre-computed `monthlyPrice` / `yearlyPrice` pass the result through
421
- * (or rely on `getPriceFromPlan`'s built-in fallback).
718
+ * Pass `description` to surface the "Usage-based pricing" subline under the
719
+ * "Pay as you go" headline (used where there's room for it).
422
720
  */
423
- const derivePriceFromPlan = (plan) => {
424
- const lastPhase = plan.phases?.at(-1);
425
- if (!lastPhase) return {
426
- monthly: null,
427
- yearly: null
428
- };
429
- const flatPrice = sumFlatFeeAmounts(lastPhase.rateCards ?? []);
430
- if (flatPrice === 0) return {
431
- monthly: 0,
432
- yearly: 0
433
- };
434
- const months = cadenceToMonths(plan.billingCadence);
435
- if (months == null) return {
436
- monthly: null,
437
- yearly: null
438
- };
439
- const monthly = flatPrice / months;
440
- return {
441
- monthly,
442
- yearly: monthly * 12
443
- };
721
+ const PlanPriceTag = ({ label, currency, billingCadence, description = false, size = "inline" }) => {
722
+ const isLg = size === "lg";
723
+ if (label.type === "priced") return /* @__PURE__ */ jsxs("span", {
724
+ className: isLg ? "text-2xl font-bold" : "text-primary font-medium text-lg",
725
+ children: [formatPrice(label.amount, currency), billingCadence && /* @__PURE__ */ jsxs("span", {
726
+ className: "text-muted-foreground font-normal",
727
+ children: ["/", formatDuration(billingCadence)]
728
+ })]
729
+ });
730
+ if (label.type === "payg") return /* @__PURE__ */ jsxs("span", {
731
+ className: isLg ? "text-2xl font-bold text-balance" : "text-primary font-medium",
732
+ children: [label.main, description && /* @__PURE__ */ jsx("span", {
733
+ className: isLg ? "block text-sm text-muted-foreground font-normal mt-1" : "block text-xs text-muted-foreground font-normal",
734
+ children: label.sub
735
+ })]
736
+ });
737
+ return /* @__PURE__ */ jsx("span", {
738
+ className: isLg ? "text-2xl text-muted-foreground font-bold" : "text-primary font-medium",
739
+ children: "Free"
740
+ });
741
+ };
742
+ //#endregion
743
+ //#region src/utils/getPlanPriceSchedule.ts
744
+ const samePriceLabel = (a, b) => {
745
+ if (a.type !== b.type) return false;
746
+ return a.type !== "priced" || b.type !== "priced" || a.amount === b.amount;
747
+ };
748
+ const rowLabel = (phase, index, lastIndex) => {
749
+ if (index === lastIndex) return "After that";
750
+ if (!phase.duration) return phase.name;
751
+ const duration = formatDuration(phase.duration);
752
+ return index === 0 ? `First ${duration}` : `Next ${duration}`;
444
753
  };
445
754
  /**
446
- * Returns the monthly and yearly headline price for a plan. Prefers the
447
- * server-provided `monthlyPrice` / `yearlyPrice` strings when present;
448
- * otherwise falls back to {@link derivePriceFromPlan}. Values that can't
449
- * be resolved are reported as `0`, which the pricing card renders as
450
- * "Free".
755
+ * A stacked price schedule for a multi-phase plan: one row per phase, each
756
+ * priced from its own rate cards (e.g. "First 3 months — Free" then
757
+ * "After that $750/month"). This is how an intro/ramp phase's price gets
758
+ * surfaced instead of only the steady-state price from {@link getPlanPrice}.
759
+ *
760
+ * Returns `undefined` when there is nothing to stack — fewer than two phases,
761
+ * or every phase resolving to the same price label (a free trial into a free
762
+ * plan, two identically-priced phases, …) — so callers fall back to the
763
+ * single-headline rendering.
451
764
  */
452
- const getPriceFromPlan = (plan) => {
453
- if (plan.monthlyPrice != null || plan.yearlyPrice != null) return {
454
- monthly: plan.monthlyPrice != null ? parseFloat(plan.monthlyPrice) : 0,
455
- yearly: plan.yearlyPrice != null ? parseFloat(plan.yearlyPrice) : 0
456
- };
457
- const derived = derivePriceFromPlan(plan);
458
- return {
459
- monthly: derived.monthly ?? 0,
460
- yearly: derived.yearly ?? 0
461
- };
765
+ const getPlanPriceSchedule = (plan) => {
766
+ const phases = plan.phases ?? [];
767
+ if (phases.length <= 1) return void 0;
768
+ const prices = phases.map(getPhasePriceLabel);
769
+ if (prices.every((price) => samePriceLabel(price, prices[0]))) return;
770
+ return phases.map((phase, index) => ({
771
+ key: phase.key ?? String(index),
772
+ label: rowLabel(phase, index, phases.length - 1),
773
+ price: prices[index]
774
+ }));
462
775
  };
463
776
  //#endregion
464
777
  //#region src/utils/pricingTaxLegend.ts
@@ -494,42 +807,26 @@ const subscriptionTaxLegendSentence = (behavior) => {
494
807
  }
495
808
  };
496
809
  //#endregion
497
- //#region src/utils/formatPlanPrice.ts
498
- const isPricedUsageRateCard = (rc) => {
499
- if (rc.type !== "usage_based" || !rc.price) return false;
500
- const p = rc.price;
501
- if (p.type === "unit") return parseFloat(p.amount) > 0;
502
- if (p.type === "tiered") return p.tiers.some((t) => parseFloat(t.flatPrice?.amount ?? "0") > 0 || parseFloat(t.unitPrice?.amount ?? "0") > 0);
503
- return true;
504
- };
505
- const hasPricedUsageRateCard = (plan) => plan.phases.some((phase) => phase.rateCards.some(isPricedUsageRateCard));
810
+ //#region src/utils/isCustomPlan.ts
506
811
  /**
507
- * Headline pricing for plan cards. Centralizes the "Pay as you go" detection:
508
- * plans whose flat-fee total is zero but that bill on usage shouldn't render
509
- * as "Free" - they're charged per-unit.
812
+ * A plan is "custom" (contact-sales, no self-serve price) when its metadata
813
+ * flags it. Mirrors the convention used by the pricing card
814
+ * (`PricingCard.tsx`), accepting boolean `true` or the string `"true"` plan
815
+ * metadata values arrive as strings from the API but may be set as booleans in
816
+ * code/fixtures.
510
817
  */
511
- const formatPlanPrice = (plan) => {
512
- if (plan.phases.length === 0) return { type: "free" };
513
- const { monthly, yearly } = getPriceFromPlan(plan);
514
- if (monthly > 0) return {
515
- type: "priced",
516
- monthly,
517
- yearly
518
- };
519
- if (hasPricedUsageRateCard(plan)) return {
520
- type: "payg",
521
- main: "Pay as you go",
522
- sub: "Usage-based pricing"
523
- };
524
- return { type: "free" };
818
+ const isCustomPlan = (plan) => {
819
+ const flag = plan.metadata?.isCustom;
820
+ return flag === true || flag === "true";
525
821
  };
526
822
  //#endregion
527
823
  //#region src/pricing-ui/PricingCard.tsx
528
- const PricingCard = ({ plan, isPopular = false, showYearlyPrice = true, units, action, className }) => {
824
+ const PricingCard = ({ plan, isPopular = false, units, action, className }) => {
529
825
  if (plan.phases.length === 0) return null;
530
826
  const priceLabel = formatPlanPrice(plan);
531
- const isCustom = plan.metadata?.isCustom === true;
827
+ const isCustom = isCustomPlan(plan);
532
828
  const billingInterval = formatDuration(plan.billingCadence);
829
+ const schedule = isCustom ? void 0 : getPlanPriceSchedule(plan);
533
830
  return /* @__PURE__ */ jsxs("div", {
534
831
  className: cn("relative rounded-lg border p-6 flex flex-col", isPopular && "border-primary border-2", className),
535
832
  children: [
@@ -547,7 +844,12 @@ const PricingCard = ({ plan, isPopular = false, showYearlyPrice = true, units, a
547
844
  className: "text-base font-semibold text-muted-foreground mb-2",
548
845
  children: plan.name
549
846
  }),
550
- /* @__PURE__ */ jsx("div", {
847
+ schedule ? /* @__PURE__ */ jsx(PlanPriceSchedule, {
848
+ schedule,
849
+ currency: plan.currency,
850
+ billingCadence: plan.billingCadence,
851
+ size: "lg"
852
+ }) : /* @__PURE__ */ jsx("div", {
551
853
  className: "flex items-baseline gap-1 flex-wrap",
552
854
  children: isCustom ? /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("div", {
553
855
  className: "text-3xl font-bold text-card-foreground",
@@ -564,20 +866,13 @@ const PricingCard = ({ plan, isPopular = false, showYearlyPrice = true, units, a
564
866
  })] }) : priceLabel.type === "free" ? /* @__PURE__ */ jsx("span", {
565
867
  className: "text-3xl font-bold text-card-foreground",
566
868
  children: "Free"
567
- }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
568
- /* @__PURE__ */ jsx("span", {
569
- className: "text-3xl font-bold text-card-foreground",
570
- children: formatPrice(priceLabel.monthly, plan.currency)
571
- }),
572
- /* @__PURE__ */ jsxs("span", {
573
- className: "text-muted-foreground text-sm",
574
- children: ["/", billingInterval]
575
- }),
576
- showYearlyPrice && priceLabel.yearly > 0 && /* @__PURE__ */ jsxs("div", {
577
- className: "w-full text-sm text-muted-foreground mt-1",
578
- children: [formatPrice(priceLabel.yearly, plan.currency), "/year"]
579
- })
580
- ] })
869
+ }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
870
+ className: "text-3xl font-bold text-card-foreground",
871
+ children: formatPrice(priceLabel.amount, plan.currency)
872
+ }), /* @__PURE__ */ jsxs("span", {
873
+ className: "text-muted-foreground text-sm",
874
+ children: ["/", billingInterval]
875
+ })] })
581
876
  }),
582
877
  plan.paymentRequired === false && /* @__PURE__ */ jsx("div", {
583
878
  className: "text-sm text-muted-foreground mt-1",
@@ -607,7 +902,7 @@ const DefaultEmptyState = () => /* @__PURE__ */ jsxs("div", {
607
902
  children: "Make sure your plans are set up and published."
608
903
  })]
609
904
  });
610
- const PricingTable = ({ plans, showYearlyPrice = true, units, renderAction, renderCard, isPopular = (plan) => plan.metadata?.zuplo_most_popular === "true", emptyState, showTaxLegend = true, className, cardClassName }) => {
905
+ const PricingTable = ({ plans, units, renderAction, renderCard, isPopular = (plan) => plan.metadata?.zuplo_most_popular === "true", emptyState, showTaxLegend = true, className, cardClassName }) => {
611
906
  if (plans.length === 0) return /* @__PURE__ */ jsx(Fragment$1, { children: emptyState ?? /* @__PURE__ */ jsx(DefaultEmptyState, {}) });
612
907
  const firstPlan = plans[0];
613
908
  const taxLegendSentence = showTaxLegend && firstPlan ? taxBehaviorLegendSentence(collectDefaultTaxBehaviors(firstPlan)) : void 0;
@@ -618,7 +913,6 @@ const PricingTable = ({ plans, showYearlyPrice = true, units, renderAction, rend
618
913
  const defaultCard = /* @__PURE__ */ jsx(PricingCard, {
619
914
  plan,
620
915
  isPopular: popular,
621
- showYearlyPrice,
622
916
  units,
623
917
  action: renderAction?.(plan, popular),
624
918
  className: cardClassName
@@ -641,4 +935,4 @@ const PricingTable = ({ plans, showYearlyPrice = true, units, renderAction, rend
641
935
  })] });
642
936
  };
643
937
  //#endregion
644
- export { formatDuration as _, subscriptionTaxLegendSentence as a, getPriceFromPlan as c, FeatureItem as d, categorizeRateCards as f, formatPrice as g, formatMinorCurrencyAmount as h, planHasDefaultTaxBehavior as i, PlanEntitlements as l, formatStaticEntitlementConfig as m, PricingCard as n, taxBehaviorLegendSentence as o, formatTieredPriceBreakdown as p, collectDefaultTaxBehaviors as r, derivePriceFromPlan as s, PricingTable as t, QuotaItem as u, formatDurationAdjective as v, formatDurationInterval as y };
938
+ export { formatStaticEntitlementConfig as C, formatDurationAdjective as D, formatDuration as E, formatDurationInterval as O, formatTieredPriceBreakdown as S, formatPrice as T, formatPlanPrice as _, planHasDefaultTaxBehavior as a, sameEntitlementSet as b, getPlanPriceSchedule as c, PlanEntitlements as d, PlanPhaseHeader as f, getPhasePriceLabel as g, FeatureItem as h, collectDefaultTaxBehaviors as i, PlanPriceTag as l, QuotaItem as m, PricingCard as n, subscriptionTaxLegendSentence as o, EntitlementList as p, isCustomPlan as r, taxBehaviorLegendSentence as s, PricingTable as t, PlanPriceSchedule as u, getPlanPrice as v, formatMinorCurrencyAmount as w, categorizeRateCards as x, comparePlanEntitlements as y };