@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.cjs CHANGED
@@ -411,7 +411,11 @@ function catalogQuery(client) {
411
411
  const response = await client.catalogFetch("/api/v1/plans", { signal });
412
412
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
413
413
  const data = await response.json();
414
- return { plans: data.plans ?? [], nozleBranding: data.meta?.nozle_branding === true };
414
+ return {
415
+ plans: data.plans ?? [],
416
+ families: Array.isArray(data.families) ? data.families : null,
417
+ nozleBranding: data.meta?.nozle_branding === true
418
+ };
415
419
  },
416
420
  staleTime: 5 * 6e4,
417
421
  // The pricing page shows a load error at once; the app can call refetch().
@@ -423,6 +427,7 @@ function usePlans() {
423
427
  const query = reactQuery.useQuery(catalogQuery(client), queryClient);
424
428
  return {
425
429
  plans: query.data?.plans ?? [],
430
+ families: query.data?.families ?? null,
426
431
  isLoading: query.isPending,
427
432
  error: query.error ?? null,
428
433
  refetch: query.refetch
@@ -1964,7 +1969,7 @@ function UpgradeModal(props) {
1964
1969
  }
1965
1970
 
1966
1971
  // src/components/billing/amounts.ts
1967
- function formatMinorAmount(value, currency, locale = "en-US") {
1972
+ function formatMinorAmount(value, currency, locale = "en-US", options = {}) {
1968
1973
  const raw = String(value);
1969
1974
  if (!/^-?\d+$/.test(raw) || typeof value === "number" && !Number.isSafeInteger(value)) {
1970
1975
  return "Amount unavailable";
@@ -1980,12 +1985,16 @@ function formatMinorAmount(value, currency, locale = "en-US") {
1980
1985
  const scale = 10n ** BigInt(digits);
1981
1986
  const whole = absolute / scale;
1982
1987
  const signed = minor < 0n ? whole === 0n ? -0 : -whole : whole;
1983
- const decimal = String(absolute % scale).padStart(digits, "0");
1988
+ const remainder = absolute % scale;
1989
+ const trim = options.trimZeroFraction === true && remainder === 0n;
1990
+ const decimal = String(remainder).padStart(digits, "0");
1984
1991
  const digitFormatter = new Intl.NumberFormat(locale, {
1985
1992
  useGrouping: false
1986
1993
  });
1987
1994
  const fraction = [...decimal].map((digit) => digitFormatter.format(Number(digit))).join("");
1988
- return formatter.formatToParts(signed).map((part) => part.type === "fraction" ? fraction : part.value).join("");
1995
+ return formatter.formatToParts(signed).filter(
1996
+ (part) => !trim || part.type !== "fraction" && part.type !== "decimal"
1997
+ ).map((part) => part.type === "fraction" ? fraction : part.value).join("");
1989
1998
  } catch {
1990
1999
  return "Amount unavailable";
1991
2000
  }
@@ -3701,6 +3710,360 @@ function useCheckout2() {
3701
3710
  );
3702
3711
  return { fetchClientSecret, checkout, isLoading, error };
3703
3712
  }
3713
+
3714
+ // src/components/pricing/catalog.ts
3715
+ var PRICING_INTERVALS = [
3716
+ "weekly",
3717
+ "monthly",
3718
+ "quarterly",
3719
+ "semiannual",
3720
+ "yearly"
3721
+ ];
3722
+ var INTERVAL_LABELS2 = {
3723
+ weekly: "Weekly",
3724
+ monthly: "Monthly",
3725
+ quarterly: "Quarterly",
3726
+ semiannual: "Every 6 months",
3727
+ yearly: "Annual"
3728
+ };
3729
+ var BILLED_WORDS = {
3730
+ weekly: "weekly",
3731
+ monthly: "monthly",
3732
+ quarterly: "quarterly",
3733
+ semiannual: "every 6 months",
3734
+ yearly: "yearly"
3735
+ };
3736
+ var PERIODS_PER_YEAR = {
3737
+ weekly: 52n,
3738
+ monthly: 12n,
3739
+ quarterly: 4n,
3740
+ semiannual: 2n,
3741
+ yearly: 1n
3742
+ };
3743
+ function isPricingInterval(value) {
3744
+ return typeof value === "string" && PRICING_INTERVALS.includes(value);
3745
+ }
3746
+ function intervalRank(interval) {
3747
+ return PRICING_INTERVALS.indexOf(interval);
3748
+ }
3749
+ function normalizePricingInterval(value) {
3750
+ if (value === "annual") return "yearly";
3751
+ return isPricingInterval(value) ? value : null;
3752
+ }
3753
+ function hasPosition(value) {
3754
+ return typeof value === "number" && Number.isFinite(value);
3755
+ }
3756
+ function comparePositions(a, b) {
3757
+ const aSet = hasPosition(a);
3758
+ const bSet = hasPosition(b);
3759
+ if (aSet && bSet) return a - b;
3760
+ if (aSet) return -1;
3761
+ if (bSet) return 1;
3762
+ return 0;
3763
+ }
3764
+ var DEFAULT_LOCALE = "en-US";
3765
+ function resolvePricingLocale(locale) {
3766
+ if (typeof locale !== "string" || locale.trim() === "") return DEFAULT_LOCALE;
3767
+ try {
3768
+ Intl.getCanonicalLocales(locale);
3769
+ return locale;
3770
+ } catch {
3771
+ return DEFAULT_LOCALE;
3772
+ }
3773
+ }
3774
+ function isLive(plan) {
3775
+ return plan.pending_deletion !== true;
3776
+ }
3777
+ function hasFamilies(catalog) {
3778
+ return Array.isArray(catalog.families);
3779
+ }
3780
+ function getPricingGroups(catalog) {
3781
+ const plans = catalog.plans ?? [];
3782
+ if (!hasFamilies(catalog)) {
3783
+ return plans.filter(isLive).map((plan) => ({ key: plan.code, family: null, plans: [plan] }));
3784
+ }
3785
+ const byCode = /* @__PURE__ */ new Map();
3786
+ for (const plan of plans) {
3787
+ if (isLive(plan) && !byCode.has(plan.code)) byCode.set(plan.code, plan);
3788
+ }
3789
+ return catalog.families.map((family, index) => ({ family, index })).filter(({ family }) => family.show_on_pricing !== false).sort(
3790
+ (a, b) => comparePositions(a.family.pricing_position, b.family.pricing_position) || a.index - b.index
3791
+ ).map(({ family }) => {
3792
+ const seen = /* @__PURE__ */ new Set();
3793
+ const familyPlans = [];
3794
+ for (const code of family.plan_codes ?? []) {
3795
+ const plan = byCode.get(code);
3796
+ if (plan && !seen.has(code)) {
3797
+ seen.add(code);
3798
+ familyPlans.push(plan);
3799
+ }
3800
+ }
3801
+ return { key: family.code, family, plans: familyPlans };
3802
+ }).filter((group) => group.plans.length > 0);
3803
+ }
3804
+ function toMinor(value) {
3805
+ const raw = String(value);
3806
+ if (!/^-?\d+$/.test(raw)) return null;
3807
+ if (typeof value === "number" && !Number.isSafeInteger(value)) return null;
3808
+ return BigInt(raw);
3809
+ }
3810
+ function divideRounded(numerator, denominator) {
3811
+ if (numerator < 0n) return -divideRounded(-numerator, denominator);
3812
+ return (2n * numerator + denominator) / (2n * denominator);
3813
+ }
3814
+ function perMonthMinor(amount, interval) {
3815
+ return divideRounded(amount * PERIODS_PER_YEAR[interval], 12n);
3816
+ }
3817
+ function fmt(amount, currency, locale) {
3818
+ return formatMinorAmount(String(amount), currency, locale, {
3819
+ trimZeroFraction: true
3820
+ });
3821
+ }
3822
+ function savingsAgainstMonthly(plan, monthly) {
3823
+ if (!monthly || monthly === plan) return null;
3824
+ if (!isPricingInterval(plan.interval) || plan.interval === "monthly") return null;
3825
+ if (monthly.amount_currency !== plan.amount_currency) return null;
3826
+ const monthlyAmount = toMinor(monthly.amount_cents);
3827
+ const amount = toMinor(plan.amount_cents);
3828
+ if (monthlyAmount === null || amount === null) return null;
3829
+ const base = monthlyAmount * 12n;
3830
+ const cost = amount * PERIODS_PER_YEAR[plan.interval];
3831
+ if (base <= 0n || cost >= base) return null;
3832
+ const percent = Number(divideRounded(100n * (base - cost), base));
3833
+ return percent > 0 ? percent : null;
3834
+ }
3835
+ function monthlyPlanOf(group) {
3836
+ return group.family ? group.plans.find((plan) => plan.interval === "monthly") : void 0;
3837
+ }
3838
+ function getAvailableIntervals(catalog) {
3839
+ const present = /* @__PURE__ */ new Set();
3840
+ for (const group of getPricingGroups(catalog)) {
3841
+ for (const plan of group.plans) present.add(plan.interval);
3842
+ }
3843
+ return PRICING_INTERVALS.filter((interval) => present.has(interval));
3844
+ }
3845
+ function getPricingIntervalOptions(catalog) {
3846
+ const groups = getPricingGroups(catalog);
3847
+ return getAvailableIntervals(catalog).map((interval) => {
3848
+ let best = null;
3849
+ if (interval !== "monthly") {
3850
+ for (const group of groups) {
3851
+ const plan = group.plans.find((p) => p.interval === interval);
3852
+ const saving = plan ? savingsAgainstMonthly(plan, monthlyPlanOf(group)) : null;
3853
+ if (saving !== null && (best === null || saving > best)) best = saving;
3854
+ }
3855
+ }
3856
+ return { interval, label: INTERVAL_LABELS2[interval], savingsPercent: best };
3857
+ });
3858
+ }
3859
+ function getDefaultInterval(catalog, preferred) {
3860
+ const available = getAvailableIntervals(catalog);
3861
+ const wanted = normalizePricingInterval(preferred);
3862
+ if (wanted && available.includes(wanted)) return wanted;
3863
+ if (available.includes("monthly")) return "monthly";
3864
+ return available[0] ?? null;
3865
+ }
3866
+ function pickPlan(plans, interval) {
3867
+ if (interval === null) return plans[0];
3868
+ const exact = plans.find((plan) => plan.interval === interval);
3869
+ if (exact) return exact;
3870
+ const target = intervalRank(interval);
3871
+ let best = plans[0];
3872
+ let bestScore = Number.POSITIVE_INFINITY;
3873
+ for (const plan of plans) {
3874
+ const rank = intervalRank(plan.interval);
3875
+ if (rank < 0) continue;
3876
+ const score = Math.abs(rank - target) * 2 + (rank > target ? 1 : 0);
3877
+ if (score < bestScore) {
3878
+ best = plan;
3879
+ bestScore = score;
3880
+ }
3881
+ }
3882
+ return best;
3883
+ }
3884
+ function isTrueBoolean(privilege) {
3885
+ const { value } = privilege;
3886
+ if (value === true) return true;
3887
+ return privilege.value_type === "boolean" && typeof value === "string" && value.toLowerCase() === "true";
3888
+ }
3889
+ function formatPrivilegeValue(privilege, numberFormat) {
3890
+ const { value } = privilege;
3891
+ if (value === null || value === void 0) return null;
3892
+ if (isTrueBoolean(privilege)) return null;
3893
+ if (value === false) return "No";
3894
+ if (typeof value === "number") {
3895
+ return Number.isFinite(value) ? numberFormat.format(value) : null;
3896
+ }
3897
+ const text2 = String(value).trim();
3898
+ if (text2 === "") return null;
3899
+ if (privilege.value_type === "boolean" && text2.toLowerCase() === "false") {
3900
+ return "No";
3901
+ }
3902
+ if (privilege.value_type === "integer" && /^-?\d+$/.test(text2)) {
3903
+ const parsed = Number(text2);
3904
+ if (Number.isSafeInteger(parsed)) return numberFormat.format(parsed);
3905
+ }
3906
+ return text2;
3907
+ }
3908
+ function featureLabel(name, entitlement, numberFormat) {
3909
+ const values = (entitlement?.privileges ?? []).map((privilege) => ({
3910
+ privilege,
3911
+ text: formatPrivilegeValue(privilege, numberFormat)
3912
+ })).filter(
3913
+ (entry) => entry.text !== null
3914
+ );
3915
+ if (values.length === 1) return `${name}: ${values[0].text}`;
3916
+ if (values.length > 1) {
3917
+ const parts = values.map(({ privilege, text: text2 }) => {
3918
+ const privilegeName = privilege.name?.trim() || privilege.code;
3919
+ return `${privilegeName}: ${text2}`;
3920
+ });
3921
+ return `${name} (${parts.join(", ")})`;
3922
+ }
3923
+ const limit = entitlement?.usage_limit;
3924
+ if (typeof limit === "number" && Number.isFinite(limit)) {
3925
+ return `${name}: ${numberFormat.format(limit)}`;
3926
+ }
3927
+ return name;
3928
+ }
3929
+ function trimmed(value) {
3930
+ return typeof value === "string" ? value.trim() : "";
3931
+ }
3932
+ function familyFeatures(family, plan, locale) {
3933
+ const numberFormat = new Intl.NumberFormat(locale);
3934
+ const entitlements = /* @__PURE__ */ new Map();
3935
+ for (const entitlement of plan.entitlements ?? []) {
3936
+ if (!entitlements.has(entitlement.code)) {
3937
+ entitlements.set(entitlement.code, entitlement);
3938
+ }
3939
+ }
3940
+ return (family.features ?? []).filter((feature) => feature.show_on_pricing !== false).map((feature, index) => {
3941
+ const entitlement = entitlements.get(feature.code);
3942
+ const name = trimmed(feature.name) || trimmed(entitlement?.name) || feature.code;
3943
+ return { feature, entitlement, index, name };
3944
+ }).sort(
3945
+ (a, b) => comparePositions(a.feature.display_position, b.feature.display_position) || a.name.localeCompare(b.name, locale) || a.index - b.index
3946
+ ).map(({ feature, entitlement, name }) => {
3947
+ const label = featureLabel(name, entitlement, numberFormat);
3948
+ const description = trimmed(feature.description);
3949
+ return feature.show_description === true && description ? { label, description } : { label };
3950
+ });
3951
+ }
3952
+ function planCardFeatures(plan) {
3953
+ return planFeatures(plan).map((label) => ({ label }));
3954
+ }
3955
+ function normalizeFeatures(items) {
3956
+ return items.map((item) => typeof item === "string" ? { label: item } : item).filter(
3957
+ (item) => Boolean(item) && typeof item.label === "string"
3958
+ );
3959
+ }
3960
+ function overrideFeatures(overrides, group, plan, cardIndex) {
3961
+ if (!overrides) return null;
3962
+ if (Array.isArray(overrides)) {
3963
+ const entry = overrides[cardIndex];
3964
+ return entry ? normalizeFeatures(entry) : null;
3965
+ }
3966
+ const keys = [group.key, plan.code, ...group.plans.map((p) => p.code)];
3967
+ for (const key of keys) {
3968
+ if (Object.prototype.hasOwnProperty.call(overrides, key)) {
3969
+ return normalizeFeatures(overrides[key] ?? []);
3970
+ }
3971
+ }
3972
+ return null;
3973
+ }
3974
+ function yearlyMinor(plan) {
3975
+ const amount = toMinor(plan.amount_cents);
3976
+ if (amount === null || !isPricingInterval(plan.interval)) return null;
3977
+ return amount * PERIODS_PER_YEAR[plan.interval];
3978
+ }
3979
+ function planPriceStrings(plan, locale) {
3980
+ const amount = toMinor(plan.amount_cents);
3981
+ return {
3982
+ price: fmt(amount ?? plan.amount_cents, plan.amount_currency, locale),
3983
+ pricePeriod: plan.interval ? `/${intervalLabel(plan.interval)}` : "",
3984
+ billing: "",
3985
+ savingsPercent: null
3986
+ };
3987
+ }
3988
+ function priceStrings(plan, monthly, locale) {
3989
+ const amount = toMinor(plan.amount_cents);
3990
+ const currency = plan.amount_currency;
3991
+ if (!isPricingInterval(plan.interval) || amount === null) {
3992
+ return {
3993
+ price: fmt(amount ?? plan.amount_cents, currency, locale),
3994
+ pricePeriod: "",
3995
+ billing: plan.interval ? `Billed ${plan.interval}` : "",
3996
+ savingsPercent: null
3997
+ };
3998
+ }
3999
+ const price = fmt(perMonthMinor(amount, plan.interval), currency, locale);
4000
+ if (plan.interval === "monthly") {
4001
+ return { price, pricePeriod: "/mo", billing: "Billed monthly", savingsPercent: null };
4002
+ }
4003
+ const savingsPercent = savingsAgainstMonthly(plan, monthly);
4004
+ const billed = `Billed ${fmt(amount, currency, locale)} ${BILLED_WORDS[plan.interval]}`;
4005
+ return {
4006
+ price,
4007
+ pricePeriod: "/mo",
4008
+ billing: savingsPercent === null ? billed : `${billed} \xB7 save ${savingsPercent}%`,
4009
+ savingsPercent
4010
+ };
4011
+ }
4012
+ function getPricingCards(catalog, options = {}) {
4013
+ const locale = resolvePricingLocale(options.locale);
4014
+ const families = hasFamilies(catalog);
4015
+ const requested = normalizePricingInterval(options.interval ?? null);
4016
+ const interval = requested ?? getDefaultInterval(catalog);
4017
+ const contactSales = new Set(options.contactSales ?? []);
4018
+ const isContactSales = (group) => contactSales.has(group.key) || group.plans.some((plan) => contactSales.has(plan.code));
4019
+ let groups = getPricingGroups(catalog);
4020
+ if (!families) {
4021
+ if (options.sort) {
4022
+ const sort = options.sort;
4023
+ 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);
4024
+ } else {
4025
+ const sales = groups.filter(isContactSales);
4026
+ const byPlan = new Map(groups.map((group) => [group.plans[0], group]));
4027
+ groups = [
4028
+ ...sortPlansByPrice(groups.filter((group) => !isContactSales(group)).map((group) => group.plans[0])).map((plan) => byPlan.get(plan)),
4029
+ ...sales
4030
+ ];
4031
+ }
4032
+ if (requested) {
4033
+ groups = groups.filter(
4034
+ (group) => group.plans[0].interval === requested || isContactSales(group)
4035
+ );
4036
+ }
4037
+ }
4038
+ const highlight = options.highlight ?? null;
4039
+ const highlightedKey = highlight !== null ? groups.find(
4040
+ (group) => group.key === highlight || group.plans.some((plan) => plan.code === highlight)
4041
+ )?.key : groups.find((group) => group.family?.pricing_highlighted === true)?.key;
4042
+ const badge = (family) => options.badge !== void 0 ? options.badge === null ? null : trimmed(options.badge) || null : trimmed(family?.badge_text) || "Most Popular";
4043
+ return groups.map((group, cardIndex) => {
4044
+ const plan = pickPlan(group.plans, interval);
4045
+ const family = group.family;
4046
+ const name = trimmed(family?.name) || plan.name;
4047
+ const description = trimmed(family?.description) || trimmed(plan.description) || null;
4048
+ const highlighted = highlightedKey !== void 0 && highlightedKey === group.key;
4049
+ const features = overrideFeatures(options.features, group, plan, cardIndex) ?? (family ? familyFeatures(family, plan, locale) : planCardFeatures(plan));
4050
+ return {
4051
+ key: group.key,
4052
+ family,
4053
+ plans: group.plans,
4054
+ plan,
4055
+ isFallbackInterval: families && interval !== null && plan.interval !== interval,
4056
+ name,
4057
+ description,
4058
+ ...family ? priceStrings(plan, monthlyPlanOf(group), locale) : planPriceStrings(plan, locale),
4059
+ features,
4060
+ highlighted,
4061
+ badge: highlighted ? badge(family) : null,
4062
+ cta: trimmed(family?.cta_text) || `Get ${name}`,
4063
+ enterprise: isContactSales(group)
4064
+ };
4065
+ });
4066
+ }
3704
4067
  function PanelPlanCard({
3705
4068
  frame,
3706
4069
  el,
@@ -3708,6 +4071,7 @@ function PanelPlanCard({
3708
4071
  description,
3709
4072
  price,
3710
4073
  interval,
4074
+ priceNote,
3711
4075
  features,
3712
4076
  isCurrent,
3713
4077
  isHighlighted,
@@ -3772,6 +4136,7 @@ function PanelPlanCard({
3772
4136
  interval
3773
4137
  ] })
3774
4138
  ] }),
4139
+ priceNote && /* @__PURE__ */ jsxRuntime.jsx("p", { "data-testid": "pricing-billing", style: { ...text.meta, color: muted5, margin: "0.25rem 0 0" }, children: priceNote }),
3775
4140
  description && /* @__PURE__ */ jsxRuntime.jsx("p", { ...el("planCard__description", { ...text.secondary, color: muted5, margin: "0.25rem 0 0" }), children: description })
3776
4141
  ] }),
