RegistryUI recipes
Plan picker
The plans with their prices and a checkout button each. Checkout starts in a server action or API route that holds the Stripe key, and the client opens the URL it returns.
Both sides call usePlanPicker from better-supabase/blocks/billing/react; the billing block must be installed (npx better-supabase add billing). Its page on bettersupabase.com is /recipes/plan-picker.
Web, with shadcn components:
npx shadcn add button card"use client";
import type { PlanCatalog } from "better-supabase/blocks/billing/react";
import type { Money } from "better-supabase/commerce";
import { DbException } from "better-supabase";
import { usePlanPicker } from "better-supabase/blocks/billing/react";
import { formatMoney } from "better-supabase/commerce";
import { useExtracted, useLocale } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useErrorMessage } from "@/lib/use-error-message";
import type { PlanOption } from "../billing-queries";
import { loadPlanCatalog, startCheckout } from "../billing-actions";
async function loadCatalog(): Promise<PlanCatalog<PlanOption>> {
const result = await loadPlanCatalog({});
if (!result.ok) {
throw new DbException(result.error);
}
return result.data;
}
/**
* The plans with a checkout button each. The server renders the catalog;
* `choose` runs the `startCheckout` server action, which talks to Stripe, and
* sends the browser to the Checkout URL it returns.
*/
export function PlanPicker({
initial,
canManage,
}: {
initial: PlanCatalog<PlanOption>;
canManage: boolean;
}) {
const t = useExtracted("billing");
const locale = useLocale();
const errorMessage = useErrorMessage();
const { plans, current, pending, choose } = usePlanPicker({
load: loadCatalog,
initial,
checkout: (plan) => startCheckout({ plan }),
open: (url) => {
window.location.assign(url);
},
});
const price = (value: Money) =>
value.amount === 0n
? t("Free")
: t("{price} per month", { price: formatMoney(value, locale) });
const featureLabel = ({ key, value }: PlanOption["features"][number]) => {
switch (key) {
case "seats": {
return t("{seats} seats", { seats: String(value) });
}
case "exports": {
return t("CSV exports");
}
case "audit": {
return t("Audit log");
}
case "sso": {
return t("Single sign-on");
}
default: {
return key;
}
}
};
return (
<div className="grid gap-4 md:grid-cols-3">
{(plans ?? initial.plans).map((plan) => {
const active = plan.key === current;
return (
<Card key={plan.key} size="sm" data-testid="plan">
<CardHeader>
<CardTitle>{plan.name}</CardTitle>
<CardDescription>{price(plan.price)}</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-1 text-sm text-muted-foreground">
{plan.features.map((feature) => (
<li key={feature.key}>{featureLabel(feature)}</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button
className="w-full"
variant={active ? "secondary" : "default"}
data-testid={active ? "active-plan" : undefined}
disabled={active || !canManage || pending !== undefined}
onClick={() => {
void choose(plan.key).then((started) => {
if (!started.ok) {
toast.error(errorMessage(started.error));
}
});
}}
>
{active
? t("Current plan")
: pending === plan.key
? t("Opening checkout")
: t("Switch")}
</Button>
</CardFooter>
</Card>
);
})}
</div>
);
}React Native, with ActivityIndicator, Pressable, Text, View from react-native:
import { usePlanPicker } from "better-supabase/blocks/billing/react";
import { tenantOf, useAuth } from "better-supabase/react";
import * as WebBrowser from "expo-web-browser";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { loadPlanCatalog, startCheckout } from "../lib/billing";
const price = (cents: number) =>
cents === 0 ? "Free" : `$${(cents / 100).toFixed(2)} per month`;
/**
* The plans with a checkout button each. `choose` posts to the app's own API
* route, which talks to Stripe, and opens the Checkout URL it returns in the
* system browser.
*/
export function PlanPicker() {
const organizationId = tenantOf(useAuth());
const { plans, current, pending, error, choose } = usePlanPicker({
load: () =>
organizationId
? loadPlanCatalog(organizationId)
: Promise.resolve({ plans: [], current: undefined }),
checkout: startCheckout,
open: async (url) => {
await WebBrowser.openBrowserAsync(url);
},
});
if (plans === undefined) {
return <ActivityIndicator />;
}
return (
<View style={{ gap: 8 }}>
<Text style={{ fontSize: 17, fontWeight: "600" }}>Plans</Text>
{plans.map((plan) => {
const active = plan.key === current;
return (
<Pressable
key={plan.key}
accessibilityRole="button"
accessibilityState={{
selected: active,
busy: pending === plan.key,
}}
disabled={active || pending !== undefined}
onPress={() => void choose(plan.key)}
style={{
flexDirection: "row",
gap: 8,
padding: 12,
borderWidth: 1,
borderRadius: 12,
borderColor: active ? "#111" : "#e5e5e5",
}}
>
<Text style={{ flex: 1, fontWeight: "600" }}>{plan.name}</Text>
<Text style={{ color: "#737373" }}>{price(plan.priceCents)}</Text>
<Text>
{active ? "Current" : pending === plan.key ? "Opening" : "Switch"}
</Text>
</Pressable>
);
})}
{error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
</View>
);
}Last updated on