diff --git a/web/src/app/app/settings/billing/page.tsx b/web/src/app/app/settings/billing/page.tsx index 3b513b45..948a948f 100644 --- a/web/src/app/app/settings/billing/page.tsx +++ b/web/src/app/app/settings/billing/page.tsx @@ -1,4 +1,7 @@ -// Billing — owner-only, organization-scoped, flat layout. +// Billing — owner-only, organization-scoped, split into tabs so the page is +// browsable: Overview (plan + usage), Plans (compare/promo), AI & credits, +// and Payment. The active tab lives in the ?tab= search param so returns from +// Stripe (?topup=success) land back on the right tab and links can deep-link. // // Plan data lives in `lib/plans` so the dashboard mirrors warmbly-web // exactly — every value here (limits, descriptions, bullets) comes @@ -11,13 +14,16 @@ import { CreditCardIcon, ExternalLinkIcon, FileTextIcon, + GaugeIcon, + LayersIcon, Loader2Icon, LockIcon, SparklesIcon, TicketIcon, XIcon, } from "lucide-react"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; +import { AnimatePresence, motion } from "framer-motion"; import toast from "react-hot-toast"; import { TopbarAction } from "@/components/layout/Page"; import useFeatureAccess from "@/hooks/useFeatureAccess"; @@ -36,6 +42,7 @@ import type { DiscountRedemption } from "@/lib/api/models/app/subscription/Disco import type ServerPlan from "@/lib/api/models/app/subscription/Plan"; import buildError from "@/lib/helper/buildError"; import { TextInput } from "@/components/ui/field"; +import { AnimatedNumber, DitherMeter, type DitherTone } from "@/components/ui/dither"; import { Row, Section, SectionShell, TableSurface } from "../_components/SectionShell"; import { PLAN_ACCENT_CLASSES, PAID_PLANS, getPlan, type PlanID } from "@/lib/plans"; import CreditsCard from "./CreditsCard"; @@ -43,6 +50,19 @@ import AIUsageCard from "./AIUsageCard"; type BillingInterval = "monthly" | "annual"; +type BillingTab = "overview" | "plans" | "ai" | "payment"; + +const TABS: { id: BillingTab; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ + { id: "overview", label: "Overview", icon: GaugeIcon }, + { id: "plans", label: "Plans", icon: LayersIcon }, + { id: "ai", label: "AI & credits", icon: SparklesIcon }, + { id: "payment", label: "Payment", icon: CreditCardIcon }, +]; + +function isBillingTab(v: string | null): v is BillingTab { + return v === "overview" || v === "plans" || v === "ai" || v === "payment"; +} + export default function BillingSettingsPage() { const access = useFeatureAccess(); const sub = useSubscription(); @@ -54,11 +74,30 @@ export default function BillingSettingsPage() { const plansQuery = usePlans(); const redemptions = useAppliedDiscounts(); const usage = useUsageOverview().data; + const [searchParams, setSearchParams] = useSearchParams(); const [codeInput, setCodeInput] = React.useState(""); const [applied, setApplied] = React.useState(null); const [billingInterval, setBillingInterval] = React.useState("annual"); + // A ?topup= return from Stripe Checkout lands on the AI tab it left from. + const spTab = searchParams.get("tab"); + const tab: BillingTab = isBillingTab(spTab) + ? spTab + : searchParams.get("topup") + ? "ai" + : "overview"; + const setTab = (t: BillingTab) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set("tab", t); + return next; + }, + { replace: true }, + ); + }; + if (!access.loading && !access.isOwner) { return ( @@ -208,271 +247,325 @@ export default function BillingSettingsPage() { } > -
- {sub.isPending ? ( -
- ) : ( -
-
- -
-
-
- - {currentPlan.label} - - -
-

- {currentPlan.description} -

- {periodEnd && status !== "canceled" && ( -
- {cancelAtEnd ? "Ends on " : "Renews on "} - - {periodEnd.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - })} - -
- )} -
- -
- )} -
    - {currentPlan.bullets.map((b) => ( -
  • - - {b} -
  • - ))} -