3777
4142
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -3788,7 +4153,13 @@ function PanelPlanCard({
3788
4153
  ...text.secondary,
3789
4154
  color: muted5
3790
4155
  }),
3791
- children: features.map((feature) => /* @__PURE__ */ jsxRuntime.jsx("li", { ...el("planCard__feature"), children: feature }, feature))
4156
+ children: features.map((feature, index) => {
4157
+ const item = typeof feature === "string" ? { label: feature } : feature;
4158
+ return /* @__PURE__ */ jsxRuntime.jsxs("li", { ...el("planCard__feature"), children: [
4159
+ item.label,
4160
+ item.description && /* @__PURE__ */ jsxRuntime.jsx("span", { style: { display: "block", ...text.meta, color: muted5 }, children: item.description })
4161
+ ] }, `${index}:${item.label}`);
4162
+ })
3792
4163
  }
3793
4164
  ),
3794
4165
  children
@@ -3797,59 +4168,66 @@ function PanelPlanCard({
3797
4168
  }
3798
4169
  );
3799
4170
  }
3800
- function featureLines(override, plan, index) {
3801
- const given = Array.isArray(override) ? override[index] : override?.[plan.code];
3802
- return given ?? planFeatures(plan);
3803
- }
3804
- var SYMBOLS = { USD: "$", EUR: "\u20AC", GBP: "\xA3", JPY: "\xA5" };
3805
- function fmtPrice(cents, cur) {
3806
- const s = SYMBOLS[cur] ?? cur + " ";
3807
- const val = cents % 100 === 0 ? String(cents / 100) : (cents / 100).toFixed(2);
3808
- return `${s}${val}`;
3809
- }
3810
- var C = {
3811
- cardBg: T.card,
3812
- hl: T.primary,
3813
- border: T.border,
3814
- radius: T.radius,
3815
- text: T.foreground,
3816
- muted: T.mutedForeground,
3817
- mutedBg: T.muted};
3818
4171
  function Check() {
3819
4172
  return /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", style: { flexShrink: 0 }, children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3.5 8.5L6.5 11.5L12.5 4.5", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) });
