@zuplo/zudoku-plugin-monetization 0.0.41 → 0.0.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { Fragment } from "react";
2
- import { parse } from "tinyduration";
3
2
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
3
+ import { parse } from "tinyduration";
4
4
  import { clsx } from "clsx";
5
5
  import { twMerge } from "tailwind-merge";
6
6
  //#region src/utils/formatDuration.ts
@@ -136,6 +136,17 @@ const formatTieredPriceBreakdown = (opts) => {
136
136
  return lines.length > 0 ? lines : void 0;
137
137
  };
138
138
  //#endregion
139
+ //#region src/utils/tierHasPositivePrice.ts
140
+ /**
141
+ * Whether a price tier charges anything — a non-zero flat or per-unit amount.
142
+ * Amounts are decimal strings and a missing part counts as zero, so this
143
+ * distinguishes a genuinely priced tier from an all-zero ("Included") tier.
144
+ * Shared by the pricing-label path ({@link formatPlanPrice}) and rate-card
145
+ * categorization ({@link categorizeRateCards}) so both decide "is this tier
146
+ * free?" the same way.
147
+ */
148
+ const tierHasPositivePrice = (tier) => parseFloat(tier.flatPrice?.amount ?? "0") > 0 || parseFloat(tier.unitPrice?.amount ?? "0") > 0;
149
+ //#endregion
139
150
  //#region src/utils/categorizeRateCards.ts
