@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/{PricingTable-BlcXx4-5.mjs → PricingTable-WkG2n7V-.mjs} +354 -79
- package/dist/index.mjs +419 -274
- package/dist/pricing-ui.d.mts +67 -1
- package/dist/pricing-ui.mjs +2 -2
- package/package.json +1 -1
|
@@ -265,6 +265,216 @@ const categorizeRateCards = (rateCards, options) => {
|
|
|
265
265
|
};
|
|
266
266
|
};
|
|
267
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
|
|
268
478
|
//#region src/pricing-ui/CheckIcon.tsx
|
|
269
479
|
/**
|
|
270
480
|
* Inline `Check` icon, visually identical to `lucide-react`'s `CheckIcon`
|
|
@@ -372,43 +582,128 @@ const EntitlementList = ({ quotas, features, header, itemClassName }) => {
|
|
|
372
582
|
};
|
|
373
583
|
//#endregion
|
|
374
584
|
//#region src/pricing-ui/PlanEntitlements.tsx
|
|
375
|
-
const
|
|
376
|
-
|
|
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
|
+
*/
|
|
636
|
+
const PlanEntitlements = ({ phases, currency, billingCadence, units, itemClassName }) => {
|
|
637
|
+
const sets = phases.map((phase) => categorizeRateCards(phase.rateCards, {
|
|
377
638
|
currency,
|
|
378
639
|
units,
|
|
379
640
|
planBillingCadence: billingCadence
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
formatDuration(phase.duration)
|
|
393
|
-
]
|
|
394
|
-
})]
|
|
395
|
-
}) : void 0
|
|
396
|
-
});
|
|
397
|
-
};
|
|
398
|
-
const PlanEntitlements = ({ phases, currency, billingCadence, units, itemClassName }) => {
|
|
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
|
+
}
|
|
399
653
|
return /* @__PURE__ */ jsx("div", {
|
|
400
654
|
className: "space-y-4",
|
|
401
655
|
children: phases.map((phase, idx) => /* @__PURE__ */ jsx(PhaseSection, {
|
|
402
656
|
phase,
|
|
657
|
+
set: sets[idx],
|
|
403
658
|
currency,
|
|
404
|
-
showName: phases.length > 1,
|
|
405
659
|
billingCadence,
|
|
406
|
-
units,
|
|
407
660
|
itemClassName
|
|
408
661
|
}, phase.key ?? String(idx)))
|
|
409
662
|
});
|
|
410
663
|
};
|
|
411
664
|
//#endregion
|
|
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
|
+
});
|
|
678
|
+
};
|
|
679
|
+
/**
|
|
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).
|
|
690
|
+
*/
|
|
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
|
|
412
707
|
//#region src/pricing-ui/PlanPriceTag.tsx
|
|
413
708
|
/**
|
|
414
709
|
* Headline price for a plan/subscription: `$X/cadence`, "Pay as you go", or
|
|
@@ -445,64 +740,38 @@ const PlanPriceTag = ({ label, currency, billingCadence, description = false, si
|
|
|
445
740
|
});
|
|
446
741
|
};
|
|
447
742
|
//#endregion
|
|
448
|
-
//#region src/utils/
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const amount = Number(rc.price.amount);
|
|
453
|
-
if (Number.isFinite(amount)) total += amount;
|
|
454
|
-
}
|
|
455
|
-
return total;
|
|
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;
|
|
456
747
|
};
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
*
|
|
463
|
-
* This is derived entirely from the plan's rate cards. It deliberately does
|
|
464
|
-
* NOT read any server-provided `monthlyPrice` / `yearlyPrice` and performs no
|
|
465
|
-
* cadence conversion, so it stays correct for any billing cadence — hourly
|
|
466
|
-
* (`PT1H`), weekly, monthly, yearly, etc. Callers pair the returned amount
|
|
467
|
-
* with `formatDuration(plan.billingCadence)` to render e.g. `$2.99/hour`.
|
|
468
|
-
*
|
|
469
|
-
* Returns `0` when there are no phases or no recurring flat fee, which callers
|
|
470
|
-
* render as "Free" (or "Pay as you go" when the plan bills on usage — see
|
|
471
|
-
* {@link formatPlanPrice}).
|
|
472
|
-
*/
|
|
473
|
-
const getPlanPrice = (plan) => {
|
|
474
|
-
const lastPhase = plan.phases?.at(-1);
|
|
475
|
-
if (!lastPhase) return 0;
|
|
476
|
-
return sumFlatFeeAmounts(lastPhase.rateCards ?? []);
|
|
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}`;
|
|
477
753
|
};
|
|
478
|
-
//#endregion
|
|
479
|
-
//#region src/utils/formatPlanPrice.ts
|
|
480
|
-
const isPricedUsageRateCard = (rc) => {
|
|
481
|
-
if (rc.type !== "usage_based" || !rc.price) return false;
|
|
482
|
-
const p = rc.price;
|
|
483
|
-
if (p.type === "unit") return parseFloat(p.amount) > 0;
|
|
484
|
-
if (p.type === "tiered") return p.tiers.some(tierHasPositivePrice);
|
|
485
|
-
return true;
|
|
486
|
-
};
|
|
487
|
-
const hasPricedUsageRateCard = (plan) => plan.phases.some((phase) => phase.rateCards.some(isPricedUsageRateCard));
|
|
488
754
|
/**
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
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.
|
|
492
764
|
*/
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
sub: "Usage-based pricing"
|
|
504
|
-
};
|
|
505
|
-
return { type: "free" };
|
|
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
|
+
}));
|
|
506
775
|
};
|
|
507
776
|
//#endregion
|
|
508
777
|
//#region src/utils/pricingTaxLegend.ts
|
|
@@ -557,6 +826,7 @@ const PricingCard = ({ plan, isPopular = false, units, action, className }) => {
|
|
|
557
826
|
const priceLabel = formatPlanPrice(plan);
|
|
558
827
|
const isCustom = isCustomPlan(plan);
|
|
559
828
|
const billingInterval = formatDuration(plan.billingCadence);
|
|
829
|
+
const schedule = isCustom ? void 0 : getPlanPriceSchedule(plan);
|
|
560
830
|
return /* @__PURE__ */ jsxs("div", {
|
|
561
831
|
className: cn("relative rounded-lg border p-6 flex flex-col", isPopular && "border-primary border-2", className),
|
|
562
832
|
children: [
|
|
@@ -574,7 +844,12 @@ const PricingCard = ({ plan, isPopular = false, units, action, className }) => {
|
|
|
574
844
|
className: "text-base font-semibold text-muted-foreground mb-2",
|
|
575
845
|
children: plan.name
|
|
576
846
|
}),
|
|
577
|
-
/* @__PURE__ */ jsx(
|
|
847
|
+
schedule ? /* @__PURE__ */ jsx(PlanPriceSchedule, {
|
|
848
|
+
schedule,
|
|
849
|
+
currency: plan.currency,
|
|
850
|
+
billingCadence: plan.billingCadence,
|
|
851
|
+
size: "lg"
|
|
852
|
+
}) : /* @__PURE__ */ jsx("div", {
|
|
578
853
|
className: "flex items-baseline gap-1 flex-wrap",
|
|
579
854
|
children: isCustom ? /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("div", {
|
|
580
855
|
className: "text-3xl font-bold text-card-foreground",
|
|
@@ -660,4 +935,4 @@ const PricingTable = ({ plans, units, renderAction, renderCard, isPopular = (pla
|
|
|
660
935
|
})] });
|
|
661
936
|
};
|
|
662
937
|
//#endregion
|
|
663
|
-
export { formatDurationInterval as S,
|
|
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 };
|