3820
4173
  }
3821
- function Toggle({ isAnnual, onChange, el }) {
4174
+ function IntervalSwitch({
4175
+ options,
4176
+ selected,
4177
+ onChange,
4178
+ annualDiscountLabel,
4179
+ el
4180
+ }) {
3822
4181
  const pill = {
3823
4182
  display: "inline-flex",
4183
+ flexWrap: "wrap",
4184
+ justifyContent: "center",
3824
4185
  borderRadius: "9999px",
3825
4186
  padding: "4px",
3826
- background: C.mutedBg,
4187
+ background: T.muted,
3827
4188
  gap: "2px"
3828
4189
  };
3829
4190
  const tab = (active) => ({
3830
4191
  padding: "8px 20px",
3831
4192
  borderRadius: "9999px",
3832
4193
  border: "none",
3833
- background: active ? C.cardBg : "transparent",
4194
+ background: active ? T.card : "transparent",
3834
4195
  ...text.action,
3835
- color: active ? C.text : C.muted,
4196
+ color: active ? T.foreground : T.mutedForeground,
3836
4197
  cursor: "pointer",
3837
4198
  boxShadow: active ? T.shadow : "none",
3838
4199
  transition: "all 0.15s ease"
3839
4200
  });
3840
- return /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", justifyContent: "center", marginBottom: "2.5rem" }, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { ...el("pricingTable__toggle", pill), children: [
3841
- /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: () => onChange(false), ...el("pricingTable__toggleButton", tab(!isAnnual)), "aria-pressed": !isAnnual, children: "Monthly" }),
3842
- /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: () => onChange(true), ...el("pricingTable__toggleButton", tab(isAnnual)), "aria-pressed": isAnnual, children: "Annual" })
3843
- ] }) });
4201
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", justifyContent: "center", marginBottom: "2.5rem" }, children: /* @__PURE__ */ jsxRuntime.jsx("div", { role: "group", "aria-label": "Billing interval", ...el("pricingTable__toggle", pill), children: options.map((option) => {
4202
+ const active = option.interval === selected;
4203
+ const note = option.interval === "yearly" && annualDiscountLabel ? annualDiscountLabel : option.interval !== "monthly" && option.savingsPercent !== null ? `Save ${option.savingsPercent}%` : null;
4204
+ return /* @__PURE__ */ jsxRuntime.jsxs(
4205
+ "button",
4206
+ {
4207
+ type: "button",
4208
+ "data-interval": option.interval,
4209
+ onClick: () => onChange(option.interval),
4210
+ ...el("pricingTable__toggleButton", tab(active)),
4211
+ "aria-pressed": active,
4212
+ "aria-label": note ? `${option.label}, ${note.replace(/^Save /, "save ")}` : void 0,
4213
+ children: [
4214
+ option.label,
4215
+ note && /* @__PURE__ */ jsxRuntime.jsx("span", { style: {
4216
+ marginLeft: "6px",
4217
+ ...text.meta,
4218
+ fontWeight: 600,
4219
+ color: active ? T.primary : T.mutedForeground
4220
+ }, children: note })
4221
+ ]
4222
+ },
4223
+ option.interval
4224
+ );
4225
+ }) }) });
3844
4226
  }
