@kerne/react 1.0.0 → 1.2.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.
@@ -1,7 +1,8 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import React from 'react';
3
- import { PublicPlan, PublicPlanPrice, PublicPriceLabel, SubscriptionWithPlan } from '@kerne/types';
4
- export { K as KerneLocalization, d as defaultLocalization, u as useLocalization } from '../i18n-Di5qlnL1.cjs';
3
+ import { PublicPlan, PublicPriceLabel, PublicPlanPrice, SubscriptionWithPlan } from '@kerne/types';
4
+ import { K as KerneLocalization } from '../i18n-CXEhPK58.cjs';
5
+ export { d as defaultLocalization, u as useLocalization } from '../i18n-CXEhPK58.cjs';
5
6
 
6
7
  /**
7
8
  * Per-instance overrides of the same custom properties the stylesheet reads,
@@ -299,9 +300,9 @@ declare function SecuritySection({ showDeleteAccount, onDeleted }: SecuritySecti
299
300
  * lists what this scope can move to and starts a checkout, and deliberately
300
301
  * does not sell. `<PricingTable>` is the surface that sells.
301
302
  *
302
- * Interval handling is the honest minimum until PriceLabel exists (see
303
- * 34-ENTITLEMENT-ENGINE.md §9.2): prices are grouped by `interval`, and the
304
- * segmented control only renders when a plan genuinely has more than one. A
303
+ * Interval handling is the honest minimum until PriceLabel exists here too:
304
+ * prices are grouped by `interval`, and the segmented control only renders
305
+ * when a plan genuinely has more than one. A
305
306
  * plan carrying a third price (early-bird, non-profit) still has no way to be
306
307
  * described here - which is exactly the gap PriceLabel closes.
307
308
  */
@@ -353,15 +354,86 @@ interface UsageMetersProps extends Omit<KerneUIProps, 'logo'> {
353
354
  }
354
355
  declare function UsageMeters({ title, featureKeys, showUnlimited, limit: maxRows, labels, emptyState, appearance, className, }: UsageMetersProps): react_jsx_runtime.JSX.Element;
355
356
 
