Billing upgrade card
A dashboard card showing the current plan with an upgrade or manage-billing CTA, wired to the billing router.
A dashboard card showing the org's current plan and a CTA to upgrade or manage
billing. On a Free org it opens Stripe Checkout for the next paid plan; on a paid
org it opens the billing portal. It reads the subscription through the billing
router; plan names come from the typed @/config/plans source of truth.
Requires: billing
Preview
"use client";import { Button } from "@/components/ui/button";import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle,} from "@/components/ui/card";import { FREE_PLAN_KEY, PLANS } from "@/config/plans";import { trpc } from "@/lib/trpc";/** * A dashboard card showing the org's current plan and a CTA to upgrade or manage * billing. On a Free org it opens Stripe Checkout for the next paid plan; on a * paid org it opens the billing portal. Reads the subscription through the billing * router; plan names come from the typed `@/config/plans` source of truth. * `getSubscription` is owner-only server-side, so a non-owner sees the loading * state resolve to the Free view. */export function UpgradeCard() { const subscription = trpc.billing.getSubscription.useQuery(); const checkout = trpc.billing.createCheckoutSession.useMutation({ onSuccess: ({ url }) => { window.location.href = url; }, }); const portal = trpc.billing.createPortalSession.useMutation({ onSuccess: ({ url }) => { window.location.href = url; }, }); const currentPlanKey = subscription.data?.planKey ?? FREE_PLAN_KEY; const isFree = currentPlanKey === FREE_PLAN_KEY; const currentPlan = PLANS.find((plan) => plan.key === currentPlanKey); const nextPaidPlan = PLANS.find((plan) => plan.key !== FREE_PLAN_KEY); const busy = checkout.isPending || portal.isPending; return ( <Card> <CardHeader> <CardTitle>Your plan</CardTitle> <CardDescription> {subscription.isPending ? "Loading your subscription…" : `You are on the ${currentPlan?.name ?? "Free"} plan.`} </CardDescription> </CardHeader> <CardContent className="text-sm text-muted-foreground"> {isFree && nextPaidPlan ? `Upgrade to ${nextPaidPlan.name} for more members, projects, and API access.` : "Manage your subscription, payment method, and invoices in the billing portal."} </CardContent> <CardFooter> {isFree && nextPaidPlan ? ( <Button disabled={busy} onClick={() => checkout.mutate({ planKey: nextPaidPlan.key })} > {checkout.isPending ? "Redirecting…" : `Upgrade to ${nextPaidPlan.name}`} </Button> ) : ( <Button variant="secondary" disabled={busy} onClick={() => portal.mutate({})} > {portal.isPending ? "Redirecting…" : "Manage billing"} </Button> )} </CardFooter> </Card> );}Offline preview
The docs site has no backend, so the subscription query never resolves: the
card's body and "Upgrade" CTA take the Free-plan branch, while the description
line stays in its real "Loading…" state. In an app the data comes from the
billing router.
Install
npx create-saas-starter add upgrade-cardRun it from your app's root. add only writes new files — wiring is up to you:
import { UpgradeCard } from "@/components/blocks/upgrade-card/upgrade-card";