3845
4227
  function Card({
3846
- plan,
3847
- features,
4228
+ card: card2,
3848
4229
  isCurrent,
3849
- isHighlighted,
3850
4230
  filled: filledStyle,
3851
- highlightLabel,
3852
- isEnt,
3853
4231
  enterpriseEmail,
3854
4232
  onSelect,
3855
4233
  el,
@@ -3857,12 +4235,14 @@ function Card({
3857
4235
  isUpgrade
3858
4236
  }) {
3859
4237
  const [hover, setHover] = React2.useState(false);
4238
+ const { plan, features } = card2;
4239
+ const isHighlighted = card2.highlighted;
4240
+ const isEnt = card2.enterprise;
3860
4241
  const filled = isHighlighted && filledStyle;
3861
- const text2 = filled ? T.highlightForeground : C.text;
3862
- const muted5 = filled ? `color-mix(in srgb, ${T.highlightForeground} 75%, ${T.highlight})` : C.muted;
3863
- const accent = filled ? T.highlightForeground : C.hl;
3864
- const badgeLabel = isCurrent ? "Current Plan" : highlightLabel;
3865
- const interval = `/${intervalLabel(plan.interval)}`;
4242
+ const text2 = filled ? T.highlightForeground : T.foreground;
4243
+ const muted5 = filled ? `color-mix(in srgb, ${T.highlightForeground} 75%, ${T.highlight})` : T.mutedForeground;
4244
+ const accent = filled ? T.highlightForeground : T.primary;
4245
+ const badgeLabel = isCurrent ? "Current Plan" : card2.badge;
3866
4246
  const handleClick = () => {
3867
4247
  if (isCurrent) return;
3868
4248
  if (isEnt && enterpriseEmail) {
@@ -3871,6 +4251,7 @@ function Card({
3871
4251
  }
3872
4252
  onSelect?.();
3873
4253
  };
4254
+ const ctaText = isCurrent ? "Current Plan" : card2.family?.cta_text?.trim() ? card2.cta : isEnt ? "Contact Sales" : card2.cta;
3874
4255
  const emphasised = isUpgrade ?? isHighlighted;
3875
4256
  const cta = onSelect && /* @__PURE__ */ jsxRuntime.jsx(
3876
4257
  CTAButton,
@@ -3879,24 +4260,32 @@ function Card({
3879
4260
  disabled: isCurrent,
3880
4261
  variant: isCurrent ? "current" : isEnt ? "outline" : !emphasised ? "secondary" : filled ? "inverse" : isHighlighted ? "highlight" : "primary",
3881
4262
  el,
3882
- children: isCurrent ? "Current Plan" : isEnt ? "Contact Sales" : `Get ${plan.name}`
4263
+ children: ctaText
3883
4264
  }
3884
4265
  );
4266
+ const dataAttributes = {
4267
+ "data-card-key": card2.key,
4268
+ "data-family-code": card2.family?.code,
4269
+ "data-plan-code": plan.code,
4270
+ "data-interval": plan.interval,
4271
+ "data-highlighted": isHighlighted ? "true" : void 0
4272
+ };
3885
4273
  if (panel) {
3886
4274
  return /* @__PURE__ */ jsxRuntime.jsx(
3887
4275
  PanelPlanCard,
3888
4276
  {
3889
- frame: (style) => el("planCard", style),
4277
+ frame: (style) => ({ ...el("planCard", style), ...dataAttributes }),
3890
4278
  el,
3891
- name: plan.name,
3892
- description: plan.description,
3893
- price: isEnt ? "Custom" : fmtPrice(plan.amount_cents, plan.amount_currency),
3894
- interval: isEnt ? void 0 : intervalLabel(plan.interval),
3895
- features: features ?? [],
4279
+ name: card2.name,
4280
+ description: card2.description,
4281
+ price: isEnt ? "Custom" : card2.price,
4282
+ interval: isEnt || !card2.pricePeriod ? void 0 : card2.pricePeriod.replace(/^\//, ""),
4283
+ priceNote: isEnt ? null : card2.billing,
4284
+ features,
3896
4285
  isCurrent,
3897
4286
  isHighlighted,
3898
4287
  filled: filledStyle,
3899
- highlightLabel,
4288
+ highlightLabel: card2.badge,
3900
4289
  children: cta
3901
4290
  }
3902
4291
  );
@@ -3904,17 +4293,18 @@ function Card({
3904
4293
  return /* @__PURE__ */ jsxRuntime.jsxs(
3905
4294
  "div",
3906
4295
  {
4296
+ ...dataAttributes,
3907
4297
  onMouseEnter: () => setHover(true),
3908
4298
  onMouseLeave: () => setHover(false),
3909
4299
  ...el("planCard", {
3910
4300
  position: "relative",
3911
4301
  display: "flex",
3912
4302
  flexDirection: "column",
3913
- background: filled ? T.highlight : C.cardBg,
4303
+ background: filled ? T.highlight : T.card,
3914
4304
  color: text2,
3915
- border: border(isHighlighted ? T.highlight : C.border),
4305
+ border: border(isHighlighted ? T.highlight : T.border),
3916
4306
  borderTopWidth: isHighlighted && !filled ? `calc(${T.borderWidth} + 2px)` : T.borderWidth,
3917
- borderRadius: C.radius,
4307
+ borderRadius: T.radius,
3918
4308
  padding: "2rem",
3919
4309
  transition: "box-shadow 0.2s ease, transform 0.2s ease",
3920
4310
  boxShadow: T.shadow,
@@ -3939,7 +4329,7 @@ function Card({
3939
4329
  top: "0",
3940
4330
  left: "50%",
3941
4331
  transform: "translate(-50%, -50%)",
3942
- background: C.mutedBg,
4332
+ background: T.muted,
3943
4333
  ...text.label,
3944
4334
  padding: "4px 12px",
3945
4335
  borderRadius: "9999px",
@@ -3950,18 +4340,21 @@ function Card({
3950
4340
  ...text.heading,
3951
4341
  color: text2,
3952
4342
  margin: 0
3953
- }), children: plan.name }),
3954
- plan.description && /* @__PURE__ */ jsxRuntime.jsx("p", { ...el("planCard__description", {
4343
+ }), children: card2.name }),
4344
+ card2.description && /* @__PURE__ */ jsxRuntime.jsx("p", { ...el("planCard__description", {
3955
4345
  ...text.secondary,
3956
4346
  color: muted5,
3957
4347
  margin: "6px 0 0"
3958
- }), children: plan.description })
4348
+ }), children: card2.description })
3959
4349
  ] }),
3960
- /* @__PURE__ */ jsxRuntime.jsx("div", { ...el("planCard__price", { marginBottom: "1.5rem" }), children: isEnt ? /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...text.metric, color: text2 }, children: "Custom" }) : /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: "2px" }, children: [
3961
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...text.metric, color: text2 }, children: fmtPrice(plan.amount_cents, plan.amount_currency) }),
3962
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...text.meta, color: muted5, marginLeft: "4px" }, children: interval })
4350
+ /* @__PURE__ */ jsxRuntime.jsx("div", { ...el("planCard__price", { marginBottom: "1.5rem" }), children: isEnt ? /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...text.metric, color: text2 }, children: "Custom" }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
4351
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: "2px" }, children: [
4352
+ /* @__PURE__ */ jsxRuntime.jsx("span", { "data-testid": "pricing-price", style: { ...text.metric, color: text2 }, children: card2.price }),
4353
+ card2.pricePeriod && /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...text.meta, color: muted5, marginLeft: "4px" }, children: card2.pricePeriod })
4354
+ ] }),
4355
+ card2.billing && /* @__PURE__ */ jsxRuntime.jsx("p", { "data-testid": "pricing-billing", style: { ...text.meta, color: muted5, margin: "8px 0 0" }, children: card2.billing })
3963
4356
  ] }) }),
