From e2951f8e947814c7dafe42ca0b70daa2e8d29da6 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 10 Sep 2026 19:08:08 +0000 Subject: [PATCH 1/2] fix(frontend): keep billing limit banner above settings drawer and show plan exhausted card Amp-Thread-ID: https://ampcode.com/threads/T-01a08c94-38c3-73bc-b299-ca37f85109d4 --- .../src/app/billing/billing-limit-alert.tsx | 91 +++++++++++-------- frontend/src/app/settings-drawer.tsx | 21 ++++- .../src/app/settings-pages/billing-panel.tsx | 83 ++++++++++++++++- 3 files changed, 154 insertions(+), 41 deletions(-) diff --git a/frontend/src/app/billing/billing-limit-alert.tsx b/frontend/src/app/billing/billing-limit-alert.tsx index 0a0aaaae90..1365fdb8a2 100644 --- a/frontend/src/app/billing/billing-limit-alert.tsx +++ b/frontend/src/app/billing/billing-limit-alert.tsx @@ -1,68 +1,85 @@ import { faExclamationTriangle, Icon } from "@rivet-gg/icons"; -import { useQuery } from "@tanstack/react-query"; +import { skipToken, useQuery } from "@tanstack/react-query"; import { Link, useMatch } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; -import { useEffect } from "react"; import { Button, cn } from "@/components"; -import { useCloudProjectDataProvider } from "@/components/actors"; import { PLAN_LABELS } from "@/content/billing"; import { features } from "@/lib/features"; -import { useHighestUsagePercent } from "./hooks"; -// Fixed banner height (Tailwind `h-9`). Published as a CSS variable so the -// settings drawer, a fixed overlay anchored under the top bar, can start below -// the banner instead of behind it. -const BANNER_HEIGHT = "2.25rem"; +// Fixed banner height (Tailwind `h-9`). Exported so fixed overlays anchored +// under the top bar (the settings drawer) can start below the banner instead +// of behind it. +export const BILLING_BANNER_HEIGHT = "2.25rem"; -export function BillingLimitAlert() { - if (!features.billing) return null; - return ; +export interface BillingLimitBannerState { + /** The banner is showing: free plan at or above 80% of its included usage. */ + visible: boolean; + /** Usage has hit or passed 100% of the plan's included allotment. */ + atLimit: boolean; + plan: string; + usagePercent: number; } -// Billing is project-scoped, but this banner renders from the shared route -// layout. `useMatch` with `shouldThrow: false` keeps it out of routes where -// `useCloudProjectDataProvider` would throw, and waiting on `loaderData` -// avoids reading the provider before the project loader resolves. -function BillingLimitAlertGuard() { +/** + * Resolves the free-plan usage banner state. + * + * Both the banner itself and anything that must lay out around it (the + * settings drawer offsets its top edge by the banner height) call this, so + * they derive the same answer from the same query data during render. A side + * channel such as a CSS variable set from an effect would let the two + * disagree, leaving the banner painted behind the drawer. + * + * Billing is project-scoped, but callers render from the shared route layout + * and the `_context` route, where `useCloudProjectDataProvider` would throw. + * The provider is read off the project match instead, and the queries are + * skipped until it resolves, so the hook is safe on every route and keeps a + * stable tree shape (no outer/inner split that would remount the caller when + * the project loader lands). Off a project route the banner is hidden. + */ +export function useBillingLimitBanner(): BillingLimitBannerState { const projectMatch = useMatch({ from: "/_context/orgs/$organization/projects/$project", shouldThrow: false, }); + const dataProvider = features.billing + ? projectMatch?.loaderData?.dataProvider + : undefined; - if (!projectMatch?.loaderData) return null; - - return ; -} - -function BillingLimitAlertInner() { - const dataProvider = useCloudProjectDataProvider(); + const detailsOptions = + dataProvider?.currentProjectBillingDetailsQueryOptions(); const { data: billingData } = useQuery({ - ...dataProvider.currentProjectBillingDetailsQueryOptions(), + queryKey: detailsOptions?.queryKey ?? ["billing-details", "no-project"], + queryFn: detailsOptions?.queryFn ?? skipToken, }); - const usagePercent = useHighestUsagePercent(); + const usageOptions = dataProvider?.currentProjectBillingUsageQueryOptions(); + const { data: usage } = useQuery({ + queryKey: usageOptions?.queryKey ?? ["billing-usage", "no-project"], + queryFn: usageOptions?.queryFn ?? skipToken, + }); + + const usagePercent = usage?.highestPercent ?? 0; const plan = billingData?.billing.activePlan || "free"; - const hidden = plan !== "free" || usagePercent < 80; - useEffect(() => { - if (hidden) return; - const root = document.documentElement; - root.style.setProperty("--billing-banner-height", BANNER_HEIGHT); - return () => { - root.style.removeProperty("--billing-banner-height"); - }; - }, [hidden]); + return { + visible: !!dataProvider && plan === "free" && usagePercent >= 80, + atLimit: usagePercent >= 100, + plan, + usagePercent, + }; +} - const atLimit = usagePercent >= 100; +export function BillingLimitAlert() { + const { visible, atLimit, plan, usagePercent } = useBillingLimitBanner(); // The usage figure comes from a slow backend scan, so the banner appears well // after the page loads. Expanding it in keeps the content below from jumping. return ( - {!hidden ? ( + {visible ? ( void; + /** + * Extra space to leave above the drawer for chrome rendered between the top + * bar and the content view, e.g. the billing limit banner. CSS length. + */ + topOffset?: string; } // TopBar is a flush `h-12` bar (48px, border included). Add the content view's @@ -129,6 +138,7 @@ export function SettingsDrawer({ open, tab, onOpenChange, + topOffset = "0px", }: SettingsDrawerProps) { const navigate = useNavigate(); const matchRoute = useMatchRoute(); @@ -190,9 +200,11 @@ export function SettingsDrawer({ "focus:outline-none", "data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + // Track the billing banner's expand/collapse animation. + "transition-[top] duration-[250ms] ease-out", )} style={{ - top: `calc(${TOP_BAR_OUTER_HEIGHT} + var(--billing-banner-height, 0px))`, + top: `calc(${TOP_BAR_OUTER_HEIGHT} + ${topOffset})`, bottom: "8px", }} onInteractOutside={(e) => e.preventDefault()} @@ -691,10 +703,17 @@ export function SettingsDrawerHost() { typeof search.settings === "string" ? search.settings : undefined; const tab = settingsParamToTab(param); + // The drawer is a fixed overlay under the top bar, so it must start below + // the billing limit banner when that is showing. Both derive the banner + // state from the same data rather than the banner publishing its height + // through a side channel. + const banner = useBillingLimitBanner(); + return ( { if (!open) { navigate({ diff --git a/frontend/src/app/settings-pages/billing-panel.tsx b/frontend/src/app/settings-pages/billing-panel.tsx index 4d0dea1cbe..2991483d75 100644 --- a/frontend/src/app/settings-pages/billing-panel.tsx +++ b/frontend/src/app/settings-pages/billing-panel.tsx @@ -2,6 +2,7 @@ import { faArrowUpRight, faBarcodeRead, faDatabase, + faExclamationTriangle, faInfoCircle, faPencil, faRunning, @@ -35,9 +36,9 @@ import { WithTooltip, } from "@/components"; import { useCloudProjectDataProvider } from "@/components/actors"; -import { features } from "@/lib/features"; import { TwinklingSparkles } from "@/components/twinkling-sparkles"; import { COMPUTE_MONTHLY_CAP_USD } from "@/content/billing"; +import { features } from "@/lib/features"; import { ResourcePicker } from "./resource-picker"; import { SettingsCard } from "./settings-card"; @@ -141,8 +142,29 @@ function BillingDrawerBody() { ? new Date(usage.currentPeriodEnd) : endOfMonth(new Date()); + // Same trigger as the free-plan limit banner (`highestPercent` covers the + // metered metrics and the compute budget). Paid plans just bill overage, so + // only the free plan gets the call-out. The names list which allotments ran + // out so the user knows what to look at in the table below. + const planExhausted = plan === "free" && usage.highestPercent >= 100; + const exhaustedNames = [ + ...USAGE_METRICS.filter( + (metric) => (metricsByKey.get(metric.key)?.percent ?? 0) >= 100, + ).map((metric) => metric.title), + ...(showCompute && usage.computeBudgetPercent >= 100 + ? ["Compute"] + : []), + ]; + return (
+ {planExhausted ? ( + setPlansOpen(true)} + /> + ) : null} +
void; +}) { + const what = + exhaustedNames.length > 0 + ? `${formatList(exhaustedNames)} ${exhaustedNames.length === 1 ? "has" : "have"} reached the Free plan's included limit for this billing period.` + : "This project has reached the Free plan's included limits for this billing period."; + return ( + +
+
+ +
+
+

+ You've used your entire Free plan +

+

+ {what} Upgrade to raise your limits and avoid service + interruptions. +

+
+ +
+
+ ); +} + +function formatList(items: string[]): string { + if (items.length <= 1) return items[0] ?? ""; + if (items.length === 2) return `${items[0]} and ${items[1]}`; + return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`; +} + function CurrentPlanCard({ plan, onUpgrade, @@ -433,11 +504,17 @@ function ComputeUsageRow({
- {loading ? : formatCurrency(cost)} + {loading ? ( + + ) : ( + formatCurrency(cost) + )}
- {capUsd != null ? `of ${formatCurrency(capUsd)}` : "No limit"} + {capUsd != null + ? `of ${formatCurrency(capUsd)}` + : "No limit"}
{capUsd != null ? (
From 1ead3f83ef8bdcf745e2b71b5e654a743efc39f8 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 10 Sep 2026 19:35:05 +0000 Subject: [PATCH 2/2] fix(frontend): set billing banner offset in a layout effect and stack it above the settings drawer Amp-Thread-ID: https://ampcode.com/threads/T-01a08c94-38c3-73bc-b299-ca37f85109d4 --- .../src/app/billing/billing-limit-alert.tsx | 98 ++++++++----------- frontend/src/app/settings-drawer.tsx | 25 +---- 2 files changed, 48 insertions(+), 75 deletions(-) diff --git a/frontend/src/app/billing/billing-limit-alert.tsx b/frontend/src/app/billing/billing-limit-alert.tsx index 1365fdb8a2..55cd3fde67 100644 --- a/frontend/src/app/billing/billing-limit-alert.tsx +++ b/frontend/src/app/billing/billing-limit-alert.tsx @@ -1,89 +1,77 @@ import { faExclamationTriangle, Icon } from "@rivet-gg/icons"; -import { skipToken, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Link, useMatch } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; +import { useLayoutEffect } from "react"; import { Button, cn } from "@/components"; +import { useCloudProjectDataProvider } from "@/components/actors"; import { PLAN_LABELS } from "@/content/billing"; import { features } from "@/lib/features"; +import { useHighestUsagePercent } from "./hooks"; -// Fixed banner height (Tailwind `h-9`). Exported so fixed overlays anchored -// under the top bar (the settings drawer) can start below the banner instead -// of behind it. -export const BILLING_BANNER_HEIGHT = "2.25rem"; +// Fixed banner height (Tailwind `h-9`). Published as a CSS variable so the +// settings drawer, a fixed overlay anchored under the top bar, can start below +// the banner instead of behind it. +const BANNER_HEIGHT = "2.25rem"; -export interface BillingLimitBannerState { - /** The banner is showing: free plan at or above 80% of its included usage. */ - visible: boolean; - /** Usage has hit or passed 100% of the plan's included allotment. */ - atLimit: boolean; - plan: string; - usagePercent: number; +export function BillingLimitAlert() { + if (!features.billing) return null; + return ; } -/** - * Resolves the free-plan usage banner state. - * - * Both the banner itself and anything that must lay out around it (the - * settings drawer offsets its top edge by the banner height) call this, so - * they derive the same answer from the same query data during render. A side - * channel such as a CSS variable set from an effect would let the two - * disagree, leaving the banner painted behind the drawer. - * - * Billing is project-scoped, but callers render from the shared route layout - * and the `_context` route, where `useCloudProjectDataProvider` would throw. - * The provider is read off the project match instead, and the queries are - * skipped until it resolves, so the hook is safe on every route and keeps a - * stable tree shape (no outer/inner split that would remount the caller when - * the project loader lands). Off a project route the banner is hidden. - */ -export function useBillingLimitBanner(): BillingLimitBannerState { +// Billing is project-scoped, but this banner renders from the shared route +// layout. `useMatch` with `shouldThrow: false` keeps it out of routes where +// `useCloudProjectDataProvider` would throw, and waiting on `loaderData` +// avoids reading the provider before the project loader resolves. +function BillingLimitAlertGuard() { const projectMatch = useMatch({ from: "/_context/orgs/$organization/projects/$project", shouldThrow: false, }); - const dataProvider = features.billing - ? projectMatch?.loaderData?.dataProvider - : undefined; - const detailsOptions = - dataProvider?.currentProjectBillingDetailsQueryOptions(); - const { data: billingData } = useQuery({ - queryKey: detailsOptions?.queryKey ?? ["billing-details", "no-project"], - queryFn: detailsOptions?.queryFn ?? skipToken, - }); + if (!projectMatch?.loaderData) return null; + + return ; +} - const usageOptions = dataProvider?.currentProjectBillingUsageQueryOptions(); - const { data: usage } = useQuery({ - queryKey: usageOptions?.queryKey ?? ["billing-usage", "no-project"], - queryFn: usageOptions?.queryFn ?? skipToken, +function BillingLimitAlertInner() { + const dataProvider = useCloudProjectDataProvider(); + const { data: billingData } = useQuery({ + ...dataProvider.currentProjectBillingDetailsQueryOptions(), }); - const usagePercent = usage?.highestPercent ?? 0; + const usagePercent = useHighestUsagePercent(); const plan = billingData?.billing.activePlan || "free"; + const hidden = plan !== "free" || usagePercent < 80; - return { - visible: !!dataProvider && plan === "free" && usagePercent >= 80, - atLimit: usagePercent >= 100, - plan, - usagePercent, - }; -} + // Layout effect so the drawer's `top` moves in the same paint the banner + // mounts in, instead of a frame where the drawer still sits behind it. + useLayoutEffect(() => { + if (hidden) return; + const root = document.documentElement; + root.style.setProperty("--billing-banner-height", BANNER_HEIGHT); + return () => { + root.style.removeProperty("--billing-banner-height"); + }; + }, [hidden]); -export function BillingLimitAlert() { - const { visible, atLimit, plan, usagePercent } = useBillingLimitBanner(); + const atLimit = usagePercent >= 100; // The usage figure comes from a slow backend scan, so the banner appears well // after the page loads. Expanding it in keeps the content below from jumping. return ( - {visible ? ( + {!hidden ? ( void; - /** - * Extra space to leave above the drawer for chrome rendered between the top - * bar and the content view, e.g. the billing limit banner. CSS length. - */ - topOffset?: string; } // TopBar is a flush `h-12` bar (48px, border included). Add the content view's @@ -138,7 +129,6 @@ export function SettingsDrawer({ open, tab, onOpenChange, - topOffset = "0px", }: SettingsDrawerProps) { const navigate = useNavigate(); const matchRoute = useMatchRoute(); @@ -195,16 +185,18 @@ export function SettingsDrawer({ e.preventDefault()} @@ -703,17 +695,10 @@ export function SettingsDrawerHost() { typeof search.settings === "string" ? search.settings : undefined; const tab = settingsParamToTab(param); - // The drawer is a fixed overlay under the top bar, so it must start below - // the billing limit banner when that is showing. Both derive the banner - // state from the same data rather than the banner publishing its height - // through a side channel. - const banner = useBillingLimitBanner(); - return ( { if (!open) { navigate({