@nozle-js/react 0.11.0 → 0.12.0

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/index.js CHANGED
@@ -165,7 +165,11 @@ function catalogQuery(client) {
165
165
  const response = await client.catalogFetch("/api/v1/plans", { signal });
166
166
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
167
167
  const data = await response.json();
168
- return { plans: data.plans ?? [], nozleBranding: data.meta?.nozle_branding === true };
168
+ return {
169
+ plans: data.plans ?? [],
170
+ families: Array.isArray(data.families) ? data.families : null,
171
+ nozleBranding: data.meta?.nozle_branding === true
172
+ };
169
173
  },
170
174
  staleTime: 5 * 6e4,
171
175
  // The pricing page shows a load error at once; the app can call refetch().
@@ -177,6 +181,7 @@ function usePlans() {
177
181
  const query = useQuery(catalogQuery(client), queryClient);
178
182
  return {
179
183
  plans: query.data?.plans ?? [],
184
+ families: query.data?.families ?? null,
180
185
  isLoading: query.isPending,
181
186
  error: query.error ?? null,
182
187
  refetch: query.refetch
@@ -1718,7 +1723,7 @@ function UpgradeModal(props) {
1718
1723
  }
1719
1724
 
1720
1725
  // src/components/billing/amounts.ts
1721
- function formatMinorAmount(value, currency, locale = "en-US") {
1726
+ function formatMinorAmount(value, currency, locale = "en-US", options = {}) {
1722
1727
  const raw = String(value);
1723
1728
  if (!/^-?\d+$/.test(raw) || typeof value === "number" && !Number.isSafeInteger(value)) {
1724
1729
  return "Amount unavailable";
@@ -1734,12 +1739,16 @@ function formatMinorAmount(value, currency, locale = "en-US") {
1734
1739
  const scale = 10n ** BigInt(digits);
1735
1740
  const whole = absolute / scale;
1736
1741
  const signed = minor < 0n ? whole === 0n ? -0 : -whole : whole;
1737
- const decimal = String(absolute % scale).padStart(digits, "0");
1742
+ const remainder = absolute % scale;
1743
+ const trim = options.trimZeroFraction === true && remainder === 0n;
1744
+ const decimal = String(remainder).padStart(digits, "0");
1738
1745
  const digitFormatter = new Intl.NumberFormat(locale, {
1739
1746
  useGrouping: false
1740
1747
  });
1741
1748
  const fraction = [...decimal].map((digit) => digitFormatter.format(Number(digit))).join("");
1742
- return formatter.formatToParts(signed).map((part) => part.type === "fraction" ? fraction : part.value).join("");
1749
+ return formatter.formatToParts(signed).filter(
1750
+ (part) => !trim || part.type !== "fraction" && part.type !== "decimal"
1751
+ ).map((part) => part.type === "fraction" ? fraction : part.value).join("");
1743
1752
  } catch {
1744
1753
  return "Amount unavailable";
1745
1754
  }
@@ -3455,6 +3464,360 @@ function useCheckout2() {
3455
3464
  );
3456
3465
  return { fetchClientSecret, checkout, isLoading, error };
3457
3466
  }
3467
+
3468
+ // src/components/pricing/catalog.ts
3469
+ var PRICING_INTERVALS = [
3470
+ "weekly",
3471
+ "monthly",
3472
+ "quarterly",
3473
+ "semiannual",
3474
+ "yearly"
3475
+ ];
3476
+ var INTERVAL_LABELS2 = {
3477
+ weekly: "Weekly",
3478
+ monthly: "Monthly",
3479
+ quarterly: "Quarterly",
3480
+ semiannual: "Every 6 months",
3481
+ yearly: "Annual"
3482
+ };
3483
+ var BILLED_WORDS = {
3484
+ weekly: "weekly",
3485
+ monthly: "monthly",
3486
+ quarterly: "quarterly",
3487
+ semiannual: "every 6 months",
3488
+ yearly: "yearly"
3489
+ };
3490
+ var PERIODS_PER_YEAR = {
3491
+ weekly: 52n,
3492
+ monthly: 12n,
3493
+ quarterly: 4n,
3494
+ semiannual: 2n,
3495
+ yearly: 1n
3496
+ };
3497
+ function isPricingInterval(value) {
3498
+ return typeof value === "string" && PRICING_INTERVALS.includes(value);
3499
+ }
3500
+ function intervalRank(interval) {
3501
+ return PRICING_INTERVALS.indexOf(interval);
3502
+ }
3503
+ function normalizePricingInterval(value) {
3504
+ if (value === "annual") return "yearly";
3505
+ return isPricingInterval(value) ? value : null;
3506
+ }
3507
+ function hasPosition(value) {
3508
+ return typeof value === "number" && Number.isFinite(value);
3509
+ }
3510
+ function comparePositions(a, b) {
3511
+ const aSet = hasPosition(a);
3512
+ const bSet = hasPosition(b);
3513
+ if (aSet && bSet) return a - b;
3514
+ if (aSet) return -1;
3515
+ if (bSet) return 1;
3516
+ return 0;
3517
+ }
3518
+ var DEFAULT_LOCALE = "en-US";
3519
+ function resolvePricingLocale(locale) {
3520
+ if (typeof locale !== "string" || locale.trim() === "") return DEFAULT_LOCALE;
3521
+ try {
3522
+ Intl.getCanonicalLocales(locale);
3523
+ return locale;
3524
+ } catch {
3525
+ return DEFAULT_LOCALE;
3526
+ }
3527
+ }
3528
+ function isLive(plan) {
3529
+ return plan.pending_deletion !== true;
3530
+ }
3531
+ function hasFamilies(catalog) {
3532
+ return Array.isArray(catalog.families);
3533
+ }
3534
+ function getPricingGroups(catalog) {
3535
+ const plans = catalog.plans ?? [];
3536
+ if (!hasFamilies(catalog)) {
3537
+ return plans.filter(isLive).map((plan) => ({ key: plan.code, family: null, plans: [plan] }));
3538
+ }
3539
+ const byCode = /* @__PURE__ */ new Map();
3540
+ for (const plan of plans) {
3541
+ if (isLive(plan) && !byCode.has(plan.code)) byCode.set(plan.code, plan);
3542
+ }
3543
+ return catalog.families.map((family, index) => ({ family, index })).filter(({ family }) => family.show_on_pricing !== false).sort(
3544
+ (a, b) => comparePositions(a.family.pricing_position, b.family.pricing_position) || a.index - b.index
3545
+ ).map(({ family }) => {
3546
+ const seen = /* @__PURE__ */ new Set();
3547
+ const familyPlans = [];
3548
+ for (const code of family.plan_codes ?? []) {
3549
+ const plan = byCode.get(code);
3550
+ if (plan && !seen.has(code)) {
3551
+ seen.add(code);
3552
+ familyPlans.push(plan);
3553
+ }
3554
+ }
3555
+ return { key: family.code, family, plans: familyPlans };
3556
+ }).filter((group) => group.plans.length > 0);
3557
+ }
3558
+ function toMinor(value) {
3559
+ const raw = String(value);
3560
+ if (!/^-?\d+$/.test(raw)) return null;
3561
+ if (typeof value === "number" && !Number.isSafeInteger(value)) return null;
3562
+ return BigInt(raw);
3563
+ }
3564
+ function divideRounded(numerator, denominator) {
3565
+ if (numerator < 0n) return -divideRounded(-numerator, denominator);
3566
+ return (2n * numerator + denominator) / (2n * denominator);
3567
+ }
3568
+ function perMonthMinor(amount, interval) {
3569
+ return divideRounded(amount * PERIODS_PER_YEAR[interval], 12n);
3570
+ }
3571
+ function fmt(amount, currency, locale) {
3572
+ return formatMinorAmount(String(amount), currency, locale, {
3573
+ trimZeroFraction: true
3574
+ });
3575
+ }
3576
+ function savingsAgainstMonthly(plan, monthly) {
3577
+ if (!monthly || monthly === plan) return null;
3578
+ if (!isPricingInterval(plan.interval) || plan.interval === "monthly") return null;
3579
+ if (monthly.amount_currency !== plan.amount_currency) return null;
3580
+ const monthlyAmount = toMinor(monthly.amount_cents);
3581
+ const amount = toMinor(plan.amount_cents);
3582
+ if (monthlyAmount === null || amount === null) return null;
3583
+ const base = monthlyAmount * 12n;
3584
+ const cost = amount * PERIODS_PER_YEAR[plan.interval];
3585
+ if (base <= 0n || cost >= base) return null;
3586
+ const percent = Number(divideRounded(100n * (base - cost), base));
3587
+ return percent > 0 ? percent : null;
3588
+ }
3589
+ function monthlyPlanOf(group) {
3590
+ return group.family ? group.plans.find((plan) => plan.interval === "monthly") : void 0;
3591
+ }
3592
+ function getAvailableIntervals(catalog) {
3593
+ const present = /* @__PURE__ */ new Set();
3594
+ for (const group of getPricingGroups(catalog)) {
3595
+ for (const plan of group.plans) present.add(plan.interval);
3596
+ }
3597
+ return PRICING_INTERVALS.filter((interval) => present.has(interval));
3598
+ }
3599
+ function getPricingIntervalOptions(catalog) {
3600
+ const groups = getPricingGroups(catalog);
3601
+ return getAvailableIntervals(catalog).map((interval) => {
3602
+ let best = null;
3603
+ if (interval !== "monthly") {
3604
+ for (const group of groups) {
3605
+ const plan = group.plans.find((p) => p.interval === interval);
3606
+ const saving = plan ? savingsAgainstMonthly(plan, monthlyPlanOf(group)) : null;
3607
+ if (saving !== null && (best === null || saving > best)) best = saving;
3608
+ }
3609
+ }
3610
+ return { interval, label: INTERVAL_LABELS2[interval], savingsPercent: best };
3611
+ });
3612
+ }
3613
+ function getDefaultInterval(catalog, preferred) {
3614
+ const available = getAvailableIntervals(catalog);
3615
+ const wanted = normalizePricingInterval(preferred);
3616
+ if (wanted && available.includes(wanted)) return wanted;
3617
+ if (available.includes("monthly")) return "monthly";
3618
+ return available[0] ?? null;
3619
+ }
3620
+ function pickPlan(plans, interval) {
3621
+ if (interval === null) return plans[0];
3622
+ const exact = plans.find((plan) => plan.interval === interval);
3623
+ if (exact) return exact;
3624
+ const target = intervalRank(interval);
3625
+ let best = plans[0];
3626
+ let bestScore = Number.POSITIVE_INFINITY;
3627
+ for (const plan of plans) {
3628
+ const rank = intervalRank(plan.interval);
3629
+ if (rank < 0) continue;
3630
+ const score = Math.abs(rank - target) * 2 + (rank > target ? 1 : 0);
3631
+ if (score < bestScore) {
3632
+ best = plan;
3633
+ bestScore = score;
3634
+ }
3635
+ }
3636
+ return best;
3637
+ }
3638
+ function isTrueBoolean(privilege) {
3639
+ const { value } = privilege;
3640
+ if (value === true) return true;
3641
+ return privilege.value_type === "boolean" && typeof value === "string" && value.toLowerCase() === "true";
3642
+ }
3643
+ function formatPrivilegeValue(privilege, numberFormat) {
3644
+ const { value } = privilege;
3645
+ if (value === null || value === void 0) return null;
3646
+ if (isTrueBoolean(privilege)) return null;
3647
+ if (value === false) return "No";
3648
+ if (typeof value === "number") {
3649
+ return Number.isFinite(value) ? numberFormat.format(value) : null;
3650
+ }
3651
+ const text2 = String(value).trim();
3652
+ if (text2 === "") return null;
3653
+ if (privilege.value_type === "boolean" && text2.toLowerCase() === "false") {
3654
+ return "No";
3655
+ }
3656
+ if (privilege.value_type === "integer" && /^-?\d+$/.test(text2)) {
3657
+ const parsed = Number(text2);
3658
+ if (Number.isSafeInteger(parsed)) return numberFormat.format(parsed);
3659
+ }
3660
+ return text2;
3661
+ }
3662
+ function featureLabel(name, entitlement, numberFormat) {
3663
+ const values = (entitlement?.privileges ?? []).map((privilege) => ({
3664
+ privilege,
3665
+ text: formatPrivilegeValue(privilege, numberFormat)
3666
+ })).filter(
3667
+ (entry) => entry.text !== null
3668
+ );
3669
+ if (values.length === 1) return `${name}: ${values[0].text}`;
3670
+ if (values.length > 1) {
3671
+ const parts = values.map(({ privilege, text: text2 }) => {
3672
+ const privilegeName = privilege.name?.trim() || privilege.code;
3673
+ return `${privilegeName}: ${text2}`;
3674
+ });
3675
+ return `${name} (${parts.join(", ")})`;
3676
+ }
3677
+ const limit = entitlement?.usage_limit;
3678
+ if (typeof limit === "number" && Number.isFinite(limit)) {
3679
+ return `${name}: ${numberFormat.format(limit)}`;
3680
+ }
3681
+ return name;
3682
+ }
3683
+ function trimmed(value) {
3684
+ return typeof value === "string" ? value.trim() : "";
3685
+ }
3686
+ function familyFeatures(family, plan, locale) {
3687
+ const numberFormat = new Intl.NumberFormat(locale);
3688
+ const entitlements = /* @__PURE__ */ new Map();
3689
+ for (const entitlement of plan.entitlements ?? []) {
3690
+ if (!entitlements.has(entitlement.code)) {
3691
+ entitlements.set(entitlement.code, entitlement);
3692
+ }
3693
+ }
3694
+ return (family.features ?? []).filter((feature) => feature.show_on_pricing !== false).map((feature, index) => {
3695
+ const entitlement = entitlements.get(feature.code);
3696
+ const name = trimmed(feature.name) || trimmed(entitlement?.name) || feature.code;
3697
+ return { feature, entitlement, index, name };
3698
+ }).sort(
3699
+ (a, b) => comparePositions(a.feature.display_position, b.feature.display_position) || a.name.localeCompare(b.name, locale) || a.index - b.index
3700
+ ).map(({ feature, entitlement, name }) => {
3701
+ const label = featureLabel(name, entitlement, numberFormat);
3702
+ const description = trimmed(feature.description);
3703
+ return feature.show_description === true && description ? { label, description } : { label };
3704
+ });
3705
+ }
3706
+ function planCardFeatures(plan) {
3707
+ return planFeatures(plan).map((label) => ({ label }));
3708
+ }
3709
+ function normalizeFeatures(items) {
3710
+ return items.map((item) => typeof item === "string" ? { label: item } : item).filter(
3711
+ (item) => Boolean(item) && typeof item.label === "string"
3712
+ );
3713
+ }
3714
+ function overrideFeatures(overrides, group, plan, cardIndex) {
3715
+ if (!overrides) return null;
3716
+ if (Array.isArray(overrides)) {
3717
+ const entry = overrides[cardIndex];
3718
+ return entry ? normalizeFeatures(entry) : null;
3719
+ }
3720
+ const keys = [group.key, plan.code, ...group.plans.map((p) => p.code)];
3721
+ for (const key of keys) {
3722
+ if (Object.prototype.hasOwnProperty.call(overrides, key)) {
3723
+ return normalizeFeatures(overrides[key] ?? []);
3724
+ }
3725
+ }
3726
+ return null;
3727
+ }
3728
+ function yearlyMinor(plan) {
3729
+ const amount = toMinor(plan.amount_cents);
3730
+ if (amount === null || !isPricingInterval(plan.interval)) return null;
3731
+ return amount * PERIODS_PER_YEAR[plan.interval];
3732
+ }
3733
+ function planPriceStrings(plan, locale) {
3734
+ const amount = toMinor(plan.amount_cents);
3735
+ return {
3736
+ price: fmt(amount ?? plan.amount_cents, plan.amount_currency, locale),
3737
+ pricePeriod: plan.interval ? `/${intervalLabel(plan.interval)}` : "",
3738
+ billing: "",
3739
+ savingsPercent: null
3740
+ };
3741
+ }
3742
+ function priceStrings(plan, monthly, locale) {
3743
+ const amount = toMinor(plan.amount_cents);
3744
+ const currency = plan.amount_currency;
3745
+ if (!isPricingInterval(plan.interval) || amount === null) {
3746
+ return {
3747
+ price: fmt(amount ?? plan.amount_cents, currency, locale),
3748
+ pricePeriod: "",
3749
+ billing: plan.interval ? `Billed ${plan.interval}` : "",
3750
+ savingsPercent: null
3751
+ };
3752
+ }
3753
+ const price = fmt(perMonthMinor(amount, plan.interval), currency, locale);
3754
+ if (plan.interval === "monthly") {
3755
+ return { price, pricePeriod: "/mo", billing: "Billed monthly", savingsPercent: null };
3756
+ }
3757
+ const savingsPercent = savingsAgainstMonthly(plan, monthly);
3758
+ const billed = `Billed ${fmt(amount, currency, locale)} ${BILLED_WORDS[plan.interval]}`;
3759
+ return {
3760
+ price,
3761
+ pricePeriod: "/mo",
3762
+ billing: savingsPercent === null ? billed : `${billed} \xB7 save ${savingsPercent}%`,
3763
+ savingsPercent
3764
+ };
3765
+ }
3766
+ function getPricingCards(catalog, options = {}) {
3767
+ const locale = resolvePricingLocale(options.locale);
3768
+ const families = hasFamilies(catalog);
3769
+ const requested = normalizePricingInterval(options.interval ?? null);
3770
+ const interval = requested ?? getDefaultInterval(catalog);
3771
+ const contactSales = new Set(options.contactSales ?? []);
3772
+ const isContactSales = (group) => contactSales.has(group.key) || group.plans.some((plan) => contactSales.has(plan.code));
3773
+ let groups = getPricingGroups(catalog);
3774
+ if (!families) {
3775
+ if (options.sort) {
3776
+ const sort = options.sort;
3777
+ groups = groups.map((group, index) => ({ group, index })).sort((a, b) => sort(a.group.plans[0], b.group.plans[0]) || a.index - b.index).map(({ group }) => group);
3778
+ } else {
3779
+ const sales = groups.filter(isContactSales);
3780
+ const byPlan = new Map(groups.map((group) => [group.plans[0], group]));
3781
+ groups = [
3782
+ ...sortPlansByPrice(groups.filter((group) => !isContactSales(group)).map((group) => group.plans[0])).map((plan) => byPlan.get(plan)),
3783
+ ...sales
3784
+ ];
3785
+ }
3786
+ if (requested) {
3787
+ groups = groups.filter(
3788
+ (group) => group.plans[0].interval === requested || isContactSales(group)
3789
+ );
3790
+ }
3791
+ }
3792
+ const highlight = options.highlight ?? null;
3793
+ const highlightedKey = highlight !== null ? groups.find(
3794
+ (group) => group.key === highlight || group.plans.some((plan) => plan.code === highlight)
3795
+ )?.key : groups.find((group) => group.family?.pricing_highlighted === true)?.key;
3796
+ const badge = (family) => options.badge !== void 0 ? options.badge === null ? null : trimmed(options.badge) || null : trimmed(family?.badge_text) || "Most Popular";
3797
+ return groups.map((group, cardIndex) => {
3798
+ const plan = pickPlan(group.plans, interval);
3799
+ const family = group.family;
3800
+ const name = trimmed(family?.name) || plan.name;
3801
+ const description = trimmed(family?.description) || trimmed(plan.description) || null;
3802
+ const highlighted = highlightedKey !== void 0 && highlightedKey === group.key;
3803
+ const features = overrideFeatures(options.features, group, plan, cardIndex) ?? (family ? familyFeatures(family, plan, locale) : planCardFeatures(plan));
3804
+ return {
3805
+ key: group.key,
3806
+ family,
3807
+ plans: group.plans,
3808
+ plan,
3809
+ isFallbackInterval: families && interval !== null && plan.interval !== interval,
3810
+ name,
3811
+ description,
3812
+ ...family ? priceStrings(plan, monthlyPlanOf(group), locale) : planPriceStrings(plan, locale),
3813
+ features,
3814
+ highlighted,
3815
+ badge: highlighted ? badge(family) : null,
3816
+ cta: trimmed(family?.cta_text) || `Get ${name}`,
3817
+ enterprise: isContactSales(group)
3818
+ };
3819
+ });
3820
+ }
3458
3821
  function PanelPlanCard({
3459
3822
  frame,
3460
3823
  el,
@@ -3462,6 +3825,7 @@ function PanelPlanCard({
3462
3825
  description,
3463
3826
  price,
3464
3827
  interval,
3828
+ priceNote,
3465
3829
  features,
3466
3830
  isCurrent,
3467
3831
  isHighlighted,
@@ -3526,6 +3890,7 @@ function PanelPlanCard({
3526
3890
  interval
3527
3891
  ] })
3528
3892
  ] }),
3893
+ priceNote && /* @__PURE__ */ jsx("p", { "data-testid": "pricing-billing", style: { ...text.meta, color: muted5, margin: "0.25rem 0 0" }, children: priceNote }),
3529
3894
  description && /* @__PURE__ */ jsx("p", { ...el("planCard__description", { ...text.secondary, color: muted5, margin: "0.25rem 0 0" }), children: description })
3530
3895
  ] }),
3531
3896
  /* @__PURE__ */ jsx(
@@ -3542,7 +3907,13 @@ function PanelPlanCard({
3542
3907
  ...text.secondary,
3543
3908
  color: muted5
3544
3909
  }),
3545
- children: features.map((feature) => /* @__PURE__ */ jsx("li", { ...el("planCard__feature"), children: feature }, feature))
3910
+ children: features.map((feature, index) => {
3911
+ const item = typeof feature === "string" ? { label: feature } : feature;
3912
+ return /* @__PURE__ */ jsxs("li", { ...el("planCard__feature"), children: [
3913
+ item.label,
3914
+ item.description && /* @__PURE__ */ jsx("span", { style: { display: "block", ...text.meta, color: muted5 }, children: item.description })
3915
+ ] }, `${index}:${item.label}`);
3916
+ })
3546
3917
  }
3547
3918
  ),