140
151
  const categorizeRateCards = (rateCards, options) => {
141
152
  const { currency, units, planBillingCadence } = options ?? {};
@@ -152,7 +163,7 @@ const categorizeRateCards = (rateCards, options) => {
152
163
  return "month";
153
164
  };
154
165
  const firstTier = rc.price?.type === "tiered" && rc.price.tiers.length > 0 ? rc.price.tiers[0] : void 0;
155
- const firstTierIsPriced = !!firstTier && (parseFloat(firstTier.flatPrice?.amount ?? "0") > 0 || parseFloat(firstTier.unitPrice?.amount ?? "0") > 0);
166
+ const firstTierIsPriced = !!firstTier && tierHasPositivePrice(firstTier);
156
167
  if (et.type === "metered" && et.issueAfterReset != null && !firstTierIsPriced) {
157
168
  let tierPrices;
158
169
  if (rc.price?.type === "tiered" && rc.price.tiers) tierPrices = formatTieredPriceBreakdown({
@@ -176,7 +187,7 @@ const categorizeRateCards = (rateCards, options) => {
176
187
  const unitLabel = unitLabelFor(rc);
177
188
  if (rc.price.type === "tiered" && rc.price.tiers.length > 0) {
178
189
  const tiers = rc.price.tiers;
179
- if (!tiers.some((t) => parseFloat(t.flatPrice?.amount ?? "0") > 0 || parseFloat(t.unitPrice?.amount ?? "0") > 0)) {
190
+ if (!tiers.some(tierHasPositivePrice)) {
180
191
  features.push({
181
192
  key: rc.featureKey ?? rc.key,
182
193
  name: rc.name
@@ -331,28 +342,23 @@ const QuotaItem = ({ quota, className }) => {
331
342
  });
332
343
  };
333
344
  //#endregion
334
- //#region src/pricing-ui/PlanEntitlements.tsx
335
- const PhaseSection = ({ phase, currency, showName, billingCadence, units, itemClassName }) => {
336
- const { quotas, features } = categorizeRateCards(phase.rateCards, {
337
- currency,
338
- units,
339
- planBillingCadence: billingCadence
340
- });
345
+ //#region src/pricing-ui/EntitlementList.tsx
346
+ /**
347
+ * Vertical list of a resolved entitlement set — quotas first, then features —
348
+ * with an optional leading `header` (e.g. a phase name) rendered inside the same
349
+ * spacing container. Returns `null` when there are no quotas or features, so
350
+ * callers can gate a section header / border without leaving an empty container
351
+ * behind.
352
+ *
353
+ * Shared by {@link PlanEntitlements} (one list per phase) and the subscription
354
+ * entitlement views so quotas and features always render identically.
355
+ */
356
+ const EntitlementList = ({ quotas, features, header, itemClassName }) => {
341
357
  if (quotas.length === 0 && features.length === 0) return null;
342
358
  return /* @__PURE__ */ jsxs("div", {
343
359
  className: "space-y-2",
344
360
  children: [
345
- showName && /* @__PURE__ */ jsxs("div", {
346
- className: "text-sm font-medium text-card-foreground",
347
- children: [phase.name, phase.duration && /* @__PURE__ */ jsxs("span", {
348
- className: "text-muted-foreground font-normal",
349
- children: [
350
- " ",
351
- "— ",
352
- formatDuration(phase.duration)
353
- ]
354
- })]
355
- }),
361
+ header,
356
362
  quotas.map((quota) => /* @__PURE__ */ jsx(QuotaItem, {
357
363
  quota,
358
364
  className: itemClassName
@@ -364,6 +370,31 @@ const PhaseSection = ({ phase, currency, showName, billingCadence, units, itemCl
364
370
  ]
365
371
  });
366
372
  };
373
+ //#endregion
374
+ //#region src/pricing-ui/PlanEntitlements.tsx
375
+ const PhaseSection = ({ phase, currency, showName, billingCadence, units, itemClassName }) => {
376
+ const { quotas, features } = categorizeRateCards(phase.rateCards, {
377
+ currency,
378
+ units,
379
+ planBillingCadence: billingCadence
380
+ });
381
+ return /* @__PURE__ */ jsx(EntitlementList, {
382
+ quotas,
383
+ features,
384
+ itemClassName,
385
+ header: showName ? /* @__PURE__ */ jsxs("div", {
386
+ className: "text-sm font-medium text-card-foreground",
387
+ children: [phase.name, phase.duration && /* @__PURE__ */ jsxs("span", {
388
+ className: "text-muted-foreground font-normal",
389
+ children: [
390
+ " ",
391
+ "— ",
392
+ formatDuration(phase.duration)
393
+ ]
394
+ })]
395
+ }) : void 0
396
+ });
397
+ };
367
398
  const PlanEntitlements = ({ phases, currency, billingCadence, units, itemClassName }) => {
368
399
  return /* @__PURE__ */ jsx("div", {
369
400
  className: "space-y-4",
@@ -378,87 +409,100 @@ const PlanEntitlements = ({ phases, currency, billingCadence, units, itemClassNa
378
409
  });
379
410
  };
380
411
  //#endregion
381
- //#region src/utils/getPriceFromPlan.ts
412
+ //#region src/pricing-ui/PlanPriceTag.tsx
413
+ /**
414
+ * Headline price for a plan/subscription: `$X/cadence`, "Pay as you go", or
415
+ * "Free", from a {@link PlanPriceLabel}. Shared by the subscription details
416
+ * page, the Switch Plan baseline, each plan-change card, and the checkout /
417
+ * plan-change summary cards so they all render the price identically.
418
+ *
419
+ * `size` selects the typographic treatment:
420
+ * - `"inline"` (default): compact, primary-colored text for use beside a name.
421
+ * - `"lg"`: a large foreground headline for a summary card's price column.
422
+ *
423
+ * Pass `description` to surface the "Usage-based pricing" subline under the
424
+ * "Pay as you go" headline (used where there's room for it).
425
+ */
426
+ const PlanPriceTag = ({ label, currency, billingCadence, description = false, size = "inline" }) => {
427
+ const isLg = size === "lg";
428
+ if (label.type === "priced") return /* @__PURE__ */ jsxs("span", {
429
+ className: isLg ? "text-2xl font-bold" : "text-primary font-medium text-lg",
430
+ children: [formatPrice(label.amount, currency), billingCadence && /* @__PURE__ */ jsxs("span", {
431
+ className: "text-muted-foreground font-normal",
432
+ children: ["/", formatDuration(billingCadence)]
433
+ })]
434
+ });
435
+ if (label.type === "payg") return /* @__PURE__ */ jsxs("span", {
436
+ className: isLg ? "text-2xl font-bold text-balance" : "text-primary font-medium",
437
+ children: [label.main, description && /* @__PURE__ */ jsx("span", {
438
+ className: isLg ? "block text-sm text-muted-foreground font-normal mt-1" : "block text-xs text-muted-foreground font-normal",
439
+ children: label.sub
440
+ })]
441
+ });
442
+ return /* @__PURE__ */ jsx("span", {
443
+ className: isLg ? "text-2xl text-muted-foreground font-bold" : "text-primary font-medium",
444
+ children: "Free"
445
+ });
446
+ };
447
+ //#endregion
448
+ //#region src/utils/getPlanPrice.ts
382
449
  const sumFlatFeeAmounts = (rateCards) => {
383
450
  let total = 0;
384
- for (const rc of rateCards) if (rc.type === "flat_fee" && rc.price) {
451
+ for (const rc of rateCards) if (rc.type === "flat_fee" && rc.price && rc.billingCadence !== null) {
385
452
  const amount = Number(rc.price.amount);
386
453
  if (Number.isFinite(amount)) total += amount;
387
454
  }
388
455
  return total;
389
456
  };
390
457
  /**
391
- * Convert an ISO 8601 duration to an approximate number of months.
392
- * Years and months contribute exactly; weeks use 12/52 months/week and
393
- * days use 1/30 months/day. Sub-day units (hours, minutes, seconds) do
394
- * not contribute and a duration consisting only of those returns
395
- * `undefined` because there is no sensible monthly equivalent.
396
- */
397
- const cadenceToMonths = (iso) => {
398
- try {
399
- const d = parse(iso);
400
- let months = 0;
401
- if (d.years) months += d.years * 12;
402
- if (d.months) months += d.months;
403
- if (d.weeks) months += d.weeks * (12 / 52);
404
- if (d.days) months += d.days * (1 / 30);
405
- return months > 0 ? months : void 0;
406
- } catch {
407
- return;
408
- }
409
- };
410
- /**
411
- * Derive a (monthly, yearly) headline price from a plan's last phase by
412
- * summing all `flat_fee` rate-card amounts and converting from the plan's
413
- * `billingCadence` to a monthly equivalent.
458
+ * The plan's headline recurring price: the sum of every recurring `flat_fee`
459
+ * rate-card amount on the plan's steady-state (last) phase, expressed in the
460
+ * plan's own `billingCadence`. One-time fees (`flat_fee` with
461
+ * `billingCadence: null`, e.g. a setup fee) are excluded.
414
462
  *
415
- * Returns `null` for either field when no value can be derived (no
416
- * phases, or an unparseable / sub-day cadence). A flat-fee sum of 0
417
- * returns `{ monthly: 0, yearly: 0 }` (Free).
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`.
418
468
  *
419
- * Useful for consumers whose source data doesn't already include
420
- * pre-computed `monthlyPrice` / `yearlyPrice` pass the result through
421
- * (or rely on `getPriceFromPlan`'s built-in fallback).
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}).
422
472
  */
423
- const derivePriceFromPlan = (plan) => {
473
+ const getPlanPrice = (plan) => {
424
474
  const lastPhase = plan.phases?.at(-1);
425
- if (!lastPhase) return {
426
- monthly: null,
427
- yearly: null
428
- };
429
- const flatPrice = sumFlatFeeAmounts(lastPhase.rateCards ?? []);
430
- if (flatPrice === 0) return {
431
- monthly: 0,
432
- yearly: 0
433
- };
434
- const months = cadenceToMonths(plan.billingCadence);
435
- if (months == null) return {
436
- monthly: null,
437
- yearly: null
438
- };
439
- const monthly = flatPrice / months;
440
- return {
441
- monthly,
442
- yearly: monthly * 12
443
- };
475
+ if (!lastPhase) return 0;
476
+ return sumFlatFeeAmounts(lastPhase.rateCards ?? []);
477
+ };
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;
444
486
  };
487
+ const hasPricedUsageRateCard = (plan) => plan.phases.some((phase) => phase.rateCards.some(isPricedUsageRateCard));
445
488
  /**
446
- * Returns the monthly and yearly headline price for a plan. Prefers the
447
- * server-provided `monthlyPrice` / `yearlyPrice` strings when present;
448
- * otherwise falls back to {@link derivePriceFromPlan}. Values that can't
449
- * be resolved are reported as `0`, which the pricing card renders as
450
- * "Free".
489
+ * Headline pricing for plan cards. Centralizes the "Pay as you go" detection:
490
+ * plans whose flat-fee total is zero but that bill on usage shouldn't render
491
+ * as "Free" - they're charged per-unit.
451
492
  */
452
- const getPriceFromPlan = (plan) => {
453
- if (plan.monthlyPrice != null || plan.yearlyPrice != null) return {
454
- monthly: plan.monthlyPrice != null ? parseFloat(plan.monthlyPrice) : 0,
455
- yearly: plan.yearlyPrice != null ? parseFloat(plan.yearlyPrice) : 0
493
+ const formatPlanPrice = (plan) => {
494
+ if (!plan.phases || plan.phases.length === 0) return { type: "free" };
495
+ const amount = getPlanPrice(plan);
496
+ if (amount > 0) return {
497
+ type: "priced",
498
+ amount
456
499
  };
457
- const derived = derivePriceFromPlan(plan);
458
- return {
459
- monthly: derived.monthly ?? 0,
460
- yearly: derived.yearly ?? 0
500
+ if (hasPricedUsageRateCard(plan)) return {
501
+ type: "payg",
502
+ main: "Pay as you go",
503
+ sub: "Usage-based pricing"
461
504
  };
505
+ return { type: "free" };
462
506
  };
463
507
  //#endregion
464
508
  //#region src/utils/pricingTaxLegend.ts
@@ -494,41 +538,24 @@ const subscriptionTaxLegendSentence = (behavior) => {
494
538
  }
495
539
  };
496
540
  //#endregion
497
- //#region src/utils/formatPlanPrice.ts
498
- const isPricedUsageRateCard = (rc) => {
499
- if (rc.type !== "usage_based" || !rc.price) return false;
500
- const p = rc.price;
501
- if (p.type === "unit") return parseFloat(p.amount) > 0;
502
- if (p.type === "tiered") return p.tiers.some((t) => parseFloat(t.flatPrice?.amount ?? "0") > 0 || parseFloat(t.unitPrice?.amount ?? "0") > 0);
503
- return true;
504
- };
505
- const hasPricedUsageRateCard = (plan) => plan.phases.some((phase) => phase.rateCards.some(isPricedUsageRateCard));
541
+ //#region src/utils/isCustomPlan.ts
506
542
  /**
507
- * Headline pricing for plan cards. Centralizes the "Pay as you go" detection:
508
- * plans whose flat-fee total is zero but that bill on usage shouldn't render
509
- * as "Free" - they're charged per-unit.
543
+ * A plan is "custom" (contact-sales, no self-serve price) when its metadata
544
+ * flags it. Mirrors the convention used by the pricing card
545
+ * (`PricingCard.tsx`), accepting boolean `true` or the string `"true"` plan
546
+ * metadata values arrive as strings from the API but may be set as booleans in
547
+ * code/fixtures.
510
548
  */
511
- const formatPlanPrice = (plan) => {
512
- if (plan.phases.length === 0) return { type: "free" };
513
- const { monthly, yearly } = getPriceFromPlan(plan);
514
- if (monthly > 0) return {
515
- type: "priced",
516
- monthly,
517
- yearly
518
- };
519
- if (hasPricedUsageRateCard(plan)) return {
520
- type: "payg",
521
- main: "Pay as you go",
522
- sub: "Usage-based pricing"
523
- };
524
- return { type: "free" };
549
+ const isCustomPlan = (plan) => {
550
+ const flag = plan.metadata?.isCustom;
551
+ return flag === true || flag === "true";
525
552
  };
526
553
  //#endregion
527
554
  //#region src/pricing-ui/PricingCard.tsx
528
- const PricingCard = ({ plan, isPopular = false, showYearlyPrice = true, units, action, className }) => {
555
+ const PricingCard = ({ plan, isPopular = false, units, action, className }) => {
529
556
  if (plan.phases.length === 0) return null;
530
557
  const priceLabel = formatPlanPrice(plan);
531
- const isCustom = plan.metadata?.isCustom === true;
558
+ const isCustom = isCustomPlan(plan);
532
559
  const billingInterval = formatDuration(plan.billingCadence);
533
560
  return /* @__PURE__ */ jsxs("div", {
534
561
  className: cn("relative rounded-lg border p-6 flex flex-col", isPopular && "border-primary border-2", className),
@@ -564,20 +591,13 @@ const PricingCard = ({ plan, isPopular = false, showYearlyPrice = true, units, a
564
591
  })] }) : priceLabel.type === "free" ? /* @__PURE__ */ jsx("span", {
565
592
  className: "text-3xl font-bold text-card-foreground",
566
593
  children: "Free"
567
- }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
568
- /* @__PURE__ */ jsx("span", {
569
- className: "text-3xl font-bold text-card-foreground",
570
- children: formatPrice(priceLabel.monthly, plan.currency)
571
- }),
572
- /* @__PURE__ */ jsxs("span", {
573
- className: "text-muted-foreground text-sm",
574
- children: ["/", billingInterval]
575
- }),
576
- showYearlyPrice && priceLabel.yearly > 0 && /* @__PURE__ */ jsxs("div", {
577
- className: "w-full text-sm text-muted-foreground mt-1",
578
- children: [formatPrice(priceLabel.yearly, plan.currency), "/year"]
579
- })
580
- ] })
594
+ }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
595
+ className: "text-3xl font-bold text-card-foreground",
596
+ children: formatPrice(priceLabel.amount, plan.currency)
597
+ }), /* @__PURE__ */ jsxs("span", {
598
+ className: "text-muted-foreground text-sm",
599
+ children: ["/", billingInterval]
600
+ })] })
581
601
  }),
582
602
  plan.paymentRequired === false && /* @__PURE__ */ jsx("div", {
583
603
  className: "text-sm text-muted-foreground mt-1",
@@ -607,7 +627,7 @@ const DefaultEmptyState = () => /* @__PURE__ */ jsxs("div", {
607
627
  children: "Make sure your plans are set up and published."
608
628
  })]
609
629
  });
610
- const PricingTable = ({ plans, showYearlyPrice = true, units, renderAction, renderCard, isPopular = (plan) => plan.metadata?.zuplo_most_popular === "true", emptyState, showTaxLegend = true, className, cardClassName }) => {
630
+ const PricingTable = ({ plans, units, renderAction, renderCard, isPopular = (plan) => plan.metadata?.zuplo_most_popular === "true", emptyState, showTaxLegend = true, className, cardClassName }) => {
611
631
  if (plans.length === 0) return /* @__PURE__ */ jsx(Fragment$1, { children: emptyState ?? /* @__PURE__ */ jsx(DefaultEmptyState, {}) });
612
632
  const firstPlan = plans[0];
613
633
  const taxLegendSentence = showTaxLegend && firstPlan ? taxBehaviorLegendSentence(collectDefaultTaxBehaviors(firstPlan)) : void 0;
@@ -618,7 +638,6 @@ const PricingTable = ({ plans, showYearlyPrice = true, units, renderAction, rend
618
638
  const defaultCard = /* @__PURE__ */ jsx(PricingCard, {
619
639
  plan,
620
640
  isPopular: popular,
621
- showYearlyPrice,
622
641
  units,
623
642
  action: renderAction?.(plan, popular),
624
643
  className: cardClassName
@@ -641,4 +660,4 @@ const PricingTable = ({ plans, showYearlyPrice = true, units, renderAction, rend
641
660
  })] });
642
661
  };
643
662
  //#endregion
644
- export { formatDuration as _, subscriptionTaxLegendSentence as a, getPriceFromPlan as c, FeatureItem as d, categorizeRateCards as f, formatPrice as g, formatMinorCurrencyAmount as h, planHasDefaultTaxBehavior as i, PlanEntitlements as l, formatStaticEntitlementConfig as m, PricingCard as n, taxBehaviorLegendSentence as o, formatTieredPriceBreakdown as p, collectDefaultTaxBehaviors as r, derivePriceFromPlan as s, PricingTable as t, QuotaItem as u, formatDurationAdjective as v, formatDurationInterval as y };
663
+ export { formatDurationInterval as S, formatStaticEntitlementConfig as _, planHasDefaultTaxBehavior as a, formatDuration as b, formatPlanPrice as c, PlanEntitlements as d, EntitlementList as f, formatTieredPriceBreakdown as g, categorizeRateCards as h, collectDefaultTaxBehaviors as i, getPlanPrice as l, FeatureItem as m, PricingCard as n, subscriptionTaxLegendSentence as o, QuotaItem as p, isCustomPlan as r, taxBehaviorLegendSentence as s, PricingTable as t, PlanPriceTag as u, formatMinorCurrencyAmount as v, formatDurationAdjective as x, formatPrice as y };
package/dist/index.d.mts CHANGED
@@ -3,7 +3,6 @@ interface MonetizationConfig {
3
3
  pricing?: {
4
4
  subtitle?: string;
5
5
  title?: string;
6
- showYearlyPrice?: boolean;
7
6
  units?: Record<string, string>;
8
7
  };
9
8
  }