357
+ /**
358
+ * Display helpers shared by the billing and usage components.
359
+ */
360
+
361
+ /**
362
+ * Last-resort label for a feature.
363
+ *
364
+ * `GET /v1/billing/entitlements` returns `feature_key` but no `feature_name`
365
+ * (see EntitlementSummary), while the subscription payload does carry names -
366
+ * so components cross-reference the subscription first and only fall back
367
+ * here for a scope on a default pack, which has no subscription to read names
368
+ * from. Turning `api_calls` into "Api calls" is not as good as the real name;
369
+ * it is only better than showing a raw key to an end user.
370
+ */
371
+ declare function humanizeFeatureKey(key: string): string;
372
+ /** Amounts are stored in minor units (cents), as the provider APIs return them. */
373
+ declare function formatMoney(amountMinorUnits: number, currency: string): string;
374
+ declare function formatNumber(value: number): string;
375
+ declare function formatDate(iso: string | null): string | null;
376
+ /**
377
+ * Normalises the provider's interval wording, then localizes it.
378
+ *
379
+ * `PlanPrice.interval` is Kerne's own "N+cycle" encoding ("1M", "3M", "1Y",
380
+ * "30D"), checked first since it's what every
381
+ * subscription/plan payload actually carries. The word-based fallback below
382
+ * stays for any raw provider string passed directly (Stripe says `month`,
383
+ * Polar says `monthly` - same thing to a reader). An interval matching
384
+ * neither falls through unchanged rather than being dropped: better a raw
385
+ * value than a price with no period attached.
386
+ */
387
+ declare function formatInterval(interval: string, labels: IntervalLabels): string;
388
+ type IntervalLabels = Pick<KerneLocalization['billing'], 'intervalMonth' | 'intervalYear' | 'intervalWeek' | 'intervalDay'>;
389
+ interface PriceDisplay {
390
+ /** What's actually charged - show this alongside displayAmount, never hide it. */
391
+ billedAmount: number;
392
+ billedInterval: string;
393
+ /** The headline number a PriceLabel.display_interval asks to show instead. */
394
+ displayAmount: number;
395
+ displayInterval: string;
396
+ }
397
+ /**
398
+ * Resolves what to show for a price under a label's `display_interval` -
399
+ * e.g. an annual price shown as its monthly equivalent while still billing
400
+ * annually. `null`/`undefined`/equal-to-billed pass through unchanged. Divides
401
+ * once via approxMonths()'s ratio, never divide-then-multiply back to
402
+ * "verify" - a price rarely divides evenly (100 EUR/year -> 8.33 EUR/month,
403
+ * and 8.33 * 12 = 99.96, not 100).
404
+ */
405
+ declare function resolvePriceDisplay(price: {
406
+ amount: number;
407
+ interval: string;
408
+ }, displayInterval: string | null | undefined): PriceDisplay;
409
+ /**
410
+ * One entry per distinct PriceLabel across a plan catalog, in owner order
411
+ * (PriceLabel.sort_order, never alphabetical) - shared by every surface that
412
+ * renders a label switch ("Monthly"/"Annual" and similar).
413
+ */
414
+ declare function collectPriceLabels(plans: PublicPlan[]): PublicPriceLabel[];
415
+ interface LabelSavings {
416
+ labelSlug: string;
417
+ percent: number;
418
+ }
419
+ /**
420
+ * Per-month discount of the longest-billed label vs the shortest-billed one -
421
+ * the "Save X%" hint on a label switch. Compares what's actually charged
422
+ * (interval/amount), never the display_interval cosmetic - a label showing
423
+ * "10 EUR/mo" for an annual price still bills the year, and that's what the
424
+ * saving has to stay true about. Only returned once an actual plan prices
425
+ * both ends of the range: nothing is inferred from a single price, and a
426
+ * catalog that only sells the longest label can't claim a discount that
427
+ * doesn't apply to it.
428
+ */
429
+ declare function resolveLabelSavings(plans: PublicPlan[], labels: PublicPriceLabel[]): LabelSavings | null;
430
+
356
431
  interface PricingTableContextValue {
357
432
  plans: PublicPlan[];
358
433
  labels: PublicPriceLabel[];
359
434
  selectedLabelSlug: string | null;
360
435
  setSelectedLabelSlug: (slug: string) => void;
361
- savings: {
362
- labelSlug: string;
363
- percent: number;
364
- } | null;
436
+ savings: LabelSavings | null;
365
437
  currentPlanId: string | null;
366
438
  busyPriceId: string | null;
367
439
  onSelectPlan: (plan: PublicPlan) => void;
@@ -451,4 +523,4 @@ interface UpgradePromptProps extends Omit<KerneUIProps, 'logo'> {
451
523
  }
452
524
  declare function UpgradePrompt({ featureKey, requested, featureName, children, upgradeUrl, onUpgradeClick, upgradeLabel, title, description, hideWhileLoading, appearance, className, }: UpgradePromptProps): react_jsx_runtime.JSX.Element | null;
453
525
 
454
- export { ActivateAccountForm, type ActivateAccountFormProps, AuthFlow, type AuthFlowProps, type AuthFlowStep, BillingSection, type BillingSectionProps, ChangePasswordForm, type ChangePasswordFormProps, CheckoutResult, type CheckoutResultProps, ForgotPasswordForm, type ForgotPasswordFormProps, type KerneAppearance, LoginForm, type LoginFormProps, MagicLinkCallback, type MagicLinkCallbackProps, PricingTable, type PricingTablePlanProps, type PricingTablePlansProps, type PricingTableProps, ProfileSection, type ProfileSectionProps, RegisterForm, type RegisterFormProps, ResetPasswordForm, type ResetPasswordFormProps, SecuritySection, type SecuritySectionProps, SignOutButton, type SignOutButtonProps, SubscriptionCard, type SubscriptionCardProps, UpgradePrompt, type UpgradePromptProps, UsageMeters, type UsageMetersProps, UserButton, type UserButtonMenuItem, type UserButtonProps, UserProfile, type UserProfileExtraTab, type UserProfileProps, type UserProfileTab, VerifyEmailForm, type VerifyEmailFormProps, WaitlistForm, type WaitlistFormProps, usePricingTable };
526
+ export { ActivateAccountForm, type ActivateAccountFormProps, AuthFlow, type AuthFlowProps, type AuthFlowStep, BillingSection, type BillingSectionProps, ChangePasswordForm, type ChangePasswordFormProps, CheckoutResult, type CheckoutResultProps, ForgotPasswordForm, type ForgotPasswordFormProps, type KerneAppearance, KerneLocalization, type LabelSavings, LoginForm, type LoginFormProps, MagicLinkCallback, type MagicLinkCallbackProps, type PriceDisplay, PricingTable, type PricingTablePlanProps, type PricingTablePlansProps, type PricingTableProps, ProfileSection, type ProfileSectionProps, RegisterForm, type RegisterFormProps, ResetPasswordForm, type ResetPasswordFormProps, SecuritySection, type SecuritySectionProps, SignOutButton, type SignOutButtonProps, SubscriptionCard, type SubscriptionCardProps, UpgradePrompt, type UpgradePromptProps, UsageMeters, type UsageMetersProps, UserButton, type UserButtonMenuItem, type UserButtonProps, UserProfile, type UserProfileExtraTab, type UserProfileProps, type UserProfileTab, VerifyEmailForm, type VerifyEmailFormProps, WaitlistForm, type WaitlistFormProps, collectPriceLabels, formatDate, formatInterval, formatMoney, formatNumber, humanizeFeatureKey, resolveLabelSavings, resolvePriceDisplay, usePricingTable };
@@ -1,7 +1,8 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import React from 'react';
3
- import { PublicPlan, PublicPlanPrice, PublicPriceLabel, SubscriptionWithPlan } from '@kerne/types';
4
- export { K as KerneLocalization, d as defaultLocalization, u as useLocalization } from '../i18n-Di5qlnL1.js';
3
+ import { PublicPlan, PublicPriceLabel, PublicPlanPrice, SubscriptionWithPlan } from '@kerne/types';
4
+ import { K as KerneLocalization } from '../i18n-CXEhPK58.js';
5
+ export { d as defaultLocalization, u as useLocalization } from '../i18n-CXEhPK58.js';
5
6
 
6
7
  /**
7
8
  * Per-instance overrides of the same custom properties the stylesheet reads,
@@ -299,9 +300,9 @@ declare function SecuritySection({ showDeleteAccount, onDeleted }: SecuritySecti
299
300
  * lists what this scope can move to and starts a checkout, and deliberately
300
301
  * does not sell. `<PricingTable>` is the surface that sells.
301
302
  *
302
- * Interval handling is the honest minimum until PriceLabel exists (see
303
- * 34-ENTITLEMENT-ENGINE.md §9.2): prices are grouped by `interval`, and the
304
- * segmented control only renders when a plan genuinely has more than one. A
303
+ * Interval handling is the honest minimum until PriceLabel exists here too:
304
+ * prices are grouped by `interval`, and the segmented control only renders
305
+ * when a plan genuinely has more than one. A
305
306
  * plan carrying a third price (early-bird, non-profit) still has no way to be
306
307
  * described here - which is exactly the gap PriceLabel closes.
307
308
  */
@@ -353,15 +354,86 @@ interface UsageMetersProps extends Omit<KerneUIProps, 'logo'> {
353
354
  }
354
355
  declare function UsageMeters({ title, featureKeys, showUnlimited, limit: maxRows, labels, emptyState, appearance, className, }: UsageMetersProps): react_jsx_runtime.JSX.Element;
355
356
 
357
+ /**
358
+ * Display helpers shared by the billing and usage components.
359
+ */
360
+
361
+ /**
362
+ * Last-resort label for a feature.
363
+ *
364
+ * `GET /v1/billing/entitlements` returns `feature_key` but no `feature_name`
365
+ * (see EntitlementSummary), while the subscription payload does carry names -
366
+ * so components cross-reference the subscription first and only fall back
367
+ * here for a scope on a default pack, which has no subscription to read names
368
+ * from. Turning `api_calls` into "Api calls" is not as good as the real name;
369
+ * it is only better than showing a raw key to an end user.
370
+ */
371
+ declare function humanizeFeatureKey(key: string): string;
372
+ /** Amounts are stored in minor units (cents), as the provider APIs return them. */
373
+ declare function formatMoney(amountMinorUnits: number, currency: string): string;
374
+ declare function formatNumber(value: number): string;
375
+ declare function formatDate(iso: string | null): string | null;
376
+ /**
377
+ * Normalises the provider's interval wording, then localizes it.
378
+ *
379
+ * `PlanPrice.interval` is Kerne's own "N+cycle" encoding ("1M", "3M", "1Y",
380
+ * "30D"), checked first since it's what every
381
+ * subscription/plan payload actually carries. The word-based fallback below
382
+ * stays for any raw provider string passed directly (Stripe says `month`,
383
+ * Polar says `monthly` - same thing to a reader). An interval matching
384
+ * neither falls through unchanged rather than being dropped: better a raw
385
+ * value than a price with no period attached.
386
+ */
387
+ declare function formatInterval(interval: string, labels: IntervalLabels): string;
388
+ type IntervalLabels = Pick<KerneLocalization['billing'], 'intervalMonth' | 'intervalYear' | 'intervalWeek' | 'intervalDay'>;
389
+ interface PriceDisplay {
390
+ /** What's actually charged - show this alongside displayAmount, never hide it. */
391
+ billedAmount: number;
392
+ billedInterval: string;
393
+ /** The headline number a PriceLabel.display_interval asks to show instead. */
394
+ displayAmount: number;
395
+ displayInterval: string;
396
+ }
397
+ /**
398
+ * Resolves what to show for a price under a label's `display_interval` -
399
+ * e.g. an annual price shown as its monthly equivalent while still billing
400
+ * annually. `null`/`undefined`/equal-to-billed pass through unchanged. Divides
401
+ * once via approxMonths()'s ratio, never divide-then-multiply back to
402
+ * "verify" - a price rarely divides evenly (100 EUR/year -> 8.33 EUR/month,
403
+ * and 8.33 * 12 = 99.96, not 100).
404
+ */
405
+ declare function resolvePriceDisplay(price: {
406
+ amount: number;
407
+ interval: string;
408
+ }, displayInterval: string | null | undefined): PriceDisplay;
409
+ /**
410
+ * One entry per distinct PriceLabel across a plan catalog, in owner order
411
+ * (PriceLabel.sort_order, never alphabetical) - shared by every surface that
412
+ * renders a label switch ("Monthly"/"Annual" and similar).
413
+ */
414
+ declare function collectPriceLabels(plans: PublicPlan[]): PublicPriceLabel[];
415
+ interface LabelSavings {
416
+ labelSlug: string;
417
+ percent: number;
418
+ }
419
+ /**
420
+ * Per-month discount of the longest-billed label vs the shortest-billed one -
421
+ * the "Save X%" hint on a label switch. Compares what's actually charged
422
+ * (interval/amount), never the display_interval cosmetic - a label showing
423
+ * "10 EUR/mo" for an annual price still bills the year, and that's what the
424
+ * saving has to stay true about. Only returned once an actual plan prices
425
+ * both ends of the range: nothing is inferred from a single price, and a
426
+ * catalog that only sells the longest label can't claim a discount that
427
+ * doesn't apply to it.
428
+ */
429
+ declare function resolveLabelSavings(plans: PublicPlan[], labels: PublicPriceLabel[]): LabelSavings | null;
430
+
356
431
  interface PricingTableContextValue {
357
432
  plans: PublicPlan[];
358
433
  labels: PublicPriceLabel[];
359
434
  selectedLabelSlug: string | null;
360
435
  setSelectedLabelSlug: (slug: string) => void;
361
- savings: {
362
- labelSlug: string;
363
- percent: number;
364
- } | null;
436
+ savings: LabelSavings | null;
365
437
  currentPlanId: string | null;
366
438
  busyPriceId: string | null;
367
439
  onSelectPlan: (plan: PublicPlan) => void;
@@ -451,4 +523,4 @@ interface UpgradePromptProps extends Omit<KerneUIProps, 'logo'> {
451
523
  }
452
524
  declare function UpgradePrompt({ featureKey, requested, featureName, children, upgradeUrl, onUpgradeClick, upgradeLabel, title, description, hideWhileLoading, appearance, className, }: UpgradePromptProps): react_jsx_runtime.JSX.Element | null;
453
525
 
454
- export { ActivateAccountForm, type ActivateAccountFormProps, AuthFlow, type AuthFlowProps, type AuthFlowStep, BillingSection, type BillingSectionProps, ChangePasswordForm, type ChangePasswordFormProps, CheckoutResult, type CheckoutResultProps, ForgotPasswordForm, type ForgotPasswordFormProps, type KerneAppearance, LoginForm, type LoginFormProps, MagicLinkCallback, type MagicLinkCallbackProps, PricingTable, type PricingTablePlanProps, type PricingTablePlansProps, type PricingTableProps, ProfileSection, type ProfileSectionProps, RegisterForm, type RegisterFormProps, ResetPasswordForm, type ResetPasswordFormProps, SecuritySection, type SecuritySectionProps, SignOutButton, type SignOutButtonProps, SubscriptionCard, type SubscriptionCardProps, UpgradePrompt, type UpgradePromptProps, UsageMeters, type UsageMetersProps, UserButton, type UserButtonMenuItem, type UserButtonProps, UserProfile, type UserProfileExtraTab, type UserProfileProps, type UserProfileTab, VerifyEmailForm, type VerifyEmailFormProps, WaitlistForm, type WaitlistFormProps, usePricingTable };
526
+ export { ActivateAccountForm, type ActivateAccountFormProps, AuthFlow, type AuthFlowProps, type AuthFlowStep, BillingSection, type BillingSectionProps, ChangePasswordForm, type ChangePasswordFormProps, CheckoutResult, type CheckoutResultProps, ForgotPasswordForm, type ForgotPasswordFormProps, type KerneAppearance, KerneLocalization, type LabelSavings, LoginForm, type LoginFormProps, MagicLinkCallback, type MagicLinkCallbackProps, type PriceDisplay, PricingTable, type PricingTablePlanProps, type PricingTablePlansProps, type PricingTableProps, ProfileSection, type ProfileSectionProps, RegisterForm, type RegisterFormProps, ResetPasswordForm, type ResetPasswordFormProps, SecuritySection, type SecuritySectionProps, SignOutButton, type SignOutButtonProps, SubscriptionCard, type SubscriptionCardProps, UpgradePrompt, type UpgradePromptProps, UsageMeters, type UsageMetersProps, UserButton, type UserButtonMenuItem, type UserButtonProps, UserProfile, type UserProfileExtraTab, type UserProfileProps, type UserProfileTab, VerifyEmailForm, type VerifyEmailFormProps, WaitlistForm, type WaitlistFormProps, collectPriceLabels, formatDate, formatInterval, formatMoney, formatNumber, humanizeFeatureKey, resolveLabelSavings, resolvePriceDisplay, usePricingTable };
package/dist/ui/index.js CHANGED
@@ -7,17 +7,17 @@ import {
7
7
  useAuth,
8
8
  useAuthConfig,
9
9
  useCancelSubscription,
10
- useCheckout,
11
10
  useClient,
12
11
  useEntitlements,
13
12
  useErrorResolver,
14
13
  useLocalization,
14
+ usePlanSwitch,
15
15
  usePlans,
16
16
  usePortal,
17
17
  useSubscription,
18
18
  useUser,
19
19
  useWaitlist
20
- } from "../chunk-NFJ2S5VJ.js";
20
+ } from "../chunk-IHNWY4ML.js";
21
21
 
22
22
  // src/ui/AuthFlow.tsx
23
23
  import { useState as useState10 } from "react";
@@ -2822,6 +2822,44 @@ function resolvePriceDisplay(price, displayInterval) {
2822
2822
  displayInterval
2823
2823
  };
2824
2824
  }