3548
3919
  children
@@ -3551,61 +3922,66 @@ function PanelPlanCard({
3551
3922
  }
3552
3923
  );
3553
3924
  }
3554
- function featureLines(override, plan, index) {
3555
- const given = Array.isArray(override) ? override[index] : override?.[plan.code];
3556
- return given ?? planFeatures(plan);
3557
- }
3558
- var SYMBOLS = { USD: "$", EUR: "\u20AC", GBP: "\xA3", JPY: "\xA5" };
3559
- function fmtPrice(cents, cur) {
3560
- const s = SYMBOLS[cur] ?? cur + " ";
3561
- const val = cents % 100 === 0 ? String(cents / 100) : (cents / 100).toFixed(2);
3562
- return `${s}${val}`;
3563
- }
3564
- var C = {
3565
- cardBg: T.card,
3566
- hl: T.primary,
3567
- border: T.border,
3568
- radius: T.radius,
3569
- text: T.foreground,
3570
- muted: T.mutedForeground,
3571
- mutedBg: T.muted,
3572
- hlFg: T.primaryForeground
3573
- };
3574
3925
  function Check() {
3575
3926
  return /* @__PURE__ */ jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", style: { flexShrink: 0 }, children: /* @__PURE__ */ jsx("path", { d: "M3.5 8.5L6.5 11.5L12.5 4.5", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) });
3576
3927
  }