3964
- features && features.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("ul", { ...el("planCard__features", {
4357
+ features.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("ul", { ...el("planCard__features", {
3965
4358
  listStyle: "none",
3966
4359
  padding: 0,
3967
4360
  margin: "0 0 2rem",
@@ -3969,7 +4362,7 @@ function Card({
3969
4362
  flexDirection: "column",
3970
4363
  gap: "0.75rem",
3971
4364
  flex: 1
3972
- }), children: features.map((feat) => /* @__PURE__ */ jsxRuntime.jsxs("li", { ...el("planCard__feature", {
4365
+ }), children: features.map((feat, index) => /* @__PURE__ */ jsxRuntime.jsxs("li", { ...el("planCard__feature", {
3973
4366
  display: "flex",
3974
4367
  alignItems: "flex-start",
3975
4368
  gap: "10px",
@@ -3977,9 +4370,12 @@ function Card({
3977
4370
  color: text2
3978
4371
  }), children: [
3979
4372
  /* @__PURE__ */ jsxRuntime.jsx("span", { style: { color: accent, marginTop: "2px" }, children: /* @__PURE__ */ jsxRuntime.jsx(Check, {}) }),
3980
- /* @__PURE__ */ jsxRuntime.jsx("span", { children: feat })
3981
- ] }, feat)) }),
3982
- (!features || features.length === 0) && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { flex: 1 } }),
4373
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { display: "flex", flexDirection: "column" }, children: [
4374
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: feat.label }),
4375
+ feat.description && /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...text.meta, color: muted5, marginTop: "2px" }, children: feat.description })
4376
+ ] })
4377
+ ] }, `${index}:${feat.label}`)) }),
4378
+ features.length === 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { flex: 1 } }),
3983
4379
  cta
3984
4380
  ]
3985
4381
  }
@@ -3993,7 +4389,7 @@ function CTAButton({
3993
4389
  el
3994
4390
  }) {
3995
4391
  const [hover, setHover] = React2.useState(false);
3996
- 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);
4392
+ 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);
3997
4393
  return /* @__PURE__ */ jsxRuntime.jsx(
3998
4394
  "button",
3999
4395
  {
@@ -4018,9 +4414,9 @@ function Skeleton({ rootProps }) {
4018
4414
  gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
4019
4415
  gap: "1.5rem"
4020
4416
  }, children: [0, 1, 2].map((i) => /* @__PURE__ */ jsxRuntime.jsxs("div", { style: {
4021
- background: C.cardBg,
4417
+ background: T.card,
4022
4418
  border: border(),
4023
- borderRadius: C.radius,
4419
+ borderRadius: T.radius,
4024
4420
  padding: "2rem",
4025
4421
  height: "380px"
4026
4422
  }, children: [
@@ -4034,6 +4430,7 @@ function Skeleton({ rootProps }) {
4034
4430
  function PricingTable({
4035
4431
  currentPlanCode,
4036
4432
  plans: propPlans,
4433
+ families: propFamilies,
4037
4434
  features,
4038
4435
  sort,
4039
4436
  onSelect,
@@ -4042,36 +4439,40 @@ function PricingTable({
4042
4439
  onCheckoutResult,
4043
4440
  onCheckoutError,
4044
4441
  showToggle = true,
4442
+ defaultInterval,
4045
4443
  highlightPlan,
4046
4444
  highlightStyle = "outline",
4047
- highlightLabel = "Most Popular",
4445
+ highlightLabel,
4048
4446
  className,
4049
4447
  contactSalesPlans,
4050
4448
  enterpriseEmail,
4449
+ annualDiscountLabel,
4450
+ locale: localeProp,
4051
4451
  appearance
4052
4452
  }) {
4053
4453
  const { root, el, planCardLayout } = useAppearance(appearance);
4054
- const isEnterprise = (p) => contactSalesPlans?.includes(p.code) ?? false;
4454
+ const locale = resolvePricingLocale(localeProp);
4055
4455
  const billing = useOptionalBillingContext();
4056
4456
  const client = billing?.client;
4057
- const catalog = reactQuery.useQuery(
4457
+ const fetched = reactQuery.useQuery(
4058
4458
  { ...catalogQuery(client), enabled: !propPlans && Boolean(client) },
4059
4459
  billing?.queryClient ?? idleQueryClient
4060
4460
  );
4061
- const plans = propPlans ?? catalog.data?.plans ?? [];
4062
- const loading = !propPlans && Boolean(client) && catalog.isPending;
4063
- 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;
4461
+ const catalog = propPlans ? { plans: propPlans, families: Array.isArray(propFamilies) ? propFamilies : null } : { plans: fetched.data?.plans ?? [], families: fetched.data?.families ?? null };
4462
+ const loading = !propPlans && Boolean(client) && fetched.isPending;
4463
+ 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;
4064
4464
  const [currentPlan, setCurrentPlan] = React2.useState(currentPlanCode ?? null);
4065
- const [isAnnual, setIsAnnual] = React2.useState(false);
4465
+ const [selectedInterval, setSelectedInterval] = React2.useState(null);
4066
4466
  const [processing, setProcessing] = React2.useState(false);
4067
4467
  const [checkoutError, setCheckoutError] = React2.useState(null);
4068
4468
  React2.useEffect(() => {
4069
4469
  if (currentPlanCode !== void 0) setCurrentPlan(currentPlanCode);
4070
4470
  }, [currentPlanCode]);
4071
- async function selectPlan(plan) {
4471
+ const withFamilies = Array.isArray(catalog.families);
4472
+ async function selectPlan(plan, family) {
4072
4473
  setCheckoutError(null);
4073
4474
  if (onSelect) {
4074
- onSelect(plan);
4475
+ onSelect(plan, family);
4075
4476
  return;
4076
4477
  }
4077
4478
  try {
@@ -4097,37 +4498,57 @@ function PricingTable({
4097
4498
  onCheckoutError?.(error);
4098
4499
  }
4099
4500
  }
4100
- const ordered = sort ? [...plans].sort(sort) : [...sortPlansByPrice(plans.filter((p) => !isEnterprise(p))), ...plans.filter(isEnterprise)];
4101
- const hasAnnual = ordered.some((p) => p.interval === "yearly");
4102
- const current = plans.find((p) => p.code === currentPlan);
4103
- const filtered = showToggle && hasAnnual ? ordered.filter((p) => p.interval === (isAnnual ? "yearly" : "monthly") || isEnterprise(p)) : ordered;
4501
+ const available = getAvailableIntervals(catalog);
4502
+ const interval = selectedInterval !== null && available.includes(selectedInterval) ? selectedInterval : getDefaultInterval(catalog, defaultInterval);
4503
+ const switchVisible = showToggle && available.length > 1 && interval !== null;
4504
+ const cards = getPricingCards(catalog, {
4505
+ interval: withFamilies || switchVisible ? interval : null,
4506
+ locale,
4507
+ highlight: highlightPlan,
4508
+ badge: highlightLabel,
4509
+ features,
4510
+ contactSales: contactSalesPlans,
4511
+ sort
4512
+ });
4513
+ const current = catalog.plans.find((p) => p.code === currentPlan);
4514
+ const currentYearly = current ? yearlyMinor(current) : null;
4515
+ const isUpgrade = (plan) => {
4516
+ if (!current || current.amount_currency !== plan.amount_currency) return void 0;
4517
+ const yearly = yearlyMinor(plan);
4518
+ return currentYearly !== null && yearly !== null ? yearly > currentYearly : plan.amount_cents > current.amount_cents;
4519
+ };
4104
4520
  const rootProps = root("pricingTable", text.body, className);
4105
4521
  if (loading) return /* @__PURE__ */ jsxRuntime.jsx(Skeleton, { rootProps });
4106
4522
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { ...rootProps, children: [
4107
- showToggle && hasAnnual && /* @__PURE__ */ jsxRuntime.jsx(Toggle, { isAnnual, onChange: setIsAnnual, el }),
4523
+ switchVisible && interval !== null && /* @__PURE__ */ jsxRuntime.jsx(
4524
+ IntervalSwitch,
4525
+ {
4526
+ options: getPricingIntervalOptions(catalog),
4527
+ selected: interval,
4528
+ onChange: setSelectedInterval,
4529
+ annualDiscountLabel,
4530
+ el
4531
+ }
4532
+ ),
4108
4533
  processing && /* @__PURE__ */ jsxRuntime.jsx("p", { role: "status", children: "Payment is processing. Confirmation may take a little while." }),
4109
4534
  (checkoutError ?? loadError) && /* @__PURE__ */ jsxRuntime.jsx("p", { role: "alert", style: { color: T.dangerText }, children: checkoutError ?? loadError }),
4110
4535
  /* @__PURE__ */ jsxRuntime.jsx("div", { ...el("pricingTable__grid", {
4111
4536
  display: "grid",
4112
4537
  gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
4113
4538
  gap: "1.5rem"
4114
- }), children: filtered.map((plan, i) => /* @__PURE__ */ jsxRuntime.jsx(
4539
+ }), children: cards.map((card2) => /* @__PURE__ */ jsxRuntime.jsx(
4115
4540
  Card,
4116
4541
  {
4117
- plan,
4118
- features: featureLines(features, plan, i),
4119
- isCurrent: currentPlan === plan.code,
4120
- isUpgrade: current && current.amount_currency === plan.amount_currency ? plan.amount_cents > current.amount_cents : void 0,
4121
- isHighlighted: highlightPlan === plan.code,
4542
+ card: card2,
4543
+ isCurrent: currentPlan === card2.plan.code,
4544
+ isUpgrade: isUpgrade(card2.plan),
4122
4545
  filled: highlightStyle === "filled",
4123
- highlightLabel,
4124
- isEnt: isEnterprise(plan),
4125
4546
  enterpriseEmail,
4126
- onSelect: () => void selectPlan(plan),
4547
+ onSelect: () => void selectPlan(card2.plan, card2.family),
4127
4548
  el,
4128
4549
  panel: planCardLayout === "panel"
4129
4550
  },
4130
- plan.code
4551
+ card2.key
4131
4552
  )) }),
4132
4553
  /* @__PURE__ */ jsxRuntime.jsx(NozleBranding, {})
4133
4554
  ] });
@@ -5179,6 +5600,10 @@ exports.UsageMeter = UsageMeter;
5179
5600
  exports.UsageTrend = UsageTrend;
5180
5601
  exports.comparisonRows = comparisonRows;
5181
5602
  exports.currentPlanMeters = currentPlanMeters;
5603
+ exports.getAvailableIntervals = getAvailableIntervals;
5604
+ exports.getDefaultInterval = getDefaultInterval;
5605
+ exports.getPricingCards = getPricingCards;
5606
+ exports.getPricingIntervalOptions = getPricingIntervalOptions;
5182
5607
  exports.getUsageMeterColor = getUsageMeterColor;
5183
5608
  exports.planFeatures = planFeatures;
5184
5609
  exports.sortPlansByPrice = sortPlansByPrice;