zazzy
Blocks

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

Your plan
Loading your subscription…
Upgrade to Pro for more members, projects, and API access.
"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-card

Run 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";

On this page