3577
- function Toggle({ isAnnual, onChange, el }) {
3928
+ function IntervalSwitch({
3929
+ options,
3930
+ selected,
3931
+ onChange,
3932
+ annualDiscountLabel,
3933
+ el
3934
+ }) {
3578
3935
  const pill = {
3579
3936
  display: "inline-flex",
3937
+ flexWrap: "wrap",
3938
+ justifyContent: "center",
3580
3939
  borderRadius: "9999px",
3581
3940
  padding: "4px",
3582
- background: C.mutedBg,
3941
+ background: T.muted,
3583
3942
  gap: "2px"
3584
3943
  };
3585
3944
  const tab = (active) => ({
3586
3945
  padding: "8px 20px",
3587
3946
  borderRadius: "9999px",
3588
3947
  border: "none",
3589
- background: active ? C.cardBg : "transparent",
3948
+ background: active ? T.card : "transparent",
3590
3949
  ...text.action,
3591
- color: active ? C.text : C.muted,
3950
+ color: active ? T.foreground : T.mutedForeground,
3592
3951
  cursor: "pointer",
3593
3952
  boxShadow: active ? T.shadow : "none",
3594
3953
  transition: "all 0.15s ease"
3595
3954
  });
3596
- return /* @__PURE__ */ jsx("div", { style: { display: "flex", justifyContent: "center", marginBottom: "2.5rem" }, children: /* @__PURE__ */ jsxs("div", { ...el("pricingTable__toggle", pill), children: [
3597
- /* @__PURE__ */ jsx("button", { type: "button", onClick: () => onChange(false), ...el("pricingTable__toggleButton", tab(!isAnnual)), "aria-pressed": !isAnnual, children: "Monthly" }),
3598
- /* @__PURE__ */ jsx("button", { type: "button", onClick: () => onChange(true), ...el("pricingTable__toggleButton", tab(isAnnual)), "aria-pressed": isAnnual, children: "Annual" })
3599
- ] }) });
3955
+ return /* @__PURE__ */ jsx("div", { style: { display: "flex", justifyContent: "center", marginBottom: "2.5rem" }, children: /* @__PURE__ */ jsx("div", { role: "group", "aria-label": "Billing interval", ...el("pricingTable__toggle", pill), children: options.map((option) => {
3956
+ const active = option.interval === selected;
3957
+ const note = option.interval === "yearly" && annualDiscountLabel ? annualDiscountLabel : option.interval !== "monthly" && option.savingsPercent !== null ? `Save ${option.savingsPercent}%` : null;
3958
+ return /* @__PURE__ */ jsxs(
3959
+ "button",
3960
+ {
3961
+ type: "button",
3962
+ "data-interval": option.interval,
3963
+ onClick: () => onChange(option.interval),
3964
+ ...el("pricingTable__toggleButton", tab(active)),
3965
+ "aria-pressed": active,
3966
+ "aria-label": note ? `${option.label}, ${note.replace(/^Save /, "save ")}` : void 0,
3967
+ children: [
3968
+ option.label,
3969
+ note && /* @__PURE__ */ jsx("span", { style: {
3970
+ marginLeft: "6px",
3971
+ ...text.meta,
3972
+ fontWeight: 600,
3973
+ color: active ? T.primary : T.mutedForeground
3974
+ }, children: note })
3975
+ ]
3976
+ },
3977
+ option.interval
3978
+ );
3979
+ }) }) });
3600
3980
  }
3601
3981
  function Card({
3602
- plan,
3603
- features,
3982
+ card,
3604
3983
  isCurrent,
3605
- isHighlighted,
3606
3984
  filled: filledStyle,
3607
- highlightLabel,
3608
- isEnt,
3609
3985
  enterpriseEmail,
3610
3986
  onSelect,
3611
3987
  el,
@@ -3613,12 +3989,14 @@ function Card({
3613
3989
  isUpgrade
3614
3990
  }) {
3615
3991
  const [hover, setHover] = useState(false);
3992
+ const { plan, features } = card;
3993
+ const isHighlighted = card.highlighted;
3994
+ const isEnt = card.enterprise;
3616
3995
  const filled = isHighlighted && filledStyle;
3617
- const text2 = filled ? T.highlightForeground : C.text;
3618
- const muted5 = filled ? `color-mix(in srgb, ${T.highlightForeground} 75%, ${T.highlight})` : C.muted;
3619
- const accent = filled ? T.highlightForeground : C.hl;
3620
- const badgeLabel = isCurrent ? "Current Plan" : highlightLabel;
3621
- const interval = `/${intervalLabel(plan.interval)}`;
3996
+ const text2 = filled ? T.highlightForeground : T.foreground;
3997
+ const muted5 = filled ? `color-mix(in srgb, ${T.highlightForeground} 75%, ${T.highlight})` : T.mutedForeground;
3998
+ const accent = filled ? T.highlightForeground : T.primary;
3999
+ const badgeLabel = isCurrent ? "Current Plan" : card.badge;
3622
4000
  const handleClick = () => {
3623
4001
  if (isCurrent) return;
3624
4002
  if (isEnt && enterpriseEmail) {
@@ -3627,6 +4005,7 @@ function Card({
3627
4005
  }
3628
4006
  onSelect?.();
3629
4007
  };
4008
+ const ctaText = isCurrent ? "Current Plan" : card.family?.cta_text?.trim() ? card.cta : isEnt ? "Contact Sales" : card.cta;
3630
4009
  const emphasised = isUpgrade ?? isHighlighted;
3631
4010
  const cta = onSelect && /* @__PURE__ */ jsx(
3632
4011
  CTAButton,
@@ -3635,24 +4014,32 @@ function Card({
3635
4014
  disabled: isCurrent,
3636
4015
  variant: isCurrent ? "current" : isEnt ? "outline" : !emphasised ? "secondary" : filled ? "inverse" : isHighlighted ? "highlight" : "primary",
3637
4016
  el,
3638
- children: isCurrent ? "Current Plan" : isEnt ? "Contact Sales" : `Get ${plan.name}`
4017
+ children: ctaText
3639
4018
  }
3640
4019
  );
4020
+ const dataAttributes = {
4021
+ "data-card-key": card.key,
4022
+ "data-family-code": card.family?.code,
4023
+ "data-plan-code": plan.code,
4024
+ "data-interval": plan.interval,
4025
+ "data-highlighted": isHighlighted ? "true" : void 0
4026
+ };
3641
4027
  if (panel) {
3642
4028
  return /* @__PURE__ */ jsx(
3643
4029
  PanelPlanCard,
3644
4030
  {
3645
- frame: (style) => el("planCard", style),
4031
+ frame: (style) => ({ ...el("planCard", style), ...dataAttributes }),
3646
4032
  el,
3647
- name: plan.name,
3648
- description: plan.description,
3649
- price: isEnt ? "Custom" : fmtPrice(plan.amount_cents, plan.amount_currency),
3650
- interval: isEnt ? void 0 : intervalLabel(plan.interval),
3651
- features: features ?? [],
4033
+ name: card.name,
4034
+ description: card.description,
4035
+ price: isEnt ? "Custom" : card.price,
4036
+ interval: isEnt || !card.pricePeriod ? void 0 : card.pricePeriod.replace(/^\//, ""),
4037
+ priceNote: isEnt ? null : card.billing,
4038
+ features,
3652
4039
  isCurrent,
3653
4040
  isHighlighted,
3654
4041
  filled: filledStyle,
3655
- highlightLabel,
4042
+ highlightLabel: card.badge,
3656
4043
  children: cta
3657
4044
  }
3658
4045
  );
@@ -3660,17 +4047,18 @@ function Card({
3660
4047
  return /* @__PURE__ */ jsxs(
3661
4048
  "div",
3662
4049
  {
4050
+ ...dataAttributes,
3663
4051
  onMouseEnter: () => setHover(true),
3664
4052
  onMouseLeave: () => setHover(false),
3665
4053
  ...el("planCard", {
3666
4054
  position: "relative",
3667
4055
  display: "flex",
3668
4056
  flexDirection: "column",
3669
- background: filled ? T.highlight : C.cardBg,
4057
+ background: filled ? T.highlight : T.card,
3670
4058
  color: text2,
3671
- border: border(isHighlighted ? T.highlight : C.border),
4059
+ border: border(isHighlighted ? T.highlight : T.border),
3672
4060
  borderTopWidth: isHighlighted && !filled ? `calc(${T.borderWidth} + 2px)` : T.borderWidth,
3673
- borderRadius: C.radius,
4061
+ borderRadius: T.radius,
3674
4062
  padding: "2rem",
3675
4063
  transition: "box-shadow 0.2s ease, transform 0.2s ease",
3676
4064
  boxShadow: T.shadow,
@@ -3695,7 +4083,7 @@ function Card({
3695
4083
  top: "0",
3696
4084
  left: "50%",
3697
4085
  transform: "translate(-50%, -50%)",
3698
- background: C.mutedBg,
4086
+ background: T.muted,
3699
4087
  ...text.label,
3700
4088
  padding: "4px 12px",
3701
4089
  borderRadius: "9999px",
@@ -3706,18 +4094,21 @@ function Card({
3706
4094
  ...text.heading,
3707
4095
  color: text2,
3708
4096
  margin: 0
3709
- }), children: plan.name }),
3710
- plan.description && /* @__PURE__ */ jsx("p", { ...el("planCard__description", {
4097
+ }), children: card.name }),
4098
+ card.description && /* @__PURE__ */ jsx("p", { ...el("planCard__description", {
3711
4099
  ...text.secondary,
3712
4100
  color: muted5,
3713
4101
  margin: "6px 0 0"
3714
- }), children: plan.description })
4102
+ }), children: card.description })
3715
4103
  ] }),
3716
- /* @__PURE__ */ jsx("div", { ...el("planCard__price", { marginBottom: "1.5rem" }), children: isEnt ? /* @__PURE__ */ jsx("span", { style: { ...text.metric, color: text2 }, children: "Custom" }) : /* @__PURE__ */ jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: "2px" }, children: [
3717
- /* @__PURE__ */ jsx("span", { style: { ...text.metric, color: text2 }, children: fmtPrice(plan.amount_cents, plan.amount_currency) }),
3718
- /* @__PURE__ */ jsx("span", { style: { ...text.meta, color: muted5, marginLeft: "4px" }, children: interval })
4104
+ /* @__PURE__ */ jsx("div", { ...el("planCard__price", { marginBottom: "1.5rem" }), children: isEnt ? /* @__PURE__ */ jsx("span", { style: { ...text.metric, color: text2 }, children: "Custom" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
4105
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: "2px" }, children: [
4106
+ /* @__PURE__ */ jsx("span", { "data-testid": "pricing-price", style: { ...text.metric, color: text2 }, children: card.price }),
4107
+ card.pricePeriod && /* @__PURE__ */ jsx("span", { style: { ...text.meta, color: muted5, marginLeft: "4px" }, children: card.pricePeriod })
4108
+ ] }),
4109
+ card.billing && /* @__PURE__ */ jsx("p", { "data-testid": "pricing-billing", style: { ...text.meta, color: muted5, margin: "8px 0 0" }, children: card.billing })
3719
4110
  ] }) }),
3720
- features && features.length > 0 && /* @__PURE__ */ jsx("ul", { ...el("planCard__features", {
4111
+ features.length > 0 && /* @__PURE__ */ jsx("ul", { ...el("planCard__features", {
3721
4112
  listStyle: "none",
3722
4113
  padding: 0,
3723
4114
  margin: "0 0 2rem",
@@ -3725,7 +4116,7 @@ function Card({
3725
4116
  flexDirection: "column",
3726
4117
  gap: "0.75rem",
3727
4118
  flex: 1
3728
- }), children: features.map((feat) => /* @__PURE__ */ jsxs("li", { ...el("planCard__feature", {
4119
+ }), children: features.map((feat, index) => /* @__PURE__ */ jsxs("li", { ...el("planCard__feature", {
3729
4120
  display: "flex",
3730
4121
  alignItems: "flex-start",
3731
4122
  gap: "10px",
@@ -3733,9 +4124,12 @@ function Card({
3733
4124
  color: text2
3734
4125
  }), children: [
3735
4126
  /* @__PURE__ */ jsx("span", { style: { color: accent, marginTop: "2px" }, children: /* @__PURE__ */ jsx(Check, {}) }),
3736
- /* @__PURE__ */ jsx("span", { children: feat })
3737
- ] }, feat)) }),
3738
- (!features || features.length === 0) && /* @__PURE__ */ jsx("div", { style: { flex: 1 } }),
4127
+ /* @__PURE__ */ jsxs("span", { style: { display: "flex", flexDirection: "column" }, children: [
4128
+ /* @__PURE__ */ jsx("span", { children: feat.label }),
4129
+ feat.description && /* @__PURE__ */ jsx("span", { style: { ...text.meta, color: muted5, marginTop: "2px" }, children: feat.description })
4130
+ ] })
4131
+ ] }, `${index}:${feat.label}`)) }),
4132
+ features.length === 0 && /* @__PURE__ */ jsx("div", { style: { flex: 1 } }),
3739
4133
  cta
3740
4134
  ]
3741
4135
  }
@@ -3749,7 +4143,7 @@ function CTAButton({
3749
4143
  el
3750
4144
  }) {
3751
4145
  const [hover, setHover] = useState(false);
3752
- const look = variant === "current" ? { ...buttonStyle("secondary"), background: C.mutedBg, color: C.muted, cursor: "default" } : variant === "highlight" ? { ...buttonStyle("primary"), background: T.highlight, color: T.highlightForeground, border: primaryButtonBorder(T.highlight) } : variant === "inverse" ? { ...buttonStyle("primary"), background: T.highlightForeground, color: T.highlight, border: primaryButtonBorder(T.highlightForeground) } : buttonStyle(variant);
4146
+ const look = variant === "current" ? { ...buttonStyle("secondary"), background: T.muted, color: T.mutedForeground, cursor: "default" } : variant === "highlight" ? { ...buttonStyle("primary"), background: T.highlight, color: T.highlightForeground, border: primaryButtonBorder(T.highlight) } : variant === "inverse" ? { ...buttonStyle("primary"), background: T.highlightForeground, color: T.highlight, border: primaryButtonBorder(T.highlightForeground) } : buttonStyle(variant);
3753
4147
  return /* @__PURE__ */ jsx(
3754
4148
  "button",
3755
4149
  {
@@ -3774,9 +4168,9 @@ function Skeleton({ rootProps }) {
3774
4168
  gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
3775
4169
  gap: "1.5rem"
3776
4170
  }, children: [0, 1, 2].map((i) => /* @__PURE__ */ jsxs("div", { style: {
3777
- background: C.cardBg,
4171
+ background: T.card,
3778
4172
  border: border(),
3779
- borderRadius: C.radius,
4173
+ borderRadius: T.radius,
3780
4174
  padding: "2rem",
3781
4175
  height: "380px"
3782
4176
  }, children: [
@@ -3790,6 +4184,7 @@ function Skeleton({ rootProps }) {
3790
4184
  function PricingTable({
3791
4185
  currentPlanCode,
3792
4186
  plans: propPlans,
4187
+ families: propFamilies,
3793
4188
  features,
3794
4189
  sort,
3795
4190
  onSelect,
@@ -3798,36 +4193,40 @@ function PricingTable({
3798
4193
  onCheckoutResult,
3799
4194
  onCheckoutError,
3800
4195
  showToggle = true,
4196
+ defaultInterval,
3801
4197
  highlightPlan,
3802
4198
  highlightStyle = "outline",
3803
- highlightLabel = "Most Popular",
4199
+ highlightLabel,
3804
4200
  className,
3805
4201
  contactSalesPlans,
3806
4202
  enterpriseEmail,
4203
+ annualDiscountLabel,
4204
+ locale: localeProp,
3807
4205
  appearance
3808
4206
  }) {
3809
4207
  const { root, el, planCardLayout } = useAppearance(appearance);
3810
- const isEnterprise = (p) => contactSalesPlans?.includes(p.code) ?? false;
4208
+ const locale = resolvePricingLocale(localeProp);
3811
4209
  const billing = useOptionalBillingContext();
3812
4210
  const client = billing?.client;
3813
- const catalog = useQuery(
4211
+ const fetched = useQuery(
3814
4212
  { ...catalogQuery(client), enabled: !propPlans && Boolean(client) },
3815
4213
  billing?.queryClient ?? idleQueryClient
3816
4214
  );
3817
- const plans = propPlans ?? catalog.data?.plans ?? [];
3818
- const loading = !propPlans && Boolean(client) && catalog.isPending;
3819
- const loadError = propPlans ? null : !client ? "BillingProvider with a publishableKey is required to load plans" : catalog.error ? `Plans could not be loaded: ${catalog.error.message}` : null;
4215
+ const catalog = propPlans ? { plans: propPlans, families: Array.isArray(propFamilies) ? propFamilies : null } : { plans: fetched.data?.plans ?? [], families: fetched.data?.families ?? null };
4216
+ const loading = !propPlans && Boolean(client) && fetched.isPending;
4217
+ const loadError = propPlans ? null : !client ? "BillingProvider with a publishableKey is required to load plans" : fetched.error ? `Plans could not be loaded: ${fetched.error.message}` : null;
3820
4218
  const [currentPlan, setCurrentPlan] = useState(currentPlanCode ?? null);
3821
- const [isAnnual, setIsAnnual] = useState(false);
4219
+ const [selectedInterval, setSelectedInterval] = useState(null);
3822
4220
  const [processing, setProcessing] = useState(false);
3823
4221
  const [checkoutError, setCheckoutError] = useState(null);
3824
4222
  useEffect(() => {
3825
4223
  if (currentPlanCode !== void 0) setCurrentPlan(currentPlanCode);
3826
4224
  }, [currentPlanCode]);
3827
- async function selectPlan(plan) {
4225
+ const withFamilies = Array.isArray(catalog.families);
4226
+ async function selectPlan(plan, family) {
3828
4227
  setCheckoutError(null);
3829
4228
  if (onSelect) {
3830
- onSelect(plan);
4229
+ onSelect(plan, family);
3831
4230
  return;
3832
4231
  }
3833
4232
  try {
@@ -3853,37 +4252,57 @@ function PricingTable({
3853
4252
  onCheckoutError?.(error);
3854
4253
  }
3855
4254
  }
3856
- const ordered = sort ? [...plans].sort(sort) : [...sortPlansByPrice(plans.filter((p) => !isEnterprise(p))), ...plans.filter(isEnterprise)];
3857
- const hasAnnual = ordered.some((p) => p.interval === "yearly");
3858
- const current = plans.find((p) => p.code === currentPlan);
3859
- const filtered = showToggle && hasAnnual ? ordered.filter((p) => p.interval === (isAnnual ? "yearly" : "monthly") || isEnterprise(p)) : ordered;
4255
+ const available = getAvailableIntervals(catalog);
4256
+ const interval = selectedInterval !== null && available.includes(selectedInterval) ? selectedInterval : getDefaultInterval(catalog, defaultInterval);
4257
+ const switchVisible = showToggle && available.length > 1 && interval !== null;
4258
+ const cards = getPricingCards(catalog, {
4259
+ interval: withFamilies || switchVisible ? interval : null,
4260
+ locale,
4261
+ highlight: highlightPlan,
4262
+ badge: highlightLabel,
4263
+ features,
4264
+ contactSales: contactSalesPlans,
4265
+ sort
4266
+ });
4267
+ const current = catalog.plans.find((p) => p.code === currentPlan);
4268
+ const currentYearly = current ? yearlyMinor(current) : null;
4269
+ const isUpgrade = (plan) => {
4270
+ if (!current || current.amount_currency !== plan.amount_currency) return void 0;
4271
+ const yearly = yearlyMinor(plan);
4272
+ return currentYearly !== null && yearly !== null ? yearly > currentYearly : plan.amount_cents > current.amount_cents;
4273
+ };
3860
4274
  const rootProps = root("pricingTable", text.body, className);
3861
4275
  if (loading) return /* @__PURE__ */ jsx(Skeleton, { rootProps });
3862
4276
  return /* @__PURE__ */ jsxs("div", { ...rootProps, children: [
3863
- showToggle && hasAnnual && /* @__PURE__ */ jsx(Toggle, { isAnnual, onChange: setIsAnnual, el }),
4277
+ switchVisible && interval !== null && /* @__PURE__ */ jsx(
4278
+ IntervalSwitch,
4279
+ {
4280
+ options: getPricingIntervalOptions(catalog),
4281
+ selected: interval,
4282
+ onChange: setSelectedInterval,
4283
+ annualDiscountLabel,
4284
+ el
4285
+ }
4286
+ ),
3864
4287
  processing && /* @__PURE__ */ jsx("p", { role: "status", children: "Payment is processing. Confirmation may take a little while." }),
3865
4288
  (checkoutError ?? loadError) && /* @__PURE__ */ jsx("p", { role: "alert", style: { color: T.dangerText }, children: checkoutError ?? loadError }),
3866
4289
  /* @__PURE__ */ jsx("div", { ...el("pricingTable__grid", {
3867
4290
  display: "grid",
3868
4291
  gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
3869
4292
  gap: "1.5rem"
3870
- }), children: filtered.map((plan, i) => /* @__PURE__ */ jsx(
4293
+ }), children: cards.map((card) => /* @__PURE__ */ jsx(
3871
4294
  Card,
3872
4295
  {
3873
- plan,
3874
- features: featureLines(features, plan, i),
3875
- isCurrent: currentPlan === plan.code,
3876
- isUpgrade: current && current.amount_currency === plan.amount_currency ? plan.amount_cents > current.amount_cents : void 0,
3877
- isHighlighted: highlightPlan === plan.code,
4296
+ card,
4297
+ isCurrent: currentPlan === card.plan.code,
4298
+ isUpgrade: isUpgrade(card.plan),
3878
4299
  filled: highlightStyle === "filled",
3879
- highlightLabel,
3880
- isEnt: isEnterprise(plan),
3881
4300
  enterpriseEmail,
3882
- onSelect: () => void selectPlan(plan),
4301
+ onSelect: () => void selectPlan(card.plan, card.family),
3883
4302
  el,
3884
4303
  panel: planCardLayout === "panel"
3885
4304
  },
3886
- plan.code
4305
+ card.key
3887
4306
  )) }),
3888
4307
  /* @__PURE__ */ jsx(NozleBranding, {})
3889
4308
  ] });
@@ -4903,6 +5322,6 @@ function UsageTrend({
4903
5322
  ] }, point.key)) }) });
4904
5323
  }
4905
5324
 
4906
- export { BillingContext, BillingPortal, BillingPortalError, BillingProvider, BillingRequestError, CancellationControl, Checkout, CheckoutButton, CreditBalance, CreditHistory, CurrentPlan, FeatureGate, LockedOverlay, PaymentMethodDisplay, PlanBadge, PlanCard, PlanChangeControl, PlanComparison, PlanGate, PoweredByNozle, PricingTable, UpgradeButton, UpgradeModal, UpgradePrompt, UsageAlert, UsageBreakdown, UsageDashboard, UsageGate, UsageMeter, UsageTrend, comparisonRows, currentPlanMeters, getUsageMeterColor, planFeatures, sortPlansByPrice, usageItems, useAppearance, useBillingContext, useBillingRefresh, useCheckout, useCheckout2 as useCheckoutSession, useCreditBalance, useCreditHistory, useEntitlement, useInvoiceDownload, useInvoices, useNozleBranding, useNozleClient, useOptionalBillingContext, usePlans, useSubscription, useUpgradePreview, useUsage };
5325
+ export { BillingContext, BillingPortal, BillingPortalError, BillingProvider, BillingRequestError, CancellationControl, Checkout, CheckoutButton, CreditBalance, CreditHistory, CurrentPlan, FeatureGate, LockedOverlay, PaymentMethodDisplay, PlanBadge, PlanCard, PlanChangeControl, PlanComparison, PlanGate, PoweredByNozle, PricingTable, UpgradeButton, UpgradeModal, UpgradePrompt, UsageAlert, UsageBreakdown, UsageDashboard, UsageGate, UsageMeter, UsageTrend, comparisonRows, currentPlanMeters, getAvailableIntervals, getDefaultInterval, getPricingCards, getPricingIntervalOptions, getUsageMeterColor, planFeatures, sortPlansByPrice, usageItems, useAppearance, useBillingContext, useBillingRefresh, useCheckout, useCheckout2 as useCheckoutSession, useCreditBalance, useCreditHistory, useEntitlement, useInvoiceDownload, useInvoices, useNozleBranding, useNozleClient, useOptionalBillingContext, usePlans, useSubscription, useUpgradePreview, useUsage };
4907
5326
  //# sourceMappingURL=index.js.map
4908
5327
  //# sourceMappingURL=index.js.map