{title}
} +{children}
+ {action &&{bodyText}
++ Changes that reduce your entitlements take effect at the end of the + current billing period, unless your payment provider processes them + sooner. Proration is handled by Stripe. +
++ Upgrade to unlock more capacity and features. +
+ > + ) : ( + <> +{priceLine}
} + > + )} + {!isFree && subscription && ( ++ {periodSubcopy + ? periodSubcopy + : subscription.cancel_at_period_end + ? `Ends on ${periodEnd}` + : subscription.trial_end && + (subscription.status === 'trialing' || + new Date(subscription.trial_end) > new Date()) + ? `Trial — ends ${formatDate(subscription.trial_end)}` + : `Renews on ${periodEnd}`} +
+ )} +{formatDate(inv.created_at)}
++ {inv.description ?? 'Subscription'} +
++ No invoices yet. Your invoices will appear here after your first + payment. +
+ + View plans + +| + Date + | ++ Description + | ++ Amount + | ++ Status + | ++ Actions + | +
|---|---|---|---|---|
| + {formatDate(inv.created_at)} + | ++ {inv.description ?? '—'} + | ++ {formatMoneyCents( + inv.amount_paid || inv.amount_due, + inv.currency + )} + | +
+ |
+ + {inv.hosted_invoice_url && ( + + View + + )} + {inv.invoice_pdf_url && ( + + PDF + + )} + | +
{tag}
: null} +{priceHeadline}
+ {priceSubline && ( +{priceSubline}
+ )} + {plan.price_cents > 0 && plan.trial_period_days > 0 ? ( ++ {plan.trial_period_days}-day trial, then billed{' '} + {billingCadence === 'annual' ? 'annually' : 'monthly'} at this rate. +
+ ) : null} ++ What's included +
+{label}
+{value}
+Inner
++ Click 'Simulate AI summarize' to generate a result. +
+ ) : ( ++ {new Date(e.at).toLocaleString()} +
++ {e.text} +
++ {name} requires a higher plan.{' '} + + Upgrade → + +
+ ); +} + +export function BooleanGatesDemo() { + const { loading: planLoading } = usePlan+ Feature A (requires Pro) +
+
+
+ Feature B (requires Max) +
+
+
+ Imperative equivalent:{' '}
+
+ const {'{'} enabled {'}'} = useFeature("feature_a")
+ {' '}
+ → currently{' '}
+
+ {imperativeA.loading ? '…' : String(imperativeA.enabled)}
+
+ {' · '}
+
+ useFeature("feature_b")
+ {' '}
+ →{' '}
+
+ {imperativeB.loading ? '…' : String(imperativeB.enabled)}
+
+
+ Demonstrates:{' '} + + {demonstrates} + +
+{description}
+// {codeReference}
++ Current plan:{' '} + + {planLoading ? '…' : (plan?.display_name ?? '—')} + +
+ ++ {message} +
+ )} + ++ These actions take effect immediately and bypass Stripe. Do not deploy + to production. +
++ {displayError.message} +
+ )} +
+ {error}{' '}
+
+ (Requires demo billing RPCs and{' '}
+ demo_billing_mode in the database.)
+
+
+ Collections:{' '} + + {loading || featLoading ? '…' : count} of {limLabel(maxCollections)} + +
+ ++ {actionErr} +
+ )} + + {!loading && collections.length === 0 ? ( ++ No collections yet. Click 'Add collection' to start. +
+ ) : ( ++ {row.id.slice(0, 8)}… +
++ Items: {row.item_count} of {limLabel(maxItemsPer)} +
+{DASHBOARD_SUBTITLE}
-
+ This dashboard is a sandbox for exercising the billing primitives in{' '}
+
+ @beakerstack/billing
+ {' '}
+ directly. Each section below demonstrates one capability with
+ working controls and code references. For the polished,
+ production-style billing UI, visit{' '}
+
+ Billing
+
+ .
+
+ Download invoices and receipts for your records. +
+ {error && ( ++ {String(error?.message ?? error)} +
+ )} ++ + Explore plans + +
+ )} +
+ BILLING_ALLOWED_ORIGINS
+ {' '}
+ includes this site's origin.
+ + Switch plans or update your billing cadence anytime. +
+Loading plans…
+ ) : ( ++ All plans billed in USD. Taxes calculated at checkout where applicable. + Cancel anytime. Plans with a trial convert to paid at trial end unless + you cancel before then (manage in Stripe customer portal). +
+Loading plan…
++ {limitsCopy.collectionsFootnote} +
+{err.message}
+ += { + supabase: SupabaseClient; + config: P; + children: React.ReactNode; + checkoutSuccessUrl: string; + checkoutCancelUrl: string; + portalReturnUrl: string; + stripeFunctionName?: string; +}; + +export function BillingProvider
({ + supabase, + config: rawConfig, + children, + checkoutSuccessUrl, + checkoutCancelUrl, + portalReturnUrl, + stripeFunctionName = 'billing-stripe', +}: BillingProviderProps
): React.ReactElement {
+ const config = useMemo(
+ () => productBillingConfigSchema.parse(rawConfig) as P,
+ [rawConfig]
+ );
+ const [userId, setUserId] = useState ({
+ children,
+ style,
+}: CustomerPortalLinkProps): ReactElement {
+ const { openPortal, pending } = useCustomerPortal ();
+ return (
+ ({
+ children,
+ className,
+ style,
+}: CustomerPortalLinkProps): ReactElement {
+ const { openPortal, pending } = useCustomerPortal ();
+ return (
+
+ );
+}
diff --git a/packages/billing/src/components/FeatureGate.native.test.tsx b/packages/billing/src/components/FeatureGate.native.test.tsx
new file mode 100644
index 00000000..6016b261
--- /dev/null
+++ b/packages/billing/src/components/FeatureGate.native.test.tsx
@@ -0,0 +1,73 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { FeatureGate } from './FeatureGate.native.js';
+import { useFeature } from '../hooks/useFeature.js';
+
+vi.mock('../hooks/useFeature.js', () => ({ useFeature: vi.fn() }));
+
+describe('FeatureGate (native)', () => {
+ beforeEach(() => {
+ vi.mocked(useFeature).mockReset();
+ });
+
+ it('renders children when enabled', () => {
+ vi.mocked(useFeature).mockReturnValue({
+ enabled: true,
+ value: true,
+ loading: false,
+ error: null,
+ });
+ render(
+ (
+ props: FeatureGateProps
+): ReactElement {
+ const { feature, featureName, fallback, children, style } = props;
+ const rawKey = feature ?? featureName;
+ if (rawKey == null || rawKey === '') {
+ return <>{fallback}>;
+ }
+ const key = rawKey as InferFeatureKeys & string;
+ const { enabled, loading } = useFeature (key);
+ if (loading) {
+ return = {
+ /** @deprecated Prefer {@link featureName} (core spec name). */
+ feature?: InferFeatureKeys & string;
+ /** Core spec: entitlement key on the current plan. */
+ featureName?: InferFeatureKeys & string;
+ fallback: ReactNode;
+ children: ReactNode;
+ className?: string;
+ style?: object;
+};
diff --git a/packages/billing/src/components/FeatureGate.web.test.tsx b/packages/billing/src/components/FeatureGate.web.test.tsx
new file mode 100644
index 00000000..50b4a40b
--- /dev/null
+++ b/packages/billing/src/components/FeatureGate.web.test.tsx
@@ -0,0 +1,75 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { FeatureGate } from './FeatureGate.web.js';
+import { useFeature } from '../hooks/useFeature.js';
+
+vi.mock('../hooks/useFeature.js', () => ({ useFeature: vi.fn() }));
+
+describe('FeatureGate (web)', () => {
+ beforeEach(() => {
+ vi.mocked(useFeature).mockReset();
+ });
+
+ it('renders loading placeholder', () => {
+ vi.mocked(useFeature).mockReturnValue({
+ enabled: false,
+ value: null,
+ loading: true,
+ error: null,
+ });
+ const { container } = render(
+ (
+ props: FeatureGateProps
+): ReactElement {
+ const { feature, featureName, fallback, children, className, style } = props;
+ const rawKey = feature ?? featureName;
+ if (rawKey == null || rawKey === '') {
+ return <>{fallback}>;
+ }
+ const key = rawKey as InferFeatureKeys & string;
+ const { enabled, loading } = useFeature (key);
+ if (loading) {
+ return ;
+ }
+ if (!enabled) {
+ return <>{fallback}>;
+ }
+ return <>{children}>;
+}
diff --git a/packages/billing/src/components/PricingTable.native.test.tsx b/packages/billing/src/components/PricingTable.native.test.tsx
new file mode 100644
index 00000000..42c2a2bb
--- /dev/null
+++ b/packages/billing/src/components/PricingTable.native.test.tsx
@@ -0,0 +1,91 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import React from 'react';
+import { PricingTable } from './PricingTable.native.js';
+import { usePlanCatalog } from '../hooks/usePlanCatalog.js';
+import { usePlan } from '../hooks/usePlan.js';
+import { testPlan } from '../test/billingFixtures.js';
+
+vi.mock('../hooks/usePlanCatalog.js', () => ({ usePlanCatalog: vi.fn() }));
+vi.mock('../hooks/usePlan.js', () => ({ usePlan: vi.fn() }));
+
+describe('PricingTable (native)', () => {
+ const p1 = testPlan({ id: 'p1', display_name: 'Pro', price_cents: 999 });
+
+ beforeEach(() => {
+ vi.mocked(usePlanCatalog).mockReturnValue({
+ plans: [p1],
+ loading: false,
+ error: null,
+ refresh: vi.fn(),
+ });
+ vi.mocked(usePlan).mockReturnValue({
+ data: null,
+ loading: false,
+ error: null,
+ });
+ });
+
+ it('renders plan row', () => {
+ render( ({
+ onSelectPlan,
+ onCheckout,
+ highlightCurrent,
+ highlightPlanId,
+ productId: _productId,
+ currentUserId: _currentUserId,
+ style,
+}: PricingTableProps): ReactElement {
+ void _productId;
+ void _currentUserId;
+ const onPlanChosen = onCheckout ?? onSelectPlan;
+ const { plans, loading } = usePlanCatalog ();
+ const { data: currentPlan } = usePlan ();
+
+ if (loading) {
+ return (
+ ({
+ onSelectPlan,
+ onCheckout,
+ highlightCurrent,
+ highlightPlanId,
+ productId: _productId,
+ currentUserId: _currentUserId,
+ className,
+ style,
+}: PricingTableProps): ReactElement {
+ void _productId;
+ void _currentUserId;
+ const onPlanChosen = onCheckout ?? onSelectPlan;
+ const { plans, loading } = usePlanCatalog ();
+ const { data: currentPlan } = usePlan ();
+
+ return (
+ Loading… ({
+ style,
+}: SubscriptionStatusProps): ReactElement {
+ const { data: sub, loading } = useSubscription ();
+ const { data: plan } = usePlan ();
+
+ if (loading) {
+ return (
+ ({
+ className,
+ style,
+}: SubscriptionStatusProps): ReactElement {
+ const { data: sub, loading } = useSubscription ();
+ const { data: plan } = usePlan ();
+
+ if (loading) {
+ return
+ Plan: {plan?.display_name ?? sub?.plan_id ?? '—'}
+
+ Status: {subscriptionStatusLabel(sub, renewal)}
+
+ Payment issue — update billing in the portal.
+
+ Renews / period ends: {renewal}
+ ({
+ targetTier,
+ suggestedPlanId,
+ reason,
+ children,
+ style,
+}: UpgradePromptProps): ReactElement {
+ const planId = suggestedPlanId ?? targetTier;
+ const { startCheckout, pending, error } = useCheckout ();
+
+ const onUpgrade = async () => {
+ if (!planId) return;
+ const r = await startCheckout(planId);
+ if (r?.checkoutUrl) {
+ const { Linking } = await import('react-native');
+ await Linking.openURL(r.checkoutUrl);
+ }
+ };
+
+ if (typeof children === 'function') {
+ return <>{children({ onUpgrade, pending })}>;
+ }
+
+ return (
+ ({
+ targetTier,
+ suggestedPlanId,
+ reason,
+ children,
+ className,
+ style,
+}: UpgradePromptProps): ReactElement {
+ const planId = suggestedPlanId ?? targetTier;
+ const { startCheckout, pending, error } = useCheckout ();
+
+ const onUpgrade = async () => {
+ if (!planId) return;
+ const r = await startCheckout(planId);
+ if (r?.checkoutUrl && typeof window !== 'undefined') {
+ window.location.href = r.checkoutUrl;
+ }
+ };
+
+ if (typeof children === 'function') {
+ return <>{children({ onUpgrade, pending })}>;
+ }
+
+ return (
+ {reason} {error.message} (
+ props: UsageIndicatorProps
+): ReactElement {
+ const { meter, variant = 'text', style, label, description } = props;
+ const { used, limit, remaining, resetsAt, loading } = useUsage<
+ P,
+ typeof meter
+ >(meter);
+ const lim = limit === null ? '∞' : String(limit);
+ const rem = remaining === null ? '∞' : String(remaining);
+ const text = loading
+ ? '…'
+ : `${used} of ${lim} used · resets ${resetsAt ? new Date(resetsAt).toLocaleDateString() : '—'}`;
+ const textUnlimited = loading
+ ? '…'
+ : limit === null
+ ? `${used} used this period · unlimited`
+ : text;
+ if (variant === 'compact') {
+ return (
+ = {
+ meter: InferMeterKeys & string;
+ variant?: 'bar' | 'text' | 'compact' | 'expanded';
+ /** Used with `variant="expanded"`. */
+ label?: string;
+ /** Shown under the label in expanded layout. */
+ description?: string;
+ className?: string;
+ style?: object;
+};
diff --git a/packages/billing/src/components/UsageIndicator.web.test.tsx b/packages/billing/src/components/UsageIndicator.web.test.tsx
new file mode 100644
index 00000000..31ea5ee7
--- /dev/null
+++ b/packages/billing/src/components/UsageIndicator.web.test.tsx
@@ -0,0 +1,229 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { UsageIndicator } from './UsageIndicator.web.js';
+import { useUsage } from '../hooks/useUsage.js';
+
+vi.mock('../hooks/useUsage.js', () => ({ useUsage: vi.fn() }));
+
+describe('UsageIndicator (web)', () => {
+ beforeEach(() => {
+ vi.mocked(useUsage).mockReturnValue({
+ used: 3,
+ limit: 10,
+ remaining: 7,
+ resetsAt: '2026-06-01T00:00:00.000Z',
+ loading: false,
+ });
+ });
+
+ it('renders default text variant', () => {
+ render( (
+ props: UsageIndicatorProps
+): ReactElement {
+ const {
+ meter,
+ variant = 'text',
+ className,
+ style,
+ label,
+ description,
+ } = props;
+ const { used, limit, remaining, resetsAt, loading } = useUsage<
+ P,
+ typeof meter
+ >(meter);
+ const lim = limit === null ? '∞' : String(limit);
+ const rem = remaining === null ? '∞' : String(remaining);
+ const text = loading
+ ? '…'
+ : `${used} of ${lim} used · resets ${resetsAt ? new Date(resetsAt).toLocaleDateString() : '—'}`;
+ const textUnlimited = loading
+ ? '…'
+ : limit === null
+ ? `${used} used this period · unlimited`
+ : text;
+ if (variant === 'compact') {
+ return (
+
+ {loading ? '…' : `${used}/${lim}`}
+
+ );
+ }
+ if (variant === 'expanded') {
+ const capLine =
+ limit === null
+ ? textUnlimited
+ : `${used} of ${lim} used · resets ${resetsAt ? new Date(resetsAt).toLocaleDateString() : '—'}`;
+ const pct =
+ limit != null && limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
+ return (
+ {description} {
+ const v = useContext(BillingReactContext) as BillingContextValue | null;
+ if (!v) {
+ throw new Error('useBillingContext must be used within BillingProvider');
+ }
+ return v;
+}
diff --git a/packages/billing/src/hooks/useBillingState.test.tsx b/packages/billing/src/hooks/useBillingState.test.tsx
new file mode 100644
index 00000000..a7ab75c0
--- /dev/null
+++ b/packages/billing/src/hooks/useBillingState.test.tsx
@@ -0,0 +1,151 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import type { Plan, SubscriptionRow } from '../types.js';
+import {
+ baseBillingContextExtras,
+ testPlan,
+ testSubscription,
+} from '../test/billingFixtures.js';
+import { useBillingState } from './useBillingState.js';
+
+const hp = vi.hoisted(() => ({
+ subscription: null as SubscriptionRow | null,
+ subscriptionLoading: false,
+ plan: null as Plan | null,
+}));
+
+vi.mock('./useBillingContext.js', () => ({
+ useBillingContext: () => ({
+ ...baseBillingContextExtras(),
+ subscription: hp.subscription,
+ subscriptionLoading: hp.subscriptionLoading,
+ }),
+}));
+
+vi.mock('./usePlan.js', () => ({
+ usePlan: () => ({
+ data: hp.plan,
+ loading: false,
+ error: null,
+ }),
+}));
+
+describe('useBillingState', () => {
+ beforeEach(() => {
+ hp.subscription = testSubscription();
+ hp.subscriptionLoading = false;
+ hp.plan = testPlan();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('returns loading when subscription is loading', () => {
+ hp.subscriptionLoading = true;
+ const { result } = renderHook(() => useBillingState());
+ expect(result.current.kind).toBe('loading');
+ });
+
+ it('returns no_subscription when row missing', () => {
+ hp.subscription = null;
+ const { result } = renderHook(() => useBillingState());
+ expect(result.current.kind).toBe('no_subscription');
+ });
+
+ it('classifies free status', () => {
+ hp.subscription = testSubscription({ status: 'free' });
+ const { result } = renderHook(() => useBillingState());
+ expect(result.current.kind).toBe('free');
+ });
+
+ it('classifies past_due as payment_failed', () => {
+ hp.subscription = testSubscription({ status: 'past_due' });
+ const { result } = renderHook(() => useBillingState());
+ expect(result.current.kind).toBe('payment_failed');
+ });
+
+ it('classifies trialing with soon trial_end as trial_ending', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2025-01-01T12:00:00.000Z'));
+ hp.subscription = testSubscription({
+ status: 'trialing',
+ trial_end: '2025-01-02T12:00:00.000Z',
+ });
+ const { result } = renderHook(() => useBillingState());
+ expect(result.current.kind).toBe('trial_ending');
+ });
+
+ it('classifies trialing with distant trial_end', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2025-01-01T12:00:00.000Z'));
+ hp.subscription = testSubscription({
+ status: 'trialing',
+ trial_end: '2025-02-01T12:00:00.000Z',
+ });
+ const { result } = renderHook(() => useBillingState());
+ expect(result.current.kind).toBe('trialing');
+ });
+
+ it('classifies cancel_at_period_end without pending target as cancelled_pending', () => {
+ hp.subscription = testSubscription({
+ status: 'active',
+ cancel_at_period_end: true,
+ pending_target_plan_id: null,
+ });
+ const { result } = renderHook(() => useBillingState());
+ expect(result.current.kind).toBe('cancelled_pending');
+ });
+
+ it('classifies cancel_at_period_end with pending target as downgrade_pending', () => {
+ hp.subscription = testSubscription({
+ status: 'active',
+ cancel_at_period_end: true,
+ pending_target_plan_id: 'plan_free',
+ });
+ const { result } = renderHook(() => useBillingState());
+ expect(result.current.kind).toBe('downgrade_pending');
+ });
+
+ it('treats active without stripe subscription as free', () => {
+ hp.subscription = testSubscription({
+ status: 'active',
+ stripe_subscription_id: null,
+ });
+ const { result } = renderHook(() => useBillingState());
+ expect(result.current.kind).toBe('free');
+ });
+
+ it('classifies active with stripe as paid_active', () => {
+ hp.subscription = testSubscription({
+ status: 'active',
+ stripe_subscription_id: 'sub_123',
+ });
+ const { result } = renderHook(() => useBillingState());
+ expect(result.current.kind).toBe('paid_active');
+ });
+
+ it('maps canceled to free', () => {
+ hp.subscription = testSubscription({ status: 'canceled' });
+ const { result } = renderHook(() => useBillingState());
+ expect(result.current.kind).toBe('free');
+ });
+
+ it('classifies paused as paid_active', () => {
+ hp.subscription = testSubscription({ status: 'paused' });
+ const { result } = renderHook(() => useBillingState());
+ expect(result.current.kind).toBe('paid_active');
+ });
+
+ it('passes plan through from usePlan', () => {
+ hp.plan = testPlan({ display_name: 'Pro' });
+ const { result } = renderHook(() => useBillingState());
+ expect(result.current.plan?.display_name).toBe('Pro');
+ });
+
+ it('defaults unrecognized status to paid_active', () => {
+ hp.subscription = testSubscription({ status: 'unknown_status' });
+ const { result } = renderHook(() => useBillingState());
+ expect(result.current.kind).toBe('paid_active');
+ });
+});
diff --git a/packages/billing/src/hooks/useBillingState.ts b/packages/billing/src/hooks/useBillingState.ts
new file mode 100644
index 00000000..d29ebab1
--- /dev/null
+++ b/packages/billing/src/hooks/useBillingState.ts
@@ -0,0 +1,96 @@
+import { useMemo } from 'react';
+import type { ProductBillingConfig } from '../schema.js';
+import type { Plan, SubscriptionRow } from '../types.js';
+import { useBillingContext } from './useBillingContext.js';
+import { usePlan } from './usePlan.js';
+
+/**
+ * Coarse UI states for billing pages (align with billing UI v1 spec matrix).
+ * `downgrade_pending`: `cancel_at_period_end` with `pending_target_plan_id` set (e.g. app-initiated downgrade to Free).
+ * Paid→paid tier changes at period end without cancel are not implemented yet (Stripe updates apply immediately).
+ */
+export type BillingUiStateKind =
+ | 'loading'
+ | 'no_subscription'
+ | 'free'
+ | 'paid_active'
+ | 'cancelled_pending'
+ | 'payment_failed'
+ | 'trialing'
+ | 'trial_ending'
+ | 'downgrade_pending';
+
+export type BillingUiState = {
+ kind: BillingUiStateKind;
+ plan: Plan | null;
+ subscription: SubscriptionRow | null;
+};
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+function deriveKind(
+ subscription: SubscriptionRow | null,
+ subscriptionLoading: boolean
+): BillingUiStateKind {
+ if (subscriptionLoading) return 'loading';
+ if (!subscription) return 'no_subscription';
+
+ const st = subscription.status.toLowerCase();
+ if (st === 'free') return 'free';
+ if (st === 'past_due') return 'payment_failed';
+
+ if (st === 'trialing') {
+ if (subscription.trial_end) {
+ const end = new Date(subscription.trial_end).getTime();
+ if (
+ !Number.isNaN(end) &&
+ end > Date.now() &&
+ end - Date.now() < 3 * MS_PER_DAY
+ ) {
+ return 'trial_ending';
+ }
+ }
+ return 'trialing';
+ }
+
+ if (subscription.cancel_at_period_end) {
+ if (subscription.pending_target_plan_id) {
+ return 'downgrade_pending';
+ }
+ return 'cancelled_pending';
+ }
+
+ if (st === 'active' && !subscription.stripe_subscription_id) {
+ return 'free';
+ }
+
+ if (
+ st === 'active' ||
+ st === 'paused' ||
+ st === 'incomplete' ||
+ st === 'unpaid'
+ ) {
+ return 'paid_active';
+ }
+
+ if (st === 'canceled') {
+ return 'free';
+ }
+
+ return 'paid_active';
+}
+
+export function useBillingState<
+ Config extends ProductBillingConfig,
+>(): BillingUiState {
+ const { subscription, subscriptionLoading } = useBillingContext ();
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState ();
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState
+ {plans.map(p => {
+ const isCurrent =
+ (highlightCurrent && currentPlan?.id === p.id) ||
+ (highlightPlanId != null &&
+ highlightPlanId !== '' &&
+ p.id === highlightPlanId);
+ return (
+
+ )}
+