@zuplo/zudoku-plugin-monetization 0.0.42 → 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.
|
@@ -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 };
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
3
|
import { AlertTriangleIcon, ArrowDownIcon, ArrowLeftRightIcon, ArrowUpIcon, CalendarIcon, CheckCheckIcon, CheckIcon, CircleSlashIcon, ClockIcon, CreditCardIcon, Grid2x2XIcon, InfoIcon, Loader2Icon, LockIcon, MoreVerticalIcon, RefreshCcw, RefreshCwIcon, Settings, ShieldIcon, StarsIcon, Trash2Icon, XIcon } from "zudoku/icons";
|
|
4
4
|
import { Link, Outlet, useLocation, useNavigate, useSearchParams } from "zudoku/router";
|
|
@@ -256,7 +256,9 @@ const formatBillingCycle = (duration) => {
|
|
|
256
256
|
* Plan summary shown on the checkout and plan-change confirmation pages: an
|
|
257
257
|
* avatar + name/description on the left and the headline price (or
|
|
258
258
|
* "Free" / "Pay as you go") plus tax and billing cadence on the right,
|
|
259
|
-
* followed by the plan's included entitlements.
|
|
259
|
+
* followed by the plan's included entitlements. Multi-phase ramp plans render
|
|
260
|
+
* a full-width per-phase price schedule beneath the title instead of the
|
|
261
|
+
* single right-column price.
|
|
260
262
|
*
|
|
261
263
|
* The price is derived from the plan's rate cards via {@link formatPlanPrice}
|
|
262
264
|
* and rendered in the plan's own billing cadence, so it stays correct for any
|
|
@@ -264,10 +266,19 @@ const formatBillingCycle = (duration) => {
|
|
|
264
266
|
*/
|
|
265
267
|
const PlanSummaryCard = ({ plan, descriptionFallback, taxAmount, taxLabel, taxInclusive, units, entitlementsItemClassName }) => {
|
|
266
268
|
const priceLabel = formatPlanPrice(plan);
|
|
269
|
+
const schedule = getPlanPriceSchedule(plan);
|
|
267
270
|
const billingCycle = plan.billingCadence ? formatDuration(plan.billingCadence) : null;
|
|
271
|
+
const taxLine = taxAmount != null && /* @__PURE__ */ jsx("div", {
|
|
272
|
+
className: "text-sm font-normal mt-1",
|
|
273
|
+
children: taxInclusive ? `${formatMinorCurrencyAmount(taxAmount, plan.currency)} ${taxLabel} included` : `+ ${formatMinorCurrencyAmount(taxAmount, plan.currency)} ${taxLabel}`
|
|
274
|
+
});
|
|
275
|
+
const billedLine = billingCycle && /* @__PURE__ */ jsxs("div", {
|
|
276
|
+
className: "text-sm text-muted-foreground font-normal",
|
|
277
|
+
children: ["Billed ", formatBillingCycle(billingCycle)]
|
|
278
|
+
});
|
|
268
279
|
return /* @__PURE__ */ jsxs(Card, {
|
|
269
280
|
className: "bg-muted/50",
|
|
270
|
-
children: [/* @__PURE__ */
|
|
281
|
+
children: [/* @__PURE__ */ jsxs(CardHeader, { children: [/* @__PURE__ */ jsxs(CardTitle, {
|
|
271
282
|
className: "flex justify-between items-start",
|
|
272
283
|
children: [/* @__PURE__ */ jsxs("div", {
|
|
273
284
|
className: "flex items-center gap-3",
|
|
@@ -284,22 +295,26 @@ const PlanSummaryCard = ({ plan, descriptionFallback, taxAmount, taxLabel, taxIn
|
|
|
284
295
|
children: plan.description || descriptionFallback
|
|
285
296
|
})]
|
|
286
297
|
})]
|
|
287
|
-
}), /* @__PURE__ */ jsxs("div", {
|
|
298
|
+
}), !schedule && /* @__PURE__ */ jsxs("div", {
|
|
288
299
|
className: "text-right",
|
|
289
300
|
children: [/* @__PURE__ */ jsx(PlanPriceTag, {
|
|
290
301
|
label: priceLabel,
|
|
291
302
|
currency: plan.currency,
|
|
292
303
|
size: "lg",
|
|
293
304
|
description: true
|
|
294
|
-
}), priceLabel.type === "priced" && /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
305
|
+
}), priceLabel.type === "priced" && /* @__PURE__ */ jsxs(Fragment$1, { children: [taxLine, billedLine] })]
|
|
306
|
+
})]
|
|
307
|
+
}), schedule && /* @__PURE__ */ jsxs("div", {
|
|
308
|
+
className: "mt-3 font-normal",
|
|
309
|
+
children: [/* @__PURE__ */ jsx(PlanPriceSchedule, {
|
|
310
|
+
schedule,
|
|
311
|
+
currency: plan.currency,
|
|
312
|
+
billingCadence: plan.billingCadence
|
|
313
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
314
|
+
className: "text-right",
|
|
315
|
+
children: [taxLine, billedLine]
|
|
301
316
|
})]
|
|
302
|
-
}) }), /* @__PURE__ */ jsxs(CardContent, { children: [
|
|
317
|
+
})] }), /* @__PURE__ */ jsxs(CardContent, { children: [
|
|
303
318
|
/* @__PURE__ */ jsx(Separator, {}),
|
|
304
319
|
/* @__PURE__ */ jsx("div", {
|
|
305
320
|
className: "text-sm font-medium mb-3 mt-3",
|
|
@@ -1431,130 +1446,6 @@ const RestoreSubscriptionDialog = ({ open, onOpenChange, planName, subscriptionI
|
|
|
1431
1446
|
});
|
|
1432
1447
|
};
|
|
1433
1448
|
//#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");
|
|
1445
|
-
/**
|
|
1446
|
-
* Compare two plans' entitlements, matching strictly by feature key (never by
|
|
1447
|
-
* display name). Each key yields exactly one change row, so a key that exists
|
|
1448
|
-
* on one side and a differently-keyed feature that merely shares a display
|
|
1449
|
-
* name can never read as a contradictory "added" + "removed" of the same
|
|
1450
|
-
* thing. Labels are disambiguated afterwards when they would collide.
|
|
1451
|
-
*/
|
|
1452
|
-
const comparePlanEntitlements = (current, target) => {
|
|
1453
|
-
const changes = [];
|
|
1454
|
-
const curQuota = new Map(current.quotas.map((q) => [q.key, q]));
|
|
1455
|
-
const tgtQuota = new Map(target.quotas.map((q) => [q.key, q]));
|
|
1456
|
-
const curFeat = new Map(current.features.map((f) => [f.key, f]));
|
|
1457
|
-
const tgtFeat = new Map(target.features.map((f) => [f.key, f]));
|
|
1458
|
-
for (const key of new Set([...curQuota.keys(), ...tgtQuota.keys()])) {
|
|
1459
|
-
const c = curQuota.get(key);
|
|
1460
|
-
const t = tgtQuota.get(key);
|
|
1461
|
-
if (c && t) {
|
|
1462
|
-
const currentValue = quotaValueLabel(c);
|
|
1463
|
-
const targetValue = quotaValueLabel(t);
|
|
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)
|
|
1524
|
-
});
|
|
1525
|
-
}
|
|
1526
|
-
const labelCounts = /* @__PURE__ */ new Map();
|
|
1527
|
-
for (const ch of changes) labelCounts.set(ch.label, (labelCounts.get(ch.label) ?? 0) + 1);
|
|
1528
|
-
return changes.map(({ period, ...ch }) => {
|
|
1529
|
-
if ((labelCounts.get(ch.label) ?? 0) > 1 && ch.kind === "quota" && period) return {
|
|
1530
|
-
...ch,
|
|
1531
|
-
label: `${ch.label} (${period})`
|
|
1532
|
-
};
|
|
1533
|
-
return ch;
|
|
1534
|
-
});
|
|
1535
|
-
};
|
|
1536
|
-
//#endregion
|
|
1537
|
-
//#region src/utils/formatPhaseRampSummary.ts
|
|
1538
|
-
const durationWithCount = (iso) => {
|
|
1539
|
-
const text = formatDuration(iso);
|
|
1540
|
-
return /^\d/.test(text) ? text : `1 ${text}`;
|
|
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
|
-
};
|
|
1547
|
-
/**
|
|
1548
|
-
* One-line summary of a multi-phase plan's progression for compact UI, e.g.
|
|
1549
|
-
* `"Free Trial (1 week), then $2.99 / month"`. Returns `undefined` for
|
|
1550
|
-
* single-phase plans (nothing to summarize).
|
|
1551
|
-
*/
|
|
1552
|
-
const formatPhaseRampSummary = (plan) => {
|
|
1553
|
-
if (!plan.phases || plan.phases.length <= 1) return void 0;
|
|
1554
|
-
const first = plan.phases[0];
|
|
1555
|
-
return `${first.duration ? `${first.name} (${durationWithCount(first.duration)})` : first.name}, then ${steadyStateLabel(plan)}`;
|
|
1556
|
-
};
|
|
1557
|
-
//#endregion
|
|
1558
1449
|
//#region src/pages/components/PlanChangeCard.tsx
|
|
1559
1450
|
const MODE_LABEL = {
|
|
1560
1451
|
upgrade: "Upgrade",
|
|
@@ -1665,18 +1556,28 @@ const ChangeRow = ({ change }) => {
|
|
|
1665
1556
|
const PlanChangeCard = ({ plan, mode, currentEntitlements, isNewerVersion, isSwitching, units, onSwitch }) => {
|
|
1666
1557
|
const isCustom = isCustomPlan(plan);
|
|
1667
1558
|
const priceLabel = formatPlanPrice(plan);
|
|
1668
|
-
const
|
|
1669
|
-
const
|
|
1670
|
-
const
|
|
1671
|
-
|
|
1559
|
+
const schedule = isCustom ? void 0 : getPlanPriceSchedule(plan);
|
|
1560
|
+
const phaseChangeGroups = useMemo(() => {
|
|
1561
|
+
const diff = (target) => {
|
|
1562
|
+
const changes = comparePlanEntitlements(currentEntitlements, target);
|
|
1563
|
+
return [...changes.filter((c) => c.change !== "removed"), ...changes.filter((c) => c.change === "removed")];
|
|
1564
|
+
};
|
|
1565
|
+
const sets = plan.phases.map((phase) => categorizeRateCards(phase.rateCards, {
|
|
1672
1566
|
currency: plan.currency,
|
|
1673
1567
|
units,
|
|
1674
1568
|
planBillingCadence: plan.billingCadence
|
|
1675
|
-
})
|
|
1569
|
+
}));
|
|
1570
|
+
return (plan.phases.length <= 1 || sets.every((set) => sameEntitlementSet(set, sets[0])) ? [{ changes: diff(sets.at(-1) ?? {
|
|
1676
1571
|
quotas: [],
|
|
1677
1572
|
features: []
|
|
1678
|
-
})
|
|
1679
|
-
|
|
1573
|
+
}) }] : plan.phases.flatMap((phase, idx) => {
|
|
1574
|
+
const set = sets[idx];
|
|
1575
|
+
if (set.quotas.length === 0 && set.features.length === 0) return [];
|
|
1576
|
+
return [{
|
|
1577
|
+
phase,
|
|
1578
|
+
changes: diff(set)
|
|
1579
|
+
}];
|
|
1580
|
+
})).filter((group) => group.changes.length > 0);
|
|
1680
1581
|
}, [
|
|
1681
1582
|
plan,
|
|
1682
1583
|
currentEntitlements,
|
|
@@ -1702,7 +1603,7 @@ const PlanChangeCard = ({ plan, mode, currentEntitlements, isNewerVersion, isSwi
|
|
|
1702
1603
|
}), isCustom ? /* @__PURE__ */ jsx("span", {
|
|
1703
1604
|
className: "text-primary font-medium",
|
|
1704
1605
|
children: "Custom"
|
|
1705
|
-
}) : /* @__PURE__ */ jsx(PlanPriceTag, {
|
|
1606
|
+
}) : !schedule && /* @__PURE__ */ jsx(PlanPriceTag, {
|
|
1706
1607
|
label: priceLabel,
|
|
1707
1608
|
currency: plan.currency,
|
|
1708
1609
|
billingCadence: plan.billingCadence
|
|
@@ -1719,13 +1620,22 @@ const PlanChangeCard = ({ plan, mode, currentEntitlements, isNewerVersion, isSwi
|
|
|
1719
1620
|
children: MODE_LABEL[mode]
|
|
1720
1621
|
})]
|
|
1721
1622
|
}),
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1623
|
+
schedule && /* @__PURE__ */ jsx(PlanPriceSchedule, {
|
|
1624
|
+
schedule,
|
|
1625
|
+
currency: plan.currency,
|
|
1626
|
+
billingCadence: plan.billingCadence,
|
|
1627
|
+
className: "mb-2"
|
|
1725
1628
|
}),
|
|
1726
|
-
|
|
1727
|
-
className: "space-y-
|
|
1728
|
-
children:
|
|
1629
|
+
phaseChangeGroups.length > 0 && /* @__PURE__ */ jsx("div", {
|
|
1630
|
+
className: "space-y-3",
|
|
1631
|
+
children: phaseChangeGroups.map((group, idx) => /* @__PURE__ */ jsxs("div", {
|
|
1632
|
+
className: "space-y-1.5",
|
|
1633
|
+
children: [group.phase && /* @__PURE__ */ jsx(PlanPhaseHeader, {
|
|
1634
|
+
phase: group.phase,
|
|
1635
|
+
currency: plan.currency,
|
|
1636
|
+
billingCadence: plan.billingCadence
|
|
1637
|
+
}), group.changes.map((change) => /* @__PURE__ */ jsx(ChangeRow, { change }, `${change.kind}:${change.key}`))]
|
|
1638
|
+
}, group.phase?.key ?? String(idx)))
|
|
1729
1639
|
})
|
|
1730
1640
|
]
|
|
1731
1641
|
});
|
package/dist/pricing-ui.d.mts
CHANGED
|
@@ -142,6 +142,13 @@ declare const FeatureItem: ({
|
|
|
142
142
|
}) => import("react/jsx-runtime").JSX.Element;
|
|
143
143
|
//#endregion
|
|
144
144
|
//#region src/pricing-ui/PlanEntitlements.d.ts
|
|
145
|
+
/**
|
|
146
|
+
* A plan's entitlements, phase by phase. Multi-phase plans whose phases all
|
|
147
|
+
* resolve to the same entitlements collapse into a single list (the phases
|
|
148
|
+
* only differ in price, which the price schedule already tells); phases with
|
|
149
|
+
* genuinely different entitlements render as separate sections headed by the
|
|
150
|
+
* phase name, duration, and that phase's own price.
|
|
151
|
+
*/
|
|
145
152
|
declare const PlanEntitlements: ({
|
|
146
153
|
phases,
|
|
147
154
|
currency,
|
|
@@ -174,6 +181,52 @@ type PlanPriceLabel = {
|
|
|
174
181
|
*/
|
|
175
182
|
declare const formatPlanPrice: (plan: Plan) => PlanPriceLabel;
|
|
176
183
|
//#endregion
|
|
184
|
+
//#region src/utils/getPlanPriceSchedule.d.ts
|
|
185
|
+
type PlanPriceScheduleRow = {
|
|
186
|
+
/** Stable row key — the phase key, falling back to the phase index. */key: string; /** Left-column label, e.g. "First 3 months" / "Next 2 months" / "After that". */
|
|
187
|
+
label: string; /** The phase's own price, derived from its rate cards alone. */
|
|
188
|
+
price: PlanPriceLabel;
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* A stacked price schedule for a multi-phase plan: one row per phase, each
|
|
192
|
+
* priced from its own rate cards (e.g. "First 3 months — Free" then
|
|
193
|
+
* "After that — $750/month"). This is how an intro/ramp phase's price gets
|
|
194
|
+
* surfaced instead of only the steady-state price from {@link getPlanPrice}.
|
|
195
|
+
*
|
|
196
|
+
* Returns `undefined` when there is nothing to stack — fewer than two phases,
|
|
197
|
+
* or every phase resolving to the same price label (a free trial into a free
|
|
198
|
+
* plan, two identically-priced phases, …) — so callers fall back to the
|
|
199
|
+
* single-headline rendering.
|
|
200
|
+
*/
|
|
201
|
+
declare const getPlanPriceSchedule: (plan: Plan) => PlanPriceScheduleRow[] | undefined;
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/pricing-ui/PlanPriceSchedule.d.ts
|
|
204
|
+
/**
|
|
205
|
+
* Stacked per-phase price rows for a multi-phase plan, replacing the single
|
|
206
|
+
* headline price (which only reflects the steady-state phase): each row pairs
|
|
207
|
+
* a phase label ("First 3 months", "After that") with that phase's own price.
|
|
208
|
+
* Every row gets equal visual weight — the intro price is part of the plan's
|
|
209
|
+
* price, not a footnote.
|
|
210
|
+
*
|
|
211
|
+
* Callers derive the rows via {@link getPlanPriceSchedule} and fall back to
|
|
212
|
+
* the single-price rendering when it returns `undefined`. `size` picks the
|
|
213
|
+
* typographic treatment: `"lg"` for a card's headline area, `"sm"` for
|
|
214
|
+
* compact contexts (plan-change rows, summary cards).
|
|
215
|
+
*/
|
|
216
|
+
declare const PlanPriceSchedule: ({
|
|
217
|
+
schedule,
|
|
218
|
+
currency,
|
|
219
|
+
billingCadence,
|
|
220
|
+
size,
|
|
221
|
+
className
|
|
222
|
+
}: {
|
|
223
|
+
schedule: PlanPriceScheduleRow[];
|
|
224
|
+
currency?: string; /** Render each priced row with the `/cadence` suffix (the plan's billing cadence). */
|
|
225
|
+
billingCadence?: string;
|
|
226
|
+
size?: "sm" | "lg";
|
|
227
|
+
className?: string;
|
|
228
|
+
}) => import("react/jsx-runtime").JSX.Element;
|
|
229
|
+
//#endregion
|
|
177
230
|
//#region src/pricing-ui/PlanPriceTag.d.ts
|
|
178
231
|
/**
|
|
179
232
|
* Headline price for a plan/subscription: `$X/cadence`, "Pay as you go", or
|
|
@@ -320,6 +373,19 @@ declare const formatTieredPriceBreakdown: (opts: {
|
|
|
320
373
|
includedLabel: string;
|
|
321
374
|
}) => string[] | undefined;
|
|
322
375
|
//#endregion
|
|
376
|
+
//#region src/utils/getPhasePriceLabel.d.ts
|
|
377
|
+
/**
|
|
378
|
+
* Headline price for a SINGLE phase, derived only from that phase's own rate
|
|
379
|
+
* cards. Mirrors {@link formatPlanPrice}'s rules, but scoped to the phase:
|
|
380
|
+
* a positive recurring flat-fee total is `priced`; otherwise a priced
|
|
381
|
+
* `usage_based` card in this phase makes it `payg`; otherwise it's `free`.
|
|
382
|
+
*
|
|
383
|
+
* Like {@link getPlanPrice}, one-time fees (`flat_fee` with
|
|
384
|
+
* `billingCadence: null`) and `price: null` rate cards contribute nothing —
|
|
385
|
+
* an intro phase whose fees all have `price: null` derives as `free`.
|
|
386
|
+
*/
|
|
387
|
+
declare const getPhasePriceLabel: (phase: PlanPhase) => PlanPriceLabel;
|
|
388
|
+
//#endregion
|
|
323
389
|
//#region src/utils/getPlanPrice.d.ts
|
|
324
390
|
/**
|
|
325
391
|
* The plan's headline recurring price: the sum of every recurring `flat_fee`
|
|
@@ -346,4 +412,4 @@ declare const collectDefaultTaxBehaviors: (plan: Plan) => CanonicalTaxBehavior;
|
|
|
346
412
|
declare const taxBehaviorLegendSentence: (behavior: string) => string | undefined;
|
|
347
413
|
declare const subscriptionTaxLegendSentence: (behavior: string) => string | undefined;
|
|
348
414
|
//#endregion
|
|
349
|
-
export { type Alignment, type BooleanEntitlementTemplate, type DynamicPrice, type EntitlementTemplate, type Feature, FeatureItem, type FlatFeeRateCard, type FlatPrice, type MeteredEntitlementTemplate, type PackagePrice, type Plan, type PlanDefaultTaxConfig, PlanEntitlements, type PlanPhase, type PlanPriceLabel, PlanPriceTag, type Price, type PriceTier, PricingCard, type PricingCardProps, PricingTable, type PricingTableProps, type ProRatingConfig, type Quota, QuotaItem, type RateCard, type StaticEntitlementTemplate, type TieredPrice, type TieredPriceBreakdownTier, type UnitPrice, type UsageBasedRateCard, type ValidationError, categorizeRateCards, collectDefaultTaxBehaviors, formatDuration, formatDurationAdjective, formatDurationInterval, formatMinorCurrencyAmount, formatPlanPrice, formatPrice, formatStaticEntitlementConfig, formatTieredPriceBreakdown, getPlanPrice, planHasDefaultTaxBehavior, subscriptionTaxLegendSentence, taxBehaviorLegendSentence };
|
|
415
|
+
export { type Alignment, type BooleanEntitlementTemplate, type DynamicPrice, type EntitlementTemplate, type Feature, FeatureItem, type FlatFeeRateCard, type FlatPrice, type MeteredEntitlementTemplate, type PackagePrice, type Plan, type PlanDefaultTaxConfig, PlanEntitlements, type PlanPhase, type PlanPriceLabel, PlanPriceSchedule, type PlanPriceScheduleRow, PlanPriceTag, type Price, type PriceTier, PricingCard, type PricingCardProps, PricingTable, type PricingTableProps, type ProRatingConfig, type Quota, QuotaItem, type RateCard, type StaticEntitlementTemplate, type TieredPrice, type TieredPriceBreakdownTier, type UnitPrice, type UsageBasedRateCard, type ValidationError, categorizeRateCards, collectDefaultTaxBehaviors, formatDuration, formatDurationAdjective, formatDurationInterval, formatMinorCurrencyAmount, formatPlanPrice, formatPrice, formatStaticEntitlementConfig, formatTieredPriceBreakdown, getPhasePriceLabel, getPlanPrice, getPlanPriceSchedule, planHasDefaultTaxBehavior, subscriptionTaxLegendSentence, taxBehaviorLegendSentence };
|
package/dist/pricing-ui.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export { FeatureItem, PlanEntitlements, PlanPriceTag, PricingCard, PricingTable, QuotaItem, categorizeRateCards, collectDefaultTaxBehaviors, formatDuration, formatDurationAdjective, formatDurationInterval, formatMinorCurrencyAmount, formatPlanPrice, formatPrice, formatStaticEntitlementConfig, formatTieredPriceBreakdown, getPlanPrice, planHasDefaultTaxBehavior, subscriptionTaxLegendSentence, taxBehaviorLegendSentence };
|
|
1
|
+
import { C as formatStaticEntitlementConfig, D as formatDurationAdjective, E as formatDuration, O as formatDurationInterval, S as formatTieredPriceBreakdown, T as formatPrice, _ as formatPlanPrice, a as planHasDefaultTaxBehavior, c as getPlanPriceSchedule, d as PlanEntitlements, g as getPhasePriceLabel, h as FeatureItem, i as collectDefaultTaxBehaviors, l as PlanPriceTag, m as QuotaItem, n as PricingCard, o as subscriptionTaxLegendSentence, s as taxBehaviorLegendSentence, t as PricingTable, u as PlanPriceSchedule, v as getPlanPrice, w as formatMinorCurrencyAmount, x as categorizeRateCards } from "./PricingTable-WkG2n7V-.mjs";
|
|
2
|
+
export { FeatureItem, PlanEntitlements, PlanPriceSchedule, PlanPriceTag, PricingCard, PricingTable, QuotaItem, categorizeRateCards, collectDefaultTaxBehaviors, formatDuration, formatDurationAdjective, formatDurationInterval, formatMinorCurrencyAmount, formatPlanPrice, formatPrice, formatStaticEntitlementConfig, formatTieredPriceBreakdown, getPhasePriceLabel, getPlanPrice, getPlanPriceSchedule, planHasDefaultTaxBehavior, subscriptionTaxLegendSentence, taxBehaviorLegendSentence };
|