-
- -
- -
-
- setCodeInput(v.toUpperCase())} - placeholder="WELCOME10" - disabled={!!applied} - onKeyDown={(e) => { - if (e.key === "Enter") applyCode(); - }} - className="w-full sm:w-[180px] font-mono uppercase" - /> - {applied ? ( - + ); + })} +
+ + + {tab === "overview" && ( + <> +
- - Clear - - ) : ( - +
)} - Apply - - )} - - {applied && ( -
- - {applied.code} - · {describeDiscount(applied)} -
+
    + {currentPlan.bullets.map((b) => ( +
  • + + {b} +
  • + ))} +
+
+ +
+ + + + +
+ )} - - - -
- {redemptions.isPending ? ( -
- ) : (redemptions.data?.data.length ?? 0) === 0 ? ( -

- No codes redeemed yet. Apply a code above to see it here. -

- ) : ( - - - - - - - - - - - - {(redemptions.data?.data ?? []).map((d) => ( - - ))} - -
CodeDiscountStatusRedeemed
-
- )} -
+ {tab === "plans" && ( + <> +
+
+ + {billingInterval === "annual" + ? "Annual billing — save 20%." + : "Monthly billing."} + + +
+
+ {PAID_PLANS.map((id) => ( + upgrade(id)} + /> + ))} +
+ + + Open full pricing page + +
-
-
- - {billingInterval === "annual" - ? "Annual billing — save 20%." - : "Monthly billing."} - - -
-
- {PAID_PLANS.map((id) => ( - upgrade(id)} - /> - ))} -
- - - Open full pricing page - -
+
+ +
+
+ setCodeInput(v.toUpperCase())} + placeholder="WELCOME10" + disabled={!!applied} + onKeyDown={(e) => { + if (e.key === "Enter") applyCode(); + }} + className="w-full sm:w-[180px] font-mono uppercase" + /> + {applied ? ( + + ) : ( + + )} +
+ {applied && ( +
+ + {applied.code} + · {describeDiscount(applied)} +
+ )} +
+
+
-
- - - - -
+
+ {redemptions.isPending ? ( +
+ ) : (redemptions.data?.data.length ?? 0) === 0 ? ( +

+ No codes redeemed yet. Apply a code above to see it here. +

+ ) : ( + + + + + + + + + + + + {(redemptions.data?.data ?? []).map((d) => ( + + ))} + +
CodeDiscountStatusRedeemed
+
+ )} +
+ + )} - + {tab === "ai" && ( + <> + + + + )} - + {tab === "payment" && ( + <> +
+ +
+ + + No card on file + + +
+
+ +
+ Owner's account email + +
+
+
-
- -
- - - No card on file - - -
-
- -
- Owner's account email - -
-
-
- -
-

- Invoices live in Stripe's billing portal. Open the portal to download - PDF receipts. -

- -
+
+

+ Invoices live in Stripe's billing portal. Open the portal to download + PDF receipts. +

+ +
+ + )} + + +
); } @@ -676,23 +769,17 @@ function UsageRow({ typeof max === "number" && max > 0 ? Math.min(100, Math.round((current / max) * 100)) : 0; + const tone: DitherTone = pct >= 90 ? "rose" : pct >= 70 ? "amber" : "slate"; return (
{label} - {current.toLocaleString()} + / {typeof max === "number" ? max.toLocaleString() : max}
-
-
= 90 ? "bg-red-500" : pct >= 70 ? "bg-amber-500" : "bg-slate-900" - } transition-all`} - style={{ width: `${pct}%` }} - /> -
+
); } diff --git a/web/src/components/layout/CreditsMeter.tsx b/web/src/components/layout/CreditsMeter.tsx index 5b6e77f9..195268db 100644 --- a/web/src/components/layout/CreditsMeter.tsx +++ b/web/src/components/layout/CreditsMeter.tsx @@ -124,7 +124,7 @@ export function CreditsMeter() { >