zazzy
Blocks

Plan comparison grid

A plan-comparison matrix from PLANS with the current plan highlighted and checkout/portal CTAs, wired to the billing router. Degrades to the plain grid for non-owners.

A side-by-side plan-comparison matrix built straight off PLANS — the single pricing source of truth — with a row per entitlement (members, projects, storage, capability flags) so every plan column shares row order. Each column carries the plan's headline price and a call to action.

billing.getSubscription marks the active org's current plan (the highlighted column) and drives the CTAs: the current paid plan opens the Stripe Customer Portal, any other paid plan starts Checkout. That query is owner-only, so a non-owner's query fails with FORBIDDEN; rather than crash or leak the error, the block degrades to the plain grid — no highlight, no CTAs.

Requires: billing

Preview

Plan comparison
Free
Free
ProCurrent
$29/mo
Scale
$15/seat/mo + usage
Members2 members10 membersUnlimited
Projects1 projects10 projectsUnlimited
Storage100 MB5 GBUnlimited
API access
Custom domain
Priority support
"use client";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";import { FREE_PLAN_KEY, PLANS } from "@/config/plans";import { formatPlanPrice } from "@/features/billing/lib/format";import { trpc } from "@/lib/trpc";import { planComparisonRows, type PlanFeatureCell } from "./plan-features";/** Render one matrix cell: a value string, or a ✓/— capability flag. */function CellValue({ cell }: { cell: PlanFeatureCell }) {  if (cell.kind === "value") return <>{cell.text}</>;  return (    <span aria-label={cell.on ? "Included" : "Not included"}>      {cell.on ? "✓" : "—"}    </span>  );}/** * A plan-comparison matrix wired to the `billing` router. Columns come from * `PLANS` — the single pricing source of truth (D-004), the `upgrade-card` * precedent — with each plan's headline price (billing's `formatPlanPrice`) and * an entitlement grid (members, projects, storage, capability flags) shared row * order via the pure `planComparisonRows` transform. * * `billing.getSubscription` marks the active org's current plan (highlighted * column) and drives the CTAs: a paid current plan opens the Customer Portal * (`createPortalSession`), any other paid plan starts Checkout * (`createCheckoutSession`). That query is **owner-only** server-side, so a * non-owner viewer's query returns `FORBIDDEN`; per D-096 the block degrades to * the plain grid — no highlight, no CTAs — rather than crash or leak the error. */export function PlanComparisonGrid({ className }: { className?: string }) {  const subscription = trpc.billing.getSubscription.useQuery(undefined, {    retry: false,  });  const checkout = trpc.billing.createCheckoutSession.useMutation({    onSuccess: ({ url }) => {      window.location.href = url;    },  });  const portal = trpc.billing.createPortalSession.useMutation({    onSuccess: ({ url }) => {      window.location.href = url;    },  });  // getSubscription is owner-only: a FORBIDDEN means the viewer isn't the owner,  // so we render the plain grid with no highlight and no owner-only CTAs (D-096).  const forbidden = subscription.error?.data?.code === "FORBIDDEN";  const showControls = !forbidden;  const currentPlanKey = forbidden    ? null    : subscription.data      ? subscription.data.planKey      : subscription.isPending        ? null        : FREE_PLAN_KEY;  const rows = planComparisonRows(PLANS);  const busy = checkout.isPending || portal.isPending || subscription.isPending;  function cta(planKey: string, planName: string, isPaid: boolean) {    if (!showControls) return null;    const isCurrent = currentPlanKey !== null && planKey === currentPlanKey;    if (isCurrent) {      return isPaid ? (        <Button          variant="secondary"          size="sm"          disabled={busy}          onClick={() => portal.mutate({})}        >          {portal.isPending ? "Redirecting…" : "Manage billing"}        </Button>      ) : (        <Button variant="secondary" size="sm" disabled>          Current plan        </Button>      );    }    // A non-current free plan is a downgrade target reached through the portal on    // the current paid plan — no Checkout to offer, so it carries no CTA.    if (!isPaid) return null;    return (      <Button        size="sm"        disabled={busy}        onClick={() => checkout.mutate({ planKey })}      >        {checkout.isPending ? "Redirecting…" : `Choose ${planName}`}      </Button>    );  }  const isHighlighted = (planKey: string) =>    showControls && currentPlanKey !== null && planKey === currentPlanKey;  return (    <div className={cn("w-full overflow-x-auto", className)}>      <table className="w-full border-collapse text-sm">        <caption className="sr-only">Plan comparison</caption>        <thead>          <tr>            <th scope="col" className="w-40" />            {PLANS.map((plan) => (              <th                key={plan.key}                scope="col"                className={cn(                  "border-b p-3 text-left align-bottom",                  isHighlighted(plan.key) && "bg-muted",                )}              >                <div className="flex flex-col gap-1">                  <div className="flex items-center gap-2">                    <span className="text-base font-semibold tracking-tight">                      {plan.name}                    </span>                    {isHighlighted(plan.key) && (                      <span className="rounded-full bg-foreground px-2 py-0.5 text-xs font-medium text-background">                        Current                      </span>                    )}                  </div>                  <span className="text-lg font-semibold tracking-tight">                    {formatPlanPrice(plan)}                  </span>                </div>              </th>            ))}          </tr>        </thead>        <tbody>          {rows.map((row) => (            <tr key={row.label}>              <th                scope="row"                className="border-b p-3 text-left font-medium text-muted-foreground"              >                {row.label}              </th>              {row.cells.map((cell, i) => (                <td                  key={PLANS[i].key}                  className={cn(                    "border-b p-3",                    isHighlighted(PLANS[i].key) && "bg-muted",                  )}                >                  <CellValue cell={cell} />                </td>              ))}            </tr>          ))}        </tbody>        <tfoot>          <tr>            <td />            {PLANS.map((plan) => (              <td                key={plan.key}                className={cn("p-3", isHighlighted(plan.key) && "bg-muted")}              >                {cta(plan.key, plan.name, plan.prices.length > 0)}              </td>            ))}          </tr>        </tfoot>      </table>    </div>  );}

Offline preview

The docs site has no backend, so the preview seeds a subscription on the Pro plan to show the highlighted column and its "Manage billing" CTA. In an app the current plan comes from the billing router; a non-owner instead sees the plain grid, and the CTAs open Checkout / the Customer Portal.

Install

npx create-saas-starter add plan-comparison-grid

Run it from your app's root. add only writes new files — wiring is up to you:

import { PlanComparisonGrid } from "@/components/blocks/plan-comparison-grid/plan-comparison-grid";

On this page