From 2750fbdb2b14ed92d6f61411cc4316d7af91e162 Mon Sep 17 00:00:00 2001 From: Michael Ryaboy Date: Sun, 27 Sep 2026 00:39:09 -0700 Subject: [PATCH] Fold auto recharge into a settings dialog with a design-system switch The sprawling on-page panel with a bare native checkbox becomes one row in Billing details (current settings and month usage at a glance, Set up/Edit) and a compact dialog using the shadcn Base UI Switch. Co-Authored-By: Claude Fable 5 --- src/components/ui/switch.tsx | 29 +++ src/features/billing/credits.tsx | 363 +++++++++++++++++-------------- tests/payment-method.test.ts | 2 +- 3 files changed, 225 insertions(+), 169 deletions(-) create mode 100644 src/components/ui/switch.tsx diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..57c7c26 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +import { Switch as SwitchPrimitive } from "@base-ui/react/switch" +import { cn } from "@/lib/utils" + +function Switch({ + className, + size = "default", + ...props +}: SwitchPrimitive.Root.Props & { + size?: "sm" | "default" +}) { + return ( + + + + ) +} + +export { Switch } diff --git a/src/features/billing/credits.tsx b/src/features/billing/credits.tsx index f7be738..8fa36b6 100644 --- a/src/features/billing/credits.tsx +++ b/src/features/billing/credits.tsx @@ -13,6 +13,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; import { ArrowRight, CreditCard } from "@/components/ui/icons"; import { BILLING_PLANS, @@ -52,14 +53,6 @@ const brandName = (brand: string) => CARD_BRANDS[brand] ?? brand.replaceAll("_", " ").replace(/^./, (c) => c.toUpperCase()); -function failureMessage(reason: string) { - if (reason === "payment_method_required") - return "Your last automatic recharge needs a saved payment method."; - if (reason === "payment_failed" || reason === "3ds_required") - return "Your last automatic recharge could not charge the saved payment method."; - return "Your last automatic recharge did not complete."; -} - export function Credits({ snapshot, navigate, @@ -76,23 +69,17 @@ export function Credits({ const [topUpAmount, setTopUpAmount] = useState("25"); const [topUpBusy, setTopUpBusy] = useState(false); const [topUpError, setTopUpError] = useState(""); + const [autoOpen, setAutoOpen] = useState(false); const [autoEnabled, setAutoEnabled] = useState(billing.autoTopUp.enabled); - const [autoAmount, setAutoAmount] = useState( - String(billing.autoTopUp.amountCents / 100), - ); - const [autoThreshold, setAutoThreshold] = useState( - String(billing.autoTopUp.thresholdCents / 100), - ); - const [autoCap, setAutoCap] = useState( - billing.autoTopUp.capCents > 0 ? String(billing.autoTopUp.capCents / 100) : "", - ); + const [autoAmount, setAutoAmount] = useState("10"); + const [autoThreshold, setAutoThreshold] = useState("5"); + const [autoCap, setAutoCap] = useState(""); const [autoBusy, setAutoBusy] = useState(false); const [autoError, setAutoError] = useState(""); - const [autoSaved, setAutoSaved] = useState(false); const refreshed = useRef(false); useEffect(() => { - // Returning from a top-up checkout: verify the paid invoice right away - // instead of waiting for a webhook, then reload the snapshot once. + // Returning from a checkout or payment setup: verify with the provider + // right away instead of waiting for a webhook, then reload the snapshot. if (refreshed.current || !act) return; if (!new URLSearchParams(window.location.search).has("billing")) return; refreshed.current = true; @@ -144,6 +131,18 @@ export function Credits({ setTopUpBusy(false); } } + function openAutoRecharge() { + setAutoEnabled(billing.autoTopUp.enabled); + setAutoAmount(String(billing.autoTopUp.amountCents / 100)); + setAutoThreshold(String(billing.autoTopUp.thresholdCents / 100)); + setAutoCap( + billing.autoTopUp.capCents > 0 + ? String(billing.autoTopUp.capCents / 100) + : "", + ); + setAutoError(""); + setAutoOpen(true); + } async function saveAutoTopUp() { const amount = dollarsToCents(autoAmount); const threshold = dollarsToCents(autoThreshold); @@ -164,7 +163,6 @@ export function Credits({ } setAutoBusy(true); setAutoError(""); - setAutoSaved(false); try { const { updateAutoTopUp } = await import("./billing.functions"); await updateAutoTopUp({ @@ -175,8 +173,8 @@ export function Credits({ capCents: cap, }, }); - setAutoSaved(true); await act?.({ type: "refresh" }).catch(() => {}); + setAutoOpen(false); } catch (cause) { setAutoError( cause instanceof Error @@ -196,6 +194,7 @@ export function Credits({ const paymentMethod = billing.paymentMethod; // null means the card on file has not been observed yet; never block on it. const missingCard = paymentMethod?.type === "none"; + const autoRecharge = billing.autoTopUp; const free = billing.plan === "free"; const allowance = snapshot.credits.included; const allowanceDescription = free @@ -310,152 +309,6 @@ export function Credits({ - {payAsYouGo && ( -
-

- Auto recharge -

- - -
-
- -

- Once your balance falls below the threshold, the saved - payment method is charged the recharge amount. Purchased - funds never expire. -

-
- setAutoEnabled(event.target.checked)} - /> -
- {missingCard && ( - - Add a payment method first - -

- Automatic recharges charge your saved payment method, so - one must be on file before auto recharge can be enabled. -

- {canManage && ( - - )} -
-
- )} - {billing.autoTopUp.lastFailure && !missingCard && ( - - Automatic recharge needs attention - -

- {failureMessage(billing.autoTopUp.lastFailure.reason)}{" "} - Retries pause for an hour after a failed charge. -

- {canManage && ( - - )} -
-
- )} -
-
- - setAutoAmount(event.target.value)} - /> -
-
- - setAutoThreshold(event.target.value)} - /> -
-
- - setAutoCap(event.target.value)} - /> -
-
-
-

- {formatCents(billing.autoTopUp.monthUsedCents)} recharged - automatically this month - {billing.autoTopUp.capCents > 0 - ? ` of the ${formatCents(billing.autoTopUp.capCents)} maximum.` - : "."} -

-
- {autoSaved && !autoError && ( -

Saved.

- )} - -
-
- {autoError && ( -

- {autoError} -

- )} - {!canManage && ( -

- Your workspace owner manages top-ups and auto recharge. -

- )} -
-
-
- )} -
+ {payAsYouGo && ( +
+
+ Auto recharge +
+
+ + {autoRecharge.enabled ? ( + <> + Adds{" "} + + {formatCents(autoRecharge.amountCents)} + {" "} + when the balance falls below{" "} + + {formatCents(autoRecharge.thresholdCents)} + + {autoRecharge.capCents > 0 && ( + <> + {" · "} + + {formatCents(autoRecharge.monthUsedCents)} of{" "} + {formatCents(autoRecharge.capCents)} + {" "} + used this month + + )} + + ) : ( + "Off" + )} + + {canManage && ( + + )} +
+
+ )}
Payment method @@ -535,6 +428,28 @@ export function Credits({
+ {payAsYouGo && autoRecharge.enabled && autoRecharge.lastFailure && ( + + Automatic recharge needs attention + +

+ The last automatic recharge could not be completed. Retries + pause for an hour after a failed charge. +

+ {canManage && ( + + )} +
+
+ )}
+ + { + if (!autoBusy) setAutoOpen(open); + }} + > + + + Auto recharge + + When your balance falls below the threshold, the saved payment + method is charged the recharge amount. Purchased funds never + expire. + + + {missingCard ? ( + + Add a payment method first + +

+ Automatic recharges charge your saved payment method, so one + must be on file before auto recharge can be enabled. +

+ +
+
+ ) : ( + <> +
+ + +
+
+
+ + setAutoAmount(event.target.value)} + /> +
+
+ + setAutoThreshold(event.target.value)} + /> +
+
+ + setAutoCap(event.target.value)} + /> +

+ {formatCents(autoRecharge.monthUsedCents)} recharged + automatically this month. +

+
+
+ {autoError && ( +

+ {autoError} +

+ )} + + + + + + )} +
+
); } diff --git a/tests/payment-method.test.ts b/tests/payment-method.test.ts index 294f20f..eaa8930 100644 --- a/tests/payment-method.test.ts +++ b/tests/payment-method.test.ts @@ -83,7 +83,7 @@ test("the billing page shows the card on file and gates auto recharge until one const withoutCard = credits({ type: "none" }); expect(withoutCard).toContain("No payment method on file"); expect(withoutCard).toContain("Add payment method"); - expect(withoutCard).toContain("Add a payment method first"); + expect(withoutCard).toContain("Set up"); // Unknown state (never reconciled) neither blocks nor claims a card exists. const unknown = credits(null); expect(unknown).not.toContain("Add payment method");