@lla-ma/checkout
v0.2.0
Published
Shared Stripe checkout flow for the lla.ma ecosystem — pricing grid, result banner, resume hook, and the framework-neutral create-checkout-session helper.
Maintainers
Readme
@lla-ma/checkout
Shared Stripe checkout flow for the lla.ma ecosystem. Factors out the client
code that apps-cloud, base-cloud, and mercado were each re-implementing
around llama-api's one central checkout endpoint.
The flow it covers:
product → membership options → Stripe (external) → confirm / decline
The Stripe gateway itself is already centralized in llama-api
(POST /api/stripe/create-checkout-session). This package is the client
half of that flow — it does not talk to Stripe directly.
What's in the box
| Import | Directive | Use |
| --- | --- | --- |
| @lla-ma/checkout | server-safe | Types + re-export of core |
| @lla-ma/checkout/core | server-safe | createCheckoutSession, readSessionCookie, cookie constants, CheckoutError |
| @lla-ma/checkout/ui | "use client" | <PricingGrid> (membership options), <CheckoutResult> (confirm/decline banner) |
| @lla-ma/checkout/resume | "use client" | useCheckoutResume() — reads ?checkout= on return from Stripe |
The one thing that stays in each app is a tiny "use server" action, because a
registered Next Server Action must live in the app's own module graph and it
owns cookies() / headers() / redirect(). Everything else — the endpoint,
the request shape, the __Host-/legacy cookie dual-read, error handling, the
?checkout= state machine, and the UI — lives here.
Wiring an app (as done in apps-cloud)
1. The server action (app/actions/checkout.ts):
"use server";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { createCheckoutSession, readSessionCookie } from "@lla-ma/checkout/core";
export async function startTierCheckout(tier: "developer" | "team") {
const jar = await cookies();
const session = readSessionCookie((n) => jar.get(n)?.value);
if (!session) redirect("/login");
// Resolve the URL inside the try; call redirect() OUTSIDE it. redirect()
// throws a NEXT_REDIRECT error to do its work — if that throw happened inside
// the try it'd be caught by this catch and swallowed (Next signals redirects
// on err.digest, not err.message), bouncing the user to /dashboard instead of
// Stripe. Keep the only redirect() outside the try and the problem disappears.
let url: string | undefined;
try {
({ url } = await createCheckoutSession({
apiBase: process.env.LLAMA_API_BASE_URL, // defaults to https://api.lla.ma
sessionCookieName: session.name,
sessionId: session.value,
product: "apps",
tier,
returnOrigin: /* this request's origin */,
successPath: "/dashboard?checkout=success",
cancelPath: "/dashboard?checkout=canceled",
offer: "TRIAL5", // optional trial code — llama-api ignores unusable ones
}));
} catch (err) {
console.error("[checkout]", err);
}
redirect(url ?? "/dashboard?checkout=error");
}2. The membership options (a client component):
"use client";
import { PricingGrid } from "@lla-ma/checkout/ui";
import type { PricingTier } from "@lla-ma/checkout";
const TIERS: PricingTier[] = [ /* app-owned marketing copy */ ];
<PricingGrid
tiers={TIERS}
pendingTier={pending}
onSelect={(t) => {
if (t.tier === "free") return enrollFree();
if (t.tier === "enterprise") return contact();
startTierCheckout(t.tier); // paid → Stripe
}}
/>PricingGrid is purely presentational — it holds no Stripe or product logic.
The app decides what each tier does in onSelect.
3. The confirm/decline step (a client component on the page Stripe returns to, e.g. the dashboard):
"use client";
import { useCheckoutResume } from "@lla-ma/checkout/resume";
import { CheckoutResult } from "@lla-ma/checkout/ui";
const [status, setStatus] = useState(null);
useCheckoutResume({
paidTiers: ["developer", "team"],
onResume: (tier) => startTierCheckout(tier), // pre-login pick, resumed
onSuccess: () => { setStatus("success"); router.refresh(); },
onCancel: () => setStatus("canceled"),
onError: () => setStatus("error"),
});
return <CheckoutResult status={status} onDismiss={() => setStatus(null)} />;Styling
The UI uses the shared @lla-ma/ui design tokens (text-accent,
bg-background-elev-1, --accent, --danger, …), so it themes automatically
in any app that defines them. There is no hard dependency on @lla-ma/ui — the
tokens are referenced as CSS classes/vars, so React is the only peer.
Build
pnpm build # tsup → dist (esm + cjs + d.ts)Client entries keep their "use client" directive through the build; core
stays a plain server-safe module (no directive), so importing the package root
never drags a client boundary into a server file.
