@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.
- package/dist/{PricingTable-DNop2iX9.mjs → PricingTable-WkG2n7V-.mjs} +434 -140
- package/dist/index.d.mts +0 -1
- package/dist/index.mjs +822 -833
- package/dist/pricing-ui.d.mts +126 -31
- package/dist/pricing-ui.mjs +2 -2
- package/package.json +1 -1
|
@@ -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 && (
|
|
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(
|
|
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/
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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/
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
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
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
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
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
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
|
-
*
|
|
420
|
-
*
|
|
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
|
|
424
|
-
const
|
|
425
|
-
if (
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
};
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
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
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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/
|
|
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
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
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
|
|
512
|
-
|
|
513
|
-
|
|
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,
|
|
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
|
|
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(
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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,
|
|
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 _,
|
|
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 };
|