2825
+ function collectPriceLabels(plans) {
2826
+ const bySlug = /* @__PURE__ */ new Map();
2827
+ for (const plan of plans) {
2828
+ for (const price of plan.prices) {
2829
+ if (!bySlug.has(price.label.slug)) bySlug.set(price.label.slug, price.label);
2830
+ }
2831
+ }
2832
+ return Array.from(bySlug.values()).sort(
2833
+ (a, b) => a.sort_order - b.sort_order || a.name.localeCompare(b.name)
2834
+ );
2835
+ }
2836
+ function resolveLabelSavings(plans, labels) {
2837
+ if (labels.length < 2) return null;
2838
+ const priceFor = (labelSlug) => {
2839
+ for (const plan of plans) {
2840
+ const price = plan.prices.find((p) => p.label.slug === labelSlug);
2841
+ if (price) return price;
2842
+ }
2843
+ return void 0;
2844
+ };
2845
+ const sorted = [...labels].sort(
2846
+ (a, b) => approxMonths(priceFor(a.slug)?.interval ?? "1M") - approxMonths(priceFor(b.slug)?.interval ?? "1M")
2847
+ );
2848
+ const shortest = sorted[0];
2849
+ const longest = sorted[sorted.length - 1];
2850
+ if (shortest.slug === longest.slug) return null;
2851
+ for (const plan of plans) {
2852
+ const shortPrice = plan.prices.find((p) => p.label.slug === shortest.slug);
2853
+ const longPrice = plan.prices.find((p) => p.label.slug === longest.slug);
2854
+ if (!shortPrice?.amount || !longPrice) continue;
2855
+ const shortMonths = approxMonths(shortPrice.interval);
2856
+ const longMonths = approxMonths(longPrice.interval);
2857
+ if (!shortMonths || !longMonths) continue;
2858
+ const savings = 1 - longPrice.amount / longMonths / (shortPrice.amount / shortMonths);
2859
+ if (savings > 0.01) return { labelSlug: longest.slug, percent: Math.round(savings * 100) };
2860
+ }
2861
+ return null;
2862
+ }
2825
2863
 
2826
2864
  // src/ui/UsageMeters.tsx
2827
2865
  import { Fragment as Fragment8, jsx as jsx15, jsxs as jsxs14 } from "react/jsx-runtime";
@@ -3105,31 +3143,37 @@ function BillingSection({
3105
3143
  usageFeatureKeys,
3106
3144
  usageLabels
3107
3145
  }) {
3108
- const { subscription } = useSubscription(productSlug);
3146
+ const { subscription, refetch: refetchSubscription } = useSubscription(productSlug);
3109
3147
  const { plans, isLoading: plansLoading } = usePlans(pricingProductIdOrSlug);
3110
- const { openCheckout } = useCheckout();
3111
3148
  const l10n = useLocalization();
3112
3149
  const showError = useErrorResolver();
3113
- const [busy, setBusy] = useState17(null);
3114
- const [error, setError] = useState17(null);
3150
+ const {
3151
+ pending: pendingPlan,
3152
+ switching,
3153
+ switchError,
3154
+ checkoutPriceId: busy,
3155
+ checkoutError,
3156
+ selectPlan,
3157
+ confirmSwitch,
3158
+ dismissSwitch
3159
+ } = usePlanSwitch(subscription, refetchSubscription, {
3160
+ successUrl: checkoutSuccessUrl,
3161
+ cancelUrl: checkoutCancelUrl
3162
+ });
3163
+ const error = checkoutError ?? switchError;
3115
3164
  const intervals = useMemo(() => intervalsOf(plans), [plans]);
3116
3165
  const [interval, setInterval] = useState17(null);
3117
3166
  const activeInterval = interval ?? subscription?.plan_price.interval ?? intervals[0] ?? null;
3118
- const handleSwitch = async (planPriceId) => {
3119
- setError(null);
3120
- setBusy(planPriceId);
3121
- try {
3122
- await openCheckout(planPriceId, {
3123
- successUrl: checkoutSuccessUrl,
3124
- cancelUrl: checkoutCancelUrl
3125
- });
3126
- } catch (e) {
3127
- setError(showError(e));
3128
- setBusy(null);
3129
- }
3130
- };
3131
3167
  return /* @__PURE__ */ jsxs16(Fragment10, { children: [
3132
- error ? /* @__PURE__ */ jsx17(Panel, { variant: "danger", children: error }) : null,
3168
+ error ? /* @__PURE__ */ jsx17(Panel, { variant: "danger", children: showError(error) }) : null,
3169
+ pendingPlan ? /* @__PURE__ */ jsxs16(Panel, { children: [
3170
+ /* @__PURE__ */ jsx17("p", { children: fill(l10n.billing.switchConfirmTitle, { plan: pendingPlan.plan.name }) }),
3171
+ /* @__PURE__ */ jsx17("p", { className: "kerne-subsection-desc", children: l10n.billing.switchConfirmBody }),
3172
+ /* @__PURE__ */ jsxs16("div", { className: "kerne-inline-actions", children: [
3173
+ /* @__PURE__ */ jsx17(Button, { variant: "outline", type: "button", onClick: dismissSwitch, disabled: switching, children: l10n.billing.switchConfirmDismiss }),
3174
+ /* @__PURE__ */ jsx17(Button, { type: "button", onClick: confirmSwitch, loading: switching, children: switching ? l10n.billing.switching : l10n.billing.switchConfirmButton })
3175
+ ] })
3176
+ ] }) : null,
3133
3177
  /* @__PURE__ */ jsx17(Subsection, { title: l10n.billing.currentPlan, children: /* @__PURE__ */ jsx17(
3134
3178
  SubscriptionCard,
3135
3179
  {
@@ -3187,7 +3231,7 @@ function BillingSection({
3187
3231
  {
3188
3232
  variant: "outline",
3189
3233
  type: "button",
3190
- onClick: () => handleSwitch(price.id),
3234
+ onClick: () => selectPlan(plan, price),
3191
3235
  loading: busy === price.id,
3192
3236
  disabled: busy !== null,
3193
3237
  children: busy === price.id ? l10n.billing.opening : l10n.billing.switchTo
@@ -3318,50 +3362,6 @@ function formatEntitlement(e, unlimitedLabel) {
3318
3362
  if (e.limit === -1) return `${unlimitedLabel} ${e.feature_name.toLowerCase()}`;
3319
3363
  return `${formatNumber(e.limit)} ${e.feature_name.toLowerCase()}`;
3320
3364
  }
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
3365
  var PricingTableContext = createContext2(null);
3366
3366
  function usePricingTable() {
3367
3367
  const ctx = useContext2(PricingTableContext);
@@ -3381,38 +3381,38 @@ function PricingTable({
3381
3381
  children
3382
3382
  }) {
3383
3383
  const { plans, isLoading, error } = usePlans(product);
3384
- const { subscription } = useSubscription(product);
3385
- const { openCheckout } = useCheckout();
3384
+ const { subscription, refetch: refetchSubscription } = useSubscription(product);
3386
3385
  const l10n = useLocalization();
3387
3386
  const showError = useErrorResolver();
3388
3387
  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]);
3388
+ const {
3389
+ pending: pendingSwitch,
3390
+ switching,
3391
+ switchError,
3392
+ checkoutPriceId: busyPriceId,
3393
+ checkoutError,
3394
+ selectPlan,
3395
+ confirmSwitch,
3396
+ dismissSwitch
3397
+ } = usePlanSwitch(subscription, refetchSubscription, {
3398
+ successUrl: checkoutSuccessUrl,
3399
+ cancelUrl: checkoutCancelUrl
3400
+ });
3401
+ const labels = useMemo2(() => collectPriceLabels(plans), [plans]);
3402
+ const savings = useMemo2(() => resolveLabelSavings(plans, labels), [plans, labels]);
3393
3403
  useEffect7(() => {
3394
3404
  if (labels.length === 0) return;
3395
3405
  if (selectedLabelSlug && labels.some((l) => l.slug === selectedLabelSlug)) return;
3396
3406
  setSelectedLabelSlug(labels[0].slug);
3397
3407
  }, [labels]);
3398
- const handleSelectPlan = async (plan) => {
3408
+ const handleSelectPlan = (plan) => {
3399
3409
  const price = plan.prices.find((p) => p.label.slug === selectedLabelSlug) ?? plan.prices[0];
3400
3410
  if (!price) return;
3401
3411
  if (onSelectPlan) {
3402
3412
  onSelectPlan(plan, price);
3403
3413
  return;
3404
3414
  }
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
- }
3415
+ void selectPlan(plan, price);
3416
3416
  };
3417
3417
  const ctx = {
3418
3418
  plans,
@@ -3425,7 +3425,25 @@ function PricingTable({
3425
3425
  onSelectPlan: handleSelectPlan
3426
3426
  };
3427
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,
3428
+ checkoutError ? /* @__PURE__ */ jsx19(Panel, { variant: "danger", children: showError(checkoutError) }) : null,
3429
+ pendingSwitch ? /* @__PURE__ */ jsxs18(Panel, { children: [
3430
+ /* @__PURE__ */ jsx19("p", { children: fill(l10n.billing.switchConfirmTitle, { plan: pendingSwitch.plan.name }) }),
3431
+ /* @__PURE__ */ jsx19("p", { className: "kerne-subsection-desc", children: l10n.billing.switchConfirmBody }),
3432
+ switchError ? /* @__PURE__ */ jsx19(Panel, { variant: "danger", children: showError(switchError) }) : null,
3433
+ /* @__PURE__ */ jsxs18("div", { className: "kerne-inline-actions", children: [
3434
+ /* @__PURE__ */ jsx19(
3435
+ Button,
3436
+ {
3437
+ variant: "outline",
3438
+ type: "button",
3439
+ onClick: dismissSwitch,
3440
+ disabled: switching,
3441
+ children: l10n.billing.switchConfirmDismiss
3442
+ }
3443
+ ),
3444
+ /* @__PURE__ */ jsx19(Button, { type: "button", onClick: confirmSwitch, loading: switching, children: switching ? l10n.billing.switching : l10n.billing.switchConfirmButton })
3445
+ ] })
3446
+ ] }) : null,
3429
3447
  children
3430
3448
  ] }) });
3431
3449
  }
@@ -3774,7 +3792,15 @@ export {
3774
3792
  UserProfile,
3775
3793
  VerifyEmailForm,
3776
3794
  WaitlistForm,
3795
+ collectPriceLabels,
3777
3796
  defaultLocalization,
3797
+ formatDate,
3798
+ formatInterval,
3799
+ formatMoney,
3800
+ formatNumber,
3801
+ humanizeFeatureKey,
3802
+ resolveLabelSavings,
3803
+ resolvePriceDisplay,
3778
3804
  useLocalization,
3779
3805
  usePricingTable
3780
3806
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kerne/react",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Kerne React SDK",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",
@@ -32,8 +32,8 @@
32
32
  "dist"
33
33
  ],
34
34
  "dependencies": {
35
- "@kerne/server": "1.0.0",
36
- "@kerne/types": "1.0.0"
35
+ "@kerne/server": "1.2.0",
36
+ "@kerne/types": "1.2.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "react": "^18.0.0 || ^19.0.0"
@@ -41,19 +41,25 @@
41
41
  "devDependencies": {
42
42
  "@types/jest": "^29.5.12",
43
43
  "@types/react": "^18.0.0 || ^19.0.0",
44
+ "eslint": "^9.9.0",
45
+ "eslint-plugin-react-hooks": "^5.1.0-rc.0",
44
46
  "jest": "^29.7.0",
45
47
  "react": "^19.0.0",
46
48
  "ts-jest": "^29.2.5",
47
49
  "tsup": "^8.3.0",
48
- "typescript": "^5.7.0"
50
+ "typescript": "^5.7.0",
51
+ "typescript-eslint": "^8.0.1",
52
+ "eslint-plugin-kerne": "0.1.0"
49
53
  },
50
54
  "publishConfig": {
51
55
  "access": "public"
52
56
  },
53
57
  "scripts": {
54
- "build": "tsup",
58
+ "build": "tsup && pnpm check:dist-hygiene",
55
59
  "dev": "tsup --watch",
56
60
  "typecheck": "tsc --noEmit",
61
+ "lint": "eslint src",
62
+ "check:dist-hygiene": "node ./node_modules/eslint-plugin-kerne/check-dist-hygiene.js dist",
57
63
  "test": "jest"
58
64
  }
59
65
  }