@kerne/react 0.1.3 → 1.0.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/ui/index.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  useAccess,
7
7
  useAuth,
8
8
  useAuthConfig,
9
+ useCancelSubscription,
9
10
  useCheckout,
10
11
  useClient,
11
12
  useEntitlements,
@@ -16,7 +17,7 @@ import {
16
17
  useSubscription,
17
18
  useUser,
18
19
  useWaitlist
19
- } from "../chunk-F6NV76OR.js";
20
+ } from "../chunk-NFJ2S5VJ.js";
20
21
 
21
22
  // src/ui/AuthFlow.tsx
22
23
  import { useState as useState10 } from "react";
@@ -246,6 +247,7 @@ var CSS = `
246
247
 
247
248
  /* ---------- Stack ---------- */
248
249
  .kerne-stack { display: flex; flex-direction: column; gap: 16px; }
250
+ .kerne-inline-actions { display: flex; gap: 8px; flex-wrap: wrap; }
249
251
  .kerne-form { display: flex; flex-direction: column; gap: 12px; }
250
252
  .kerne-field { display: flex; flex-direction: column; gap: 6px; }
251
253
  .kerne-label { font-size: 14px; font-weight: 500; color: var(--kerne-fg); }
@@ -512,6 +514,126 @@ var CSS = `
512
514
  .kerne-meter-note { font-size: 12px; color: var(--kerne-fg-subtle); margin: 0; }
513
515
  @media (prefers-reduced-motion: reduce) { .kerne-meter-fill { transition: none; } }
514
516
 
517
+ /* ---------- Pricing table ---------- */
518
+ .kerne-pricing-skeleton { width: 100%; }
519
+
520
+ .kerne-pricing-switch-wrap { display: flex; justify-content: center; }
521
+ .kerne-pricing-switch {
522
+ position: relative;
523
+ display: inline-flex;
524
+ gap: 2px;
525
+ padding: 4px;
526
+ background: var(--kerne-panel);
527
+ border-radius: 999px;
528
+ }
529
+ .kerne-pricing-switch-thumb {
530
+ position: absolute;
531
+ top: 4px;
532
+ bottom: 4px;
533
+ left: 0;
534
+ border-radius: 999px;
535
+ background: var(--kerne-surface);
536
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
537
+ transition: transform 0.32s cubic-bezier(0.16, 1, 0.3, 1), width 0.32s cubic-bezier(0.16, 1, 0.3, 1);
538
+ }
539
+ .kerne-pricing-switch-option {
540
+ position: relative;
541
+ z-index: 1;
542
+ display: inline-flex;
543
+ align-items: center;
544
+ gap: 7px;
545
+ font: inherit;
546
+ font-size: 13.5px;
547
+ font-weight: 500;
548
+ white-space: nowrap;
549
+ color: var(--kerne-fg-muted);
550
+ background: none;
551
+ border: none;
552
+ border-radius: 999px;
553
+ padding: 7px 16px;
554
+ cursor: pointer;
555
+ transition: color 0.18s ease;
556
+ }
557
+ .kerne-pricing-switch-option[aria-pressed="true"] { color: var(--kerne-fg); }
558
+ .kerne-pricing-switch-option:focus-visible { outline: 2px solid var(--kerne-accent); outline-offset: 2px; }
559
+ .kerne-pricing-switch-save {
560
+ font-size: 10.5px;
561
+ font-weight: 600;
562
+ letter-spacing: 0.01em;
563
+ color: var(--kerne-success);
564
+ background: color-mix(in srgb, var(--kerne-success) 14%, transparent);
565
+ padding: 2px 6px;
566
+ border-radius: 999px;
567
+ }
568
+
569
+ .kerne-pricing-grid {
570
+ display: grid;
571
+ grid-template-columns: repeat(auto-fit, minmax(248px, 1fr));
572
+ gap: 20px;
573
+ align-items: stretch;
574
+ }
575
+
576
+ .kerne-pricing-card {
577
+ position: relative;
578
+ display: flex;
579
+ flex-direction: column;
580
+ gap: 24px;
581
+ height: 100%;
582
+ padding: 28px 26px;
583
+ background: var(--kerne-surface);
584
+ border: 1px solid var(--kerne-border);
585
+ border-radius: calc(var(--kerne-radius) + 8px);
586
+ }
587
+ .kerne-pricing-card[data-featured="true"] {
588
+ background: var(--kerne-surface-raised);
589
+ border-color: var(--kerne-fg);
590
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 24px 48px -28px rgba(0, 0, 0, 0.22);
591
+ }
592
+ .kerne-pricing-card[data-current="true"]:not([data-featured="true"]) { border-color: var(--kerne-border-strong); }
593
+
594
+ /* A quiet inline pill, not a loud badge escaping the card border - that
595
+ shouted-uppercase-chip-on-the-edge look is the single most template-y
596
+ tell a pricing page can have. Reads as confirmation of what the card's
597
+ own elevation already signals, never as the primary signal itself. */
598
+ .kerne-pricing-badge {
599
+ align-self: flex-start;
600
+ font-size: 11.5px;
601
+ font-weight: 600;
602
+ letter-spacing: 0.01em;
603
+ color: var(--kerne-fg);
604
+ background: var(--kerne-panel);
605
+ padding: 3px 9px;
606
+ border-radius: 999px;
607
+ }
608
+
609
+ .kerne-pricing-card-head { display: flex; flex-direction: column; gap: 8px; }
610
+ .kerne-pricing-card-name { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; }
611
+ .kerne-pricing-card-desc { margin: 0; font-size: 13px; line-height: 1.5; color: var(--kerne-fg-muted); }
612
+
613
+ .kerne-pricing-price { display: flex; flex-direction: column; gap: 3px; min-height: 52px; justify-content: center; }
614
+ .kerne-pricing-price-row { display: flex; align-items: baseline; gap: 5px; }
615
+ .kerne-pricing-amount {
616
+ font-size: 38px;
617
+ font-weight: 600;
618
+ letter-spacing: -0.025em;
619
+ line-height: 1;
620
+ font-variant-numeric: tabular-nums;
621
+ }
622
+ .kerne-pricing-interval { font-size: 14px; color: var(--kerne-fg-muted); }
623
+ .kerne-pricing-billed { margin: 0; font-size: 12px; color: var(--kerne-fg-subtle); }
624
+
625
+ .kerne-pricing-features { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 11px; flex: 1; }
626
+ .kerne-pricing-feature { display: flex; align-items: flex-start; gap: 9px; font-size: 13.5px; line-height: 1.4; color: var(--kerne-fg); }
627
+ .kerne-pricing-feature svg { flex-shrink: 0; margin-top: 2px; color: var(--kerne-fg-subtle); }
628
+ .kerne-pricing-feature-more { font-size: 12.5px; color: var(--kerne-fg-subtle); padding-left: 24px; }
629
+
630
+ .kerne-pricing-cta { margin-top: auto; }
631
+ .kerne-pricing-cta .kerne-button { width: 100%; }
632
+
633
+ @media (prefers-reduced-motion: reduce) {
634
+ .kerne-pricing-switch-thumb { transition: none; }
635
+ }
636
+
515
637
  /* ---------- Sections (profile, settings) ---------- */
516
638
  .kerne-section-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
517
639
  .kerne-section-title { font-size: 15px; font-weight: 600; margin: 0; }
@@ -2677,15 +2799,45 @@ function formatInterval(interval, labels) {
2677
2799
  return interval;
2678
2800
  }
2679
2801
  }
2802
+ var ENCODED_INTERVAL_UNIT = { D: 12 / 365, M: 1, Y: 12 };
2803
+ function approxMonths(value) {
2804
+ const match = ENCODED_INTERVAL.exec(value);
2805
+ if (!match) return 0;
2806
+ return parseInt(match[1], 10) * ENCODED_INTERVAL_UNIT[match[2]];
2807
+ }
2808
+ function resolvePriceDisplay(price, displayInterval) {
2809
+ if (!displayInterval || displayInterval === price.interval) {
2810
+ return {
2811
+ billedAmount: price.amount,
2812
+ billedInterval: price.interval,
2813
+ displayAmount: price.amount,
2814
+ displayInterval: price.interval
2815
+ };
2816
+ }
2817
+ const ratio = approxMonths(displayInterval) / approxMonths(price.interval);
2818
+ return {
2819
+ billedAmount: price.amount,
2820
+ billedInterval: price.interval,
2821
+ displayAmount: Math.round(price.amount * ratio),
2822
+ displayInterval
2823
+ };
2824
+ }
2680
2825
 
2681
2826
  // src/ui/UsageMeters.tsx
2682
- import { jsx as jsx15, jsxs as jsxs14 } from "react/jsx-runtime";
2683
- function levelFor(ratio, overageAllowed) {
2684
- if (overageAllowed) return ratio >= 1 ? "warning" : "normal";
2827
+ import { Fragment as Fragment8, jsx as jsx15, jsxs as jsxs14 } from "react/jsx-runtime";
2828
+ function isSoftOverage(overage) {
2829
+ return overage === "ALLOW" || overage === "BILL";
2830
+ }
2831
+ function levelFor(ratio, softOverage) {
2832
+ if (softOverage) return ratio >= 1 ? "warning" : "normal";
2685
2833
  if (ratio >= 0.9) return "critical";
2686
2834
  if (ratio >= 0.75) return "warning";
2687
2835
  return "normal";
2688
2836
  }
2837
+ function quotaRatio(used, limit) {
2838
+ if (limit <= 0) return used > 0 ? 1 : 0;
2839
+ return Math.min(used / limit, 1);
2840
+ }
2689
2841
  function UsageMeters({
2690
2842
  title,
2691
2843
  featureKeys,
@@ -2739,10 +2891,13 @@ function UsageMeters({
2739
2891
  return shell(
2740
2892
  /* @__PURE__ */ jsx15("div", { className: "kerne-meters", children: rows.map((e) => {
2741
2893
  const unlimited = e.limit === null || e.limit === void 0;
2742
- const overageAllowed = e.overage_behavior === "ALLOW";
2743
- const ratio = unlimited ? 0 : Math.min(e.used / e.limit, 1);
2744
- const level = unlimited ? "normal" : levelFor(e.used / e.limit, overageAllowed);
2894
+ const softOverage = isSoftOverage(e.overage_behavior);
2895
+ const billed = e.overage_behavior === "BILL";
2896
+ const ratio = unlimited ? 0 : quotaRatio(e.used, e.limit);
2897
+ const level = unlimited ? "normal" : levelFor(ratio, softOverage);
2745
2898
  const over = !unlimited && e.used > e.limit;
2899
+ const standing = billed ? subscription?.entitlements.find((se) => se.feature_key === e.feature_key) : void 0;
2900
+ const capReached = billed && (standing?.cap_reached ?? false);
2746
2901
  return /* @__PURE__ */ jsxs14("div", { className: "kerne-meter", "data-level": level, children: [
2747
2902
  /* @__PURE__ */ jsxs14("div", { className: "kerne-meter-head", children: [
2748
2903
  /* @__PURE__ */ jsx15("span", { className: "kerne-meter-name", children: nameFor(e.feature_key) }),
@@ -2760,9 +2915,18 @@ function UsageMeters({
2760
2915
  children: /* @__PURE__ */ jsx15("div", { className: "kerne-meter-fill", style: { width: `${ratio * 100}%` } })
2761
2916
  }
2762
2917
  ) : null,
2763
- over && overageAllowed ? /* @__PURE__ */ jsx15("p", { className: "kerne-meter-note", children: fill(l10n.usage.overage, {
2764
- count: formatNumber(e.used - e.limit)
2765
- }) }) : over ? /* @__PURE__ */ jsx15("p", { className: "kerne-meter-note", children: l10n.usage.limitReached }) : null
2918
+ over && softOverage ? /* @__PURE__ */ jsxs14(Fragment8, { children: [
2919
+ /* @__PURE__ */ jsx15("p", { className: "kerne-meter-note", children: fill(
2920
+ // capReached inverts overageBilled's meaning (billing stopped,
2921
+ // access didn't) - a variant string would say the opposite of
2922
+ // the truth for the one plan that just stopped costing money.
2923
+ capReached ? l10n.usage.capReached : billed ? l10n.usage.overageBilled : l10n.usage.overage,
2924
+ { count: formatNumber(e.used - e.limit) }
2925
+ ) }),
2926
+ billed && standing?.billed_amount != null && standing.currency ? /* @__PURE__ */ jsx15("p", { className: "kerne-meter-note kerne-meter-cost", children: fill(l10n.usage.billedAmount, {
2927
+ amount: formatMoney(standing.billed_amount, standing.currency)
2928
+ }) }) : null
2929
+ ] }) : over ? /* @__PURE__ */ jsx15("p", { className: "kerne-meter-note", children: l10n.usage.limitReached }) : null
2766
2930
  ] }, e.feature_key);
2767
2931
  }) })
2768
2932
  );
@@ -2770,7 +2934,7 @@ function UsageMeters({
2770
2934
 
2771
2935
  // src/ui/SubscriptionCard.tsx
2772
2936
  import { useState as useState16 } from "react";
2773
- import { Fragment as Fragment8, jsx as jsx16, jsxs as jsxs15 } from "react/jsx-runtime";
2937
+ import { Fragment as Fragment9, jsx as jsx16, jsxs as jsxs15 } from "react/jsx-runtime";
2774
2938
  var STATUS_TONES = {
2775
2939
  ACTIVE: "success",
2776
2940
  TRIALING: "success",
@@ -2786,11 +2950,13 @@ function SubscriptionCard({
2786
2950
  upgradeUrl,
2787
2951
  onUpgradeClick,
2788
2952
  hidePortalButton = false,
2953
+ hideCancelButton = false,
2789
2954
  appearance,
2790
2955
  className
2791
2956
  }) {
2792
- const { subscription, isLoading, error } = useSubscription(productSlug);
2957
+ const { subscription, isLoading, error, refetch } = useSubscription(productSlug);
2793
2958
  const { openPortal } = usePortal();
2959
+ const { cancelSubscription, isCanceling } = useCancelSubscription();
2794
2960
  const l10n = useLocalization();
2795
2961
  const showError = useErrorResolver();
2796
2962
  const heading = title === null ? null : title ?? l10n.billing.plan;
@@ -2804,6 +2970,8 @@ function SubscriptionCard({
2804
2970
  };
2805
2971
  const [opening, setOpening] = useState16(false);
2806
2972
  const [portalError, setPortalError] = useState16(null);
2973
+ const [confirmingCancel, setConfirmingCancel] = useState16(false);
2974
+ const [cancelError, setCancelError] = useState16(null);
2807
2975
  const handlePortal = async () => {
2808
2976
  setPortalError(null);
2809
2977
  setOpening(true);
@@ -2814,6 +2982,17 @@ function SubscriptionCard({
2814
2982
  setOpening(false);
2815
2983
  }
2816
2984
  };
2985
+ const handleCancel = async () => {
2986
+ if (!subscription) return;
2987
+ setCancelError(null);
2988
+ try {
2989
+ await cancelSubscription(subscription.id, false);
2990
+ setConfirmingCancel(false);
2991
+ refetch();
2992
+ } catch (e) {
2993
+ setCancelError(showError(e));
2994
+ }
2995
+ };
2817
2996
  const shell = (children) => /* @__PURE__ */ jsx16(Shell, { appearance, className, children: /* @__PURE__ */ jsxs15("div", { className: "kerne-stack", children: [
2818
2997
  heading ? /* @__PURE__ */ jsx16("h2", { className: "kerne-section-title", children: heading }) : null,
2819
2998
  children
@@ -2831,7 +3010,7 @@ function SubscriptionCard({
2831
3010
  }
2832
3011
  if (!subscription) {
2833
3012
  return shell(
2834
- /* @__PURE__ */ jsxs15(Fragment8, { children: [
3013
+ /* @__PURE__ */ jsxs15(Fragment9, { children: [
2835
3014
  /* @__PURE__ */ jsx16("p", { className: "kerne-subsection-desc", children: l10n.billing.freePlan }),
2836
3015
  upgradeUrl || onUpgradeClick ? /* @__PURE__ */ jsx16(
2837
3016
  Button,
@@ -2850,7 +3029,7 @@ function SubscriptionCard({
2850
3029
  const renews = formatDate(current_period_end);
2851
3030
  const trialEnds = formatDate(trial_end);
2852
3031
  return shell(
2853
- /* @__PURE__ */ jsxs15(Fragment8, { children: [
3032
+ /* @__PURE__ */ jsxs15(Fragment9, { children: [
2854
3033
  portalError ? /* @__PURE__ */ jsx16(Panel, { variant: "danger", children: portalError }) : null,
2855
3034
  /* @__PURE__ */ jsxs15("dl", { className: "kerne-rows", children: [
2856
3035
  /* @__PURE__ */ jsxs15("div", { className: "kerne-row", children: [
@@ -2879,13 +3058,33 @@ function SubscriptionCard({
2879
3058
  ] }) : null
2880
3059
  ] }),
2881
3060
  canceled_at ? /* @__PURE__ */ jsx16(Panel, { children: l10n.billing.cancelNotice }) : null,
2882
- !hidePortalButton ? /* @__PURE__ */ jsx16(Button, { variant: "outline", type: "button", onClick: handlePortal, loading: opening, children: opening ? l10n.billing.opening : l10n.billing.managePortal }) : null
3061
+ cancelError ? /* @__PURE__ */ jsx16(Panel, { variant: "danger", children: cancelError }) : null,
3062
+ confirmingCancel ? /* @__PURE__ */ jsxs15(Panel, { children: [
3063
+ /* @__PURE__ */ jsx16("p", { children: l10n.billing.cancelConfirmTitle }),
3064
+ /* @__PURE__ */ jsx16("p", { className: "kerne-subsection-desc", children: l10n.billing.cancelConfirmBody }),
3065
+ /* @__PURE__ */ jsxs15("div", { className: "kerne-inline-actions", children: [
3066
+ /* @__PURE__ */ jsx16(
3067
+ Button,
3068
+ {
3069
+ variant: "outline",
3070
+ type: "button",
3071
+ onClick: () => setConfirmingCancel(false),
3072
+ disabled: isCanceling,
3073
+ children: l10n.billing.cancelConfirmDismiss
3074
+ }
3075
+ ),
3076
+ /* @__PURE__ */ jsx16(Button, { type: "button", onClick: handleCancel, loading: isCanceling, children: isCanceling ? l10n.billing.canceling : l10n.billing.cancelConfirmButton })
3077
+ ] })
3078
+ ] }) : /* @__PURE__ */ jsxs15("div", { className: "kerne-inline-actions", children: [
3079
+ !hidePortalButton ? /* @__PURE__ */ jsx16(Button, { variant: "outline", type: "button", onClick: handlePortal, loading: opening, children: opening ? l10n.billing.opening : l10n.billing.managePortal }) : null,
3080
+ !hideCancelButton && !canceled_at && status !== "CANCELED" ? /* @__PURE__ */ jsx16(Button, { variant: "outline", type: "button", onClick: () => setConfirmingCancel(true), children: l10n.billing.cancelSubscription }) : null
3081
+ ] })
2883
3082
  ] })
2884
3083
  );
2885
3084
  }
2886
3085
 
2887
3086
  // src/ui/profile/BillingSection.tsx
2888
- import { Fragment as Fragment9, jsx as jsx17, jsxs as jsxs16 } from "react/jsx-runtime";
3087
+ import { Fragment as Fragment10, jsx as jsx17, jsxs as jsxs16 } from "react/jsx-runtime";
2889
3088
  function intervalsOf(plans) {
2890
3089
  const seen = [];
2891
3090
  for (const plan of plans) {
@@ -2929,7 +3128,7 @@ function BillingSection({
2929
3128
  setBusy(null);
2930
3129
  }
2931
3130
  };
2932
- return /* @__PURE__ */ jsxs16(Fragment9, { children: [
3131
+ return /* @__PURE__ */ jsxs16(Fragment10, { children: [
2933
3132
  error ? /* @__PURE__ */ jsx17(Panel, { variant: "danger", children: error }) : null,
2934
3133
  /* @__PURE__ */ jsx17(Subsection, { title: l10n.billing.currentPlan, children: /* @__PURE__ */ jsx17(
2935
3134
  SubscriptionCard,
@@ -2947,7 +3146,7 @@ function BillingSection({
2947
3146
  style: { height: 64 },
2948
3147
  "aria-label": l10n.billing.loadingPlans
2949
3148
  }
2950
- ) : plans.length === 0 ? /* @__PURE__ */ jsx17("p", { className: "kerne-subsection-desc", children: l10n.billing.noPlans }) : /* @__PURE__ */ jsxs16(Fragment9, { children: [
3149
+ ) : plans.length === 0 ? /* @__PURE__ */ jsx17("p", { className: "kerne-subsection-desc", children: l10n.billing.noPlans }) : /* @__PURE__ */ jsxs16(Fragment10, { children: [
2951
3150
  intervals.length > 1 ? /* @__PURE__ */ jsx17(
2952
3151
  "div",
2953
3152
  {
@@ -3004,7 +3203,7 @@ function BillingSection({
3004
3203
  }
3005
3204
 
3006
3205
  // src/ui/UserProfile.tsx
3007
- import { Fragment as Fragment10, jsx as jsx18, jsxs as jsxs17 } from "react/jsx-runtime";
3206
+ import { Fragment as Fragment11, jsx as jsx18, jsxs as jsxs17 } from "react/jsx-runtime";
3008
3207
  function UserProfile({
3009
3208
  tabs = ["profile", "security", "billing"],
3010
3209
  extraTabs = [],
@@ -3099,7 +3298,7 @@ function UserProfile({
3099
3298
  tab.id
3100
3299
  )) }),
3101
3300
  /* @__PURE__ */ jsxs17("div", { className: "kerne-pane", children: [
3102
- active ? /* @__PURE__ */ jsxs17(Fragment10, { children: [
3301
+ active ? /* @__PURE__ */ jsxs17(Fragment11, { children: [
3103
3302
  /* @__PURE__ */ jsx18("h2", { className: "kerne-pane-title", children: active.title }),
3104
3303
  active.description ? /* @__PURE__ */ jsx18("p", { className: "kerne-pane-desc", children: active.description }) : null
3105
3304
  ] }) : null,
@@ -3108,9 +3307,317 @@ function UserProfile({
3108
3307
  ] }) });
3109
3308
  }
3110
3309
 
3310
+ // src/ui/PricingTable.tsx
3311
+ import { createContext as createContext2, useContext as useContext2, useEffect as useEffect7, useMemo as useMemo2, useRef as useRef4, useState as useState19 } from "react";
3312
+ import { Fragment as Fragment12, jsx as jsx19, jsxs as jsxs18 } from "react/jsx-runtime";
3313
+ function formatEntitlement(e, unlimitedLabel) {
3314
+ if (e.feature_type === "BOOLEAN") return e.feature_name;
3315
+ if (e.feature_type === "STATIC")
3316
+ return e.value ? `${e.feature_name}: ${e.value}` : e.feature_name;
3317
+ if (e.limit === null) return e.feature_name;
3318
+ if (e.limit === -1) return `${unlimitedLabel} ${e.feature_name.toLowerCase()}`;
3319
+ return `${formatNumber(e.limit)} ${e.feature_name.toLowerCase()}`;
3320
+ }
3321
+ function collectLabels(plans) {
3322
+ const bySlug = /* @__PURE__ */ new Map();
3323
+ for (const plan of plans) {
3324
+ for (const price of plan.prices) {
3325
+ if (!bySlug.has(price.label.slug)) bySlug.set(price.label.slug, price.label);
3326
+ }
3327
+ }
3328
+ return Array.from(bySlug.values()).sort(
3329
+ (a, b) => a.sort_order - b.sort_order || a.name.localeCompare(b.name)
3330
+ );
3331
+ }
3332
+ var ENCODED_INTERVAL_MONTHS = { D: 12 / 365, M: 1, Y: 12 };
3333
+ function approxMonths2(value) {
3334
+ const match = /^([1-9]\d*)([DMY])$/.exec(value);
3335
+ if (!match) return 0;
3336
+ return parseInt(match[1], 10) * ENCODED_INTERVAL_MONTHS[match[2]];
3337
+ }
3338
+ function labelSavings(plans, labels) {
3339
+ if (labels.length < 2) return null;
3340
+ const priceFor = (labelSlug) => {
3341
+ for (const plan of plans) {
3342
+ const price = plan.prices.find((p) => p.label.slug === labelSlug);
3343
+ if (price) return price;
3344
+ }
3345
+ return void 0;
3346
+ };
3347
+ const sorted = [...labels].sort(
3348
+ (a, b) => approxMonths2(priceFor(a.slug)?.interval ?? "1M") - approxMonths2(priceFor(b.slug)?.interval ?? "1M")
3349
+ );
3350
+ const shortest = sorted[0];
3351
+ const longest = sorted[sorted.length - 1];
3352
+ if (shortest.slug === longest.slug) return null;
3353
+ for (const plan of plans) {
3354
+ const shortPrice = plan.prices.find((p) => p.label.slug === shortest.slug);
3355
+ const longPrice = plan.prices.find((p) => p.label.slug === longest.slug);
3356
+ if (!shortPrice?.amount || !longPrice) continue;
3357
+ const shortMonths = approxMonths2(shortPrice.interval);
3358
+ const longMonths = approxMonths2(longPrice.interval);
3359
+ if (!shortMonths || !longMonths) continue;
3360
+ const savings = 1 - longPrice.amount / longMonths / (shortPrice.amount / shortMonths);
3361
+ if (savings > 0.01) return { labelSlug: longest.slug, percent: Math.round(savings * 100) };
3362
+ }
3363
+ return null;
3364
+ }
3365
+ var PricingTableContext = createContext2(null);
3366
+ function usePricingTable() {
3367
+ const ctx = useContext2(PricingTableContext);
3368
+ if (!ctx) {
3369
+ throw new Error("usePricingTable() must be called inside <PricingTable>.");
3370
+ }
3371
+ return ctx;
3372
+ }
3373
+ function PricingTable({
3374
+ product,
3375
+ defaultLabel,
3376
+ checkoutSuccessUrl,
3377
+ checkoutCancelUrl,
3378
+ onSelectPlan,
3379
+ appearance,
3380
+ className,
3381
+ children
3382
+ }) {
3383
+ const { plans, isLoading, error } = usePlans(product);
3384
+ const { subscription } = useSubscription(product);
3385
+ const { openCheckout } = useCheckout();
3386
+ const l10n = useLocalization();
3387
+ const showError = useErrorResolver();
3388
+ const [selectedLabelSlug, setSelectedLabelSlug] = useState19(defaultLabel ?? null);
3389
+ const [busyPriceId, setBusyPriceId] = useState19(null);
3390
+ const [checkoutError, setCheckoutError] = useState19(null);
3391
+ const labels = useMemo2(() => collectLabels(plans), [plans]);
3392
+ const savings = useMemo2(() => labelSavings(plans, labels), [plans, labels]);
3393
+ useEffect7(() => {
3394
+ if (labels.length === 0) return;
3395
+ if (selectedLabelSlug && labels.some((l) => l.slug === selectedLabelSlug)) return;
3396
+ setSelectedLabelSlug(labels[0].slug);
3397
+ }, [labels]);
3398
+ const handleSelectPlan = async (plan) => {
3399
+ const price = plan.prices.find((p) => p.label.slug === selectedLabelSlug) ?? plan.prices[0];
3400
+ if (!price) return;
3401
+ if (onSelectPlan) {
3402
+ onSelectPlan(plan, price);
3403
+ return;
3404
+ }
3405
+ setCheckoutError(null);
3406
+ setBusyPriceId(price.id);
3407
+ try {
3408
+ await openCheckout(price.id, {
3409
+ successUrl: checkoutSuccessUrl,
3410
+ cancelUrl: checkoutCancelUrl
3411
+ });
3412
+ } catch (e) {
3413
+ setCheckoutError(showError(e));
3414
+ setBusyPriceId(null);
3415
+ }
3416
+ };
3417
+ const ctx = {
3418
+ plans,
3419
+ labels,
3420
+ selectedLabelSlug,
3421
+ setSelectedLabelSlug,
3422
+ savings,
3423
+ currentPlanId: subscription?.plan?.id ?? null,
3424
+ busyPriceId,
3425
+ onSelectPlan: handleSelectPlan
3426
+ };
3427
+ return /* @__PURE__ */ jsx19(Shell, { appearance, className, size: "wide", children: isLoading ? /* @__PURE__ */ jsx19("div", { className: "kerne-pricing-skeleton", "aria-busy": "true", "aria-label": l10n.pricing.loading, children: /* @__PURE__ */ jsx19("div", { className: "kerne-skeleton", style: { height: 320 } }) }) : error ? /* @__PURE__ */ jsx19(Panel, { variant: "danger", children: l10n.pricing.loadFailed }) : /* @__PURE__ */ jsxs18(PricingTableContext.Provider, { value: ctx, children: [
3428
+ checkoutError ? /* @__PURE__ */ jsx19(Panel, { variant: "danger", children: checkoutError }) : null,
3429
+ children
3430
+ ] }) });
3431
+ }
3432
+ function LabelSwitch() {
3433
+ const { labels, selectedLabelSlug, setSelectedLabelSlug, savings } = usePricingTable();
3434
+ const l10n = useLocalization();
3435
+ const containerRef = useRef4(null);
3436
+ const buttonRefs = useRef4(/* @__PURE__ */ new Map());
3437
+ const [thumb, setThumb] = useState19(null);
3438
+ useEffect7(() => {
3439
+ const measure = () => {
3440
+ const container = containerRef.current;
3441
+ const button = selectedLabelSlug ? buttonRefs.current.get(selectedLabelSlug) : null;
3442
+ if (!container || !button) return;
3443
+ const containerBox = container.getBoundingClientRect();
3444
+ const buttonBox = button.getBoundingClientRect();
3445
+ setThumb({ left: buttonBox.left - containerBox.left, width: buttonBox.width });
3446
+ };
3447
+ measure();
3448
+ window.addEventListener("resize", measure);
3449
+ return () => window.removeEventListener("resize", measure);
3450
+ }, [selectedLabelSlug, labels]);
3451
+ if (labels.length < 2) return null;
3452
+ return /* @__PURE__ */ jsx19("div", { className: "kerne-pricing-switch-wrap", children: /* @__PURE__ */ jsxs18(
3453
+ "div",
3454
+ {
3455
+ ref: containerRef,
3456
+ className: "kerne-pricing-switch",
3457
+ role: "group",
3458
+ "aria-label": l10n.billing.intervalGroupLabel,
3459
+ children: [
3460
+ thumb ? /* @__PURE__ */ jsx19(
3461
+ "span",
3462
+ {
3463
+ className: "kerne-pricing-switch-thumb",
3464
+ style: { transform: `translateX(${thumb.left}px)`, width: `${thumb.width}px` },
3465
+ "aria-hidden": "true"
3466
+ }
3467
+ ) : null,
3468
+ labels.map((label) => /* @__PURE__ */ jsxs18(
3469
+ "button",
3470
+ {
3471
+ ref: (el) => {
3472
+ if (el) buttonRefs.current.set(label.slug, el);
3473
+ else buttonRefs.current.delete(label.slug);
3474
+ },
3475
+ type: "button",
3476
+ className: "kerne-pricing-switch-option",
3477
+ "aria-pressed": label.slug === selectedLabelSlug,
3478
+ onClick: () => setSelectedLabelSlug(label.slug),
3479
+ children: [
3480
+ label.name,
3481
+ savings && savings.labelSlug === label.slug ? /* @__PURE__ */ jsx19("span", { className: "kerne-pricing-switch-save", children: fill(l10n.pricing.save, { percent: savings.percent }) }) : null
3482
+ ]
3483
+ },
3484
+ label.slug
3485
+ ))
3486
+ ]
3487
+ }
3488
+ ) });
3489
+ }
3490
+ function useAnimatedAmount(target, durationMs = 420) {
3491
+ const [displayed, setDisplayed] = useState19(target);
3492
+ const displayedRef = useRef4(target);
3493
+ const frameRef = useRef4();
3494
+ useEffect7(() => {
3495
+ if (displayedRef.current === target) return;
3496
+ const reduceMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
3497
+ if (reduceMotion) {
3498
+ displayedRef.current = target;
3499
+ setDisplayed(target);
3500
+ return;
3501
+ }
3502
+ const from = displayedRef.current;
3503
+ const start = performance.now();
3504
+ const tick = (now) => {
3505
+ const t = Math.min((now - start) / durationMs, 1);
3506
+ const eased = 1 - Math.pow(1 - t, 3);
3507
+ const value = Math.round(from + (target - from) * eased);
3508
+ displayedRef.current = value;
3509
+ setDisplayed(value);
3510
+ if (t < 1) frameRef.current = requestAnimationFrame(tick);
3511
+ };
3512
+ frameRef.current = requestAnimationFrame(tick);
3513
+ return () => {
3514
+ if (frameRef.current) cancelAnimationFrame(frameRef.current);
3515
+ };
3516
+ }, [target, durationMs]);
3517
+ return displayed;
3518
+ }
3519
+ function Plan({ slug, featured, badge, maxFeatures }) {
3520
+ const { plans, selectedLabelSlug, currentPlanId, busyPriceId, onSelectPlan } = usePricingTable();
3521
+ const l10n = useLocalization();
3522
+ const plan = plans.find((p) => p.slug === slug);
3523
+ useEffect7(() => {
3524
+ if (!plan && process.env.NODE_ENV === "development") {
3525
+ console.warn(
3526
+ `[Kerne] <PricingTable.Plan slug="${slug}"> - no plan with this slug on the loaded product.`
3527
+ );
3528
+ }
3529
+ }, [plan, slug]);
3530
+ const price = plan?.prices.find((p) => p.label.slug === selectedLabelSlug) ?? plan?.prices[0];
3531
+ const resolved = price ? resolvePriceDisplay(price, price.label.display_interval) : null;
3532
+ const animatedAmount = useAnimatedAmount(resolved?.displayAmount ?? 0);
3533
+ if (!plan) return null;
3534
+ const isCurrent = plan.id === currentPlanId;
3535
+ const isBusy = !!price && busyPriceId === price.id;
3536
+ const shownEntitlements = maxFeatures ? plan.entitlements.slice(0, maxFeatures) : plan.entitlements;
3537
+ const remaining = plan.entitlements.length - shownEntitlements.length;
3538
+ const billedCaption = resolved && resolved.displayInterval !== resolved.billedInterval ? fill(l10n.pricing.billedAt, {
3539
+ amount: formatMoney(resolved.billedAmount, price.currency),
3540
+ interval: formatInterval(resolved.billedInterval, l10n.billing)
3541
+ }) : null;
3542
+ return /* @__PURE__ */ jsxs18(
3543
+ "div",
3544
+ {
3545
+ className: "kerne-pricing-card",
3546
+ "data-featured": featured ? "true" : void 0,
3547
+ "data-current": isCurrent ? "true" : void 0,
3548
+ children: [
3549
+ /* @__PURE__ */ jsxs18("div", { className: "kerne-pricing-card-head", children: [
3550
+ badge ? /* @__PURE__ */ jsx19("span", { className: "kerne-pricing-badge", children: badge }) : null,
3551
+ /* @__PURE__ */ jsx19("span", { className: "kerne-pricing-card-name", children: plan.name }),
3552
+ plan.description ? /* @__PURE__ */ jsx19("p", { className: "kerne-pricing-card-desc", children: plan.description }) : null
3553
+ ] }),
3554
+ /* @__PURE__ */ jsx19("div", { className: "kerne-pricing-price", children: resolved ? /* @__PURE__ */ jsxs18(Fragment12, { children: [
3555
+ /* @__PURE__ */ jsxs18("div", { className: "kerne-pricing-price-row", children: [
3556
+ /* @__PURE__ */ jsx19("span", { className: "kerne-pricing-amount", children: formatMoney(animatedAmount, price.currency) }),
3557
+ /* @__PURE__ */ jsxs18("span", { className: "kerne-pricing-interval", children: [
3558
+ "/ ",
3559
+ formatInterval(resolved.displayInterval, l10n.billing)
3560
+ ] })
3561
+ ] }),
3562
+ billedCaption ? /* @__PURE__ */ jsx19("p", { className: "kerne-pricing-billed", children: billedCaption }) : null
3563
+ ] }) : /* @__PURE__ */ jsx19("p", { className: "kerne-pricing-billed", children: l10n.billing.notOnInterval }) }),
3564
+ shownEntitlements.length > 0 ? /* @__PURE__ */ jsxs18("ul", { className: "kerne-pricing-features", children: [
3565
+ shownEntitlements.map((e) => /* @__PURE__ */ jsxs18("li", { className: "kerne-pricing-feature", children: [
3566
+ /* @__PURE__ */ jsx19(CheckIcon2, {}),
3567
+ /* @__PURE__ */ jsx19("span", { children: formatEntitlement(e, l10n.pricing.unlimited) })
3568
+ ] }, e.feature_key)),
3569
+ remaining > 0 ? /* @__PURE__ */ jsx19("li", { className: "kerne-pricing-feature-more", children: fill(l10n.pricing.moreFeatures, { count: remaining }) }) : null
3570
+ ] }) : null,
3571
+ /* @__PURE__ */ jsx19("div", { className: "kerne-pricing-cta", children: isCurrent ? /* @__PURE__ */ jsx19(Button, { variant: "outline", type: "button", disabled: true, children: l10n.billing.current }) : /* @__PURE__ */ jsx19(
3572
+ Button,
3573
+ {
3574
+ variant: featured ? "primary" : "outline",
3575
+ type: "button",
3576
+ disabled: !price || busyPriceId !== null && !isBusy,
3577
+ loading: isBusy,
3578
+ onClick: () => onSelectPlan(plan),
3579
+ children: currentPlanId ? l10n.billing.switchTo : l10n.pricing.getStarted
3580
+ }
3581
+ ) })
3582
+ ]
3583
+ }
3584
+ );
3585
+ }
3586
+ function CheckIcon2() {
3587
+ return /* @__PURE__ */ jsx19(
3588
+ "svg",
3589
+ {
3590
+ width: "15",
3591
+ height: "15",
3592
+ viewBox: "0 0 24 24",
3593
+ fill: "none",
3594
+ stroke: "currentColor",
3595
+ strokeWidth: "2",
3596
+ "aria-hidden": "true",
3597
+ children: /* @__PURE__ */ jsx19("path", { d: "m5 13 4 4L19 7", strokeLinecap: "round", strokeLinejoin: "round" })
3598
+ }
3599
+ );
3600
+ }
3601
+ function Plans({ featuredSlug, featuredBadge, maxFeatures }) {
3602
+ const { plans } = usePricingTable();
3603
+ return /* @__PURE__ */ jsx19("div", { className: "kerne-pricing-grid", children: plans.map((plan) => /* @__PURE__ */ jsx19(
3604
+ Plan,
3605
+ {
3606
+ slug: plan.slug,
3607
+ featured: plan.slug === featuredSlug,
3608
+ badge: plan.slug === featuredSlug ? featuredBadge : void 0,
3609
+ maxFeatures
3610
+ },
3611
+ plan.id
3612
+ )) });
3613
+ }
3614
+ PricingTable.LabelSwitch = LabelSwitch;
3615
+ PricingTable.Plan = Plan;
3616
+ PricingTable.Plans = Plans;
3617
+
3111
3618
  // src/ui/CheckoutResult.tsx
3112
- import { useEffect as useEffect7, useRef as useRef4, useState as useState19 } from "react";
3113
- import { jsx as jsx19, jsxs as jsxs18 } from "react/jsx-runtime";
3619
+ import { useEffect as useEffect8, useRef as useRef5, useState as useState20 } from "react";
3620
+ import { jsx as jsx20, jsxs as jsxs19 } from "react/jsx-runtime";
3114
3621
  var POLL_INTERVAL_MS = 1500;
3115
3622
  function CheckoutResult({
3116
3623
  outcome = "success",
@@ -3128,9 +3635,9 @@ function CheckoutResult({
3128
3635
  const client = useClient();
3129
3636
  const { config } = useAuthConfig();
3130
3637
  const l10n = useLocalization();
3131
- const [state, setState] = useState19("checking");
3132
- const confirmed = useRef4(false);
3133
- useEffect7(() => {
3638
+ const [state, setState] = useState20("checking");
3639
+ const confirmed = useRef5(false);
3640
+ useEffect8(() => {
3134
3641
  if (outcome !== "success") return;
3135
3642
  let stopped = false;
3136
3643
  const startedAt = Date.now();
@@ -3161,20 +3668,20 @@ function CheckoutResult({
3161
3668
  const go = (url, handler) => handler ?? (() => {
3162
3669
  if (url) window.location.href = url;
3163
3670
  });
3164
- const continueAction = continueUrl || onContinue ? /* @__PURE__ */ jsx19(Button, { type: "button", onClick: go(continueUrl, onContinue), children: l10n.common.continue }) : void 0;
3165
- const shell = (children) => /* @__PURE__ */ jsxs18(Shell, { appearance, className, children: [
3166
- logo ? /* @__PURE__ */ jsx19("div", { className: "kerne-masthead", children: logo }) : null,
3671
+ const continueAction = continueUrl || onContinue ? /* @__PURE__ */ jsx20(Button, { type: "button", onClick: go(continueUrl, onContinue), children: l10n.common.continue }) : void 0;
3672
+ const shell = (children) => /* @__PURE__ */ jsxs19(Shell, { appearance, className, children: [
3673
+ logo ? /* @__PURE__ */ jsx20("div", { className: "kerne-masthead", children: logo }) : null,
3167
3674
  children,
3168
- /* @__PURE__ */ jsx19(KerneBranding, { show: config?.show_kerne_branding ?? false })
3675
+ /* @__PURE__ */ jsx20(KerneBranding, { show: config?.show_kerne_branding ?? false })
3169
3676
  ] });
3170
3677
  if (outcome === "cancel") {
3171
3678
  return shell(
3172
- /* @__PURE__ */ jsx19(
3679
+ /* @__PURE__ */ jsx20(
3173
3680
  Status,
3174
3681
  {
3175
3682
  tone: "danger",
3176
3683
  title: l10n.checkout.cancelTitle,
3177
- action: pricingUrl || onRetry ? /* @__PURE__ */ jsx19(Button, { variant: "outline", type: "button", onClick: go(pricingUrl, onRetry), children: l10n.checkout.backToPlans }) : void 0,
3684
+ action: pricingUrl || onRetry ? /* @__PURE__ */ jsx20(Button, { variant: "outline", type: "button", onClick: go(pricingUrl, onRetry), children: l10n.checkout.backToPlans }) : void 0,
3178
3685
  children: l10n.checkout.cancelBody
3179
3686
  }
3180
3687
  )
@@ -3182,21 +3689,21 @@ function CheckoutResult({
3182
3689
  }
3183
3690
  if (state === "checking") {
3184
3691
  return shell(
3185
- /* @__PURE__ */ jsx19(Status, { tone: "loading", title: l10n.checkout.confirmingTitle, children: l10n.checkout.confirmingBody })
3692
+ /* @__PURE__ */ jsx20(Status, { tone: "loading", title: l10n.checkout.confirmingTitle, children: l10n.checkout.confirmingBody })
3186
3693
  );
3187
3694
  }
3188
3695
  if (state === "pending") {
3189
3696
  return shell(
3190
- /* @__PURE__ */ jsx19(Status, { tone: "success", title: l10n.checkout.pendingTitle, action: continueAction, children: l10n.checkout.pendingBody })
3697
+ /* @__PURE__ */ jsx20(Status, { tone: "success", title: l10n.checkout.pendingTitle, action: continueAction, children: l10n.checkout.pendingBody })
3191
3698
  );
3192
3699
  }
3193
3700
  return shell(
3194
- /* @__PURE__ */ jsx19(Status, { tone: "success", title: l10n.checkout.doneTitle, action: continueAction, children: l10n.checkout.doneBody })
3701
+ /* @__PURE__ */ jsx20(Status, { tone: "success", title: l10n.checkout.doneTitle, action: continueAction, children: l10n.checkout.doneBody })
3195
3702
  );
3196
3703
  }
3197
3704
 
3198
3705
  // src/ui/UpgradePrompt.tsx
3199
- import { Fragment as Fragment11, jsx as jsx20, jsxs as jsxs19 } from "react/jsx-runtime";
3706
+ import { Fragment as Fragment13, jsx as jsx21, jsxs as jsxs20 } from "react/jsx-runtime";
3200
3707
  function UpgradePrompt({
3201
3708
  featureKey,
3202
3709
  requested,
@@ -3215,22 +3722,25 @@ function UpgradePrompt({
3215
3722
  const l10n = useLocalization();
3216
3723
  if (isLoading) {
3217
3724
  if (hideWhileLoading) return null;
3218
- return /* @__PURE__ */ jsx20(Shell, { appearance, className, children: /* @__PURE__ */ jsx20("div", { className: "kerne-skeleton", style: { height: 80 }, "aria-label": l10n.upgrade.checking }) });
3725
+ return /* @__PURE__ */ jsx21(Shell, { appearance, className, children: /* @__PURE__ */ jsx21("div", { className: "kerne-skeleton", style: { height: 80 }, "aria-label": l10n.upgrade.checking }) });
3219
3726
  }
3220
- if (allowed) return /* @__PURE__ */ jsx20(Fragment11, { children });
3727
+ if (allowed) return /* @__PURE__ */ jsx21(Fragment13, { children });
3221
3728
  const name = featureName ?? humanizeFeatureKey(featureKey).toLowerCase();
3222
3729
  const quotaExhausted = details?.feature_type === "QUOTA" && typeof details.limit === "number" && typeof details.used === "number";
3223
3730
  const heading = title ?? fill(quotaExhausted ? l10n.upgrade.quotaTitle : l10n.upgrade.featureTitle, { feature: name });
3224
3731
  const body = description ?? (quotaExhausted ? fill(l10n.upgrade.quotaBody, {
3225
3732
  used: formatNumber(details.used),
3226
- limit: formatNumber(details.limit)
3733
+ // `access_limit` is where access actually stops; `limit` is only the
3734
+ // included allocation and can understate it past an overage ceiling.
3735
+ // Falls back to `limit` defensively - should never be needed here.
3736
+ limit: formatNumber(details.access_limit ?? details.limit)
3227
3737
  }) : fill(l10n.upgrade.featureBody, { feature: name }));
3228
- return /* @__PURE__ */ jsx20(Shell, { appearance, className, children: /* @__PURE__ */ jsxs19("div", { className: "kerne-status", children: [
3229
- /* @__PURE__ */ jsxs19("div", { className: "kerne-heading", children: [
3230
- /* @__PURE__ */ jsx20("h2", { className: "kerne-section-title", style: { fontSize: 17 }, children: heading }),
3231
- /* @__PURE__ */ jsx20("p", { className: "kerne-status-text", children: body })
3738
+ return /* @__PURE__ */ jsx21(Shell, { appearance, className, children: /* @__PURE__ */ jsxs20("div", { className: "kerne-status", children: [
3739
+ /* @__PURE__ */ jsxs20("div", { className: "kerne-heading", children: [
3740
+ /* @__PURE__ */ jsx21("h2", { className: "kerne-section-title", style: { fontSize: 17 }, children: heading }),
3741
+ /* @__PURE__ */ jsx21("p", { className: "kerne-status-text", children: body })
3232
3742
  ] }),
3233
- upgradeUrl || onUpgradeClick ? /* @__PURE__ */ jsx20(
3743
+ upgradeUrl || onUpgradeClick ? /* @__PURE__ */ jsx21(
3234
3744
  Button,
3235
3745
  {
3236
3746
  type: "button",
@@ -3251,6 +3761,7 @@ export {
3251
3761
  ForgotPasswordForm,
3252
3762
  LoginForm,
3253
3763
  MagicLinkCallback,
3764
+ PricingTable,
3254
3765
  ProfileSection,
3255
3766
  RegisterForm,
3256
3767
  ResetPasswordForm,
@@ -3264,5 +3775,6 @@ export {
3264
3775
  VerifyEmailForm,
3265
3776
  WaitlistForm,
3266
3777
  defaultLocalization,
3267
- useLocalization
3778
+ useLocalization,
3779
+ usePricingTable
3268
3780
  };