fix(demo): align payments-demo with its own hexclave.config.ts catalog

The payments-demo page and API routes referenced the internal project's
plan catalog (emails_per_month, products team/growth, PLAN_LIMITS/resolvePlanId)
from @hexclave/shared/plans. But the demo runs against a development-environment
project provisioned from examples/demo/hexclave.config.ts, which declares a
different catalog: items api_calls/seats and products pro/team_pro/extra_seats.

As a result team.useItem("emails_per_month") threw ItemNotFound and crashed
the whole payments-demo page. Switch the demo to its own catalog:
- useItem("seats") + seat-based metrics instead of email-quota metrics
- buy team_pro / extra_seats instead of team / growth
- a local resolveTeamPlan() instead of resolvePlanId()/PLAN_LIMITS
- update create-checkout-url + config-check routes to match
This commit is contained in:
Bilal Godil 2026-06-23 12:20:44 -07:00
parent 7b0f430975
commit 8e5bd9a575
3 changed files with 26 additions and 22 deletions

View File

@ -1,5 +1,4 @@
import { branchConfigSchema, getConfigOverrideErrors } from "@hexclave/shared/dist/config/schema";
import { ITEM_IDS, PLAN_LIMITS } from "@hexclave/shared/dist/plans";
import { NextResponse } from "next/server";
import { hexclaveServerApp } from "src/hexclave";
@ -28,9 +27,9 @@ export async function GET() {
expected: {
freePrice: "0.00",
freeInterval: [1, "month"],
freeEmailsPerMonth: PLAN_LIMITS.free.emailsPerMonth,
emailItemId: ITEM_IDS.emailsPerMonth,
emailsPerMonthRepeat: [1, "month"],
apiCallsItemId: "api_calls",
seatsItemId: "seats",
teamProSeats: 25,
},
});
}

View File

@ -5,7 +5,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readBody(value: unknown): { teamId: string, productId: "team" | "growth", returnUrl?: string } {
function readBody(value: unknown): { teamId: string, productId: "team_pro" | "extra_seats", returnUrl?: string } {
if (!isRecord(value)) {
throw new Error("Request body must be an object.");
}
@ -14,8 +14,8 @@ function readBody(value: unknown): { teamId: string, productId: "team" | "growth
if (typeof teamId !== "string" || teamId === "") {
throw new Error("teamId is required.");
}
if (productId !== "team" && productId !== "growth") {
throw new Error("productId must be team or growth.");
if (productId !== "team_pro" && productId !== "extra_seats") {
throw new Error("productId must be team_pro or extra_seats.");
}
if (returnUrl !== undefined && typeof returnUrl !== "string") {
throw new Error("returnUrl must be a string.");

View File

@ -1,7 +1,6 @@
"use client";
import { useStackApp, useUser } from "@hexclave/next";
import { ITEM_IDS, PLAN_LIMITS, resolvePlanId } from "@hexclave/shared/dist/plans";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { Button, Card, CardContent, CardFooter, CardHeader, Input, Typography } from "@hexclave/ui";
import Link from "next/link";
@ -38,7 +37,7 @@ async function readJson(response: Response): Promise<unknown> {
return value;
}
async function createCheckoutUrl(options: { teamId: string, productId: "team" | "growth", returnUrl: string }): Promise<string> {
async function createCheckoutUrl(options: { teamId: string, productId: "team_pro" | "extra_seats", returnUrl: string }): Promise<string> {
const response = await fetch("/payments-demo/api/create-checkout-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -51,10 +50,17 @@ async function createCheckoutUrl(options: { teamId: string, productId: "team" |
return data.url;
}
function resolveTeamPlan(products: ReadonlyArray<{ id: string | null, type?: string }>): string {
const activeSubscriptionIds = new Set(
products.filter((p) => p.type === "subscription" && p.id != null).map((p) => p.id),
);
return activeSubscriptionIds.has("team_pro") ? "team_pro" : "none";
}
function ProductList(props: { team: ReturnType<NonNullable<ReturnType<typeof useUser>>["useTeams"]>[number] }) {
const products = props.team.useProducts();
const emails = props.team.useItem(ITEM_IDS.emailsPerMonth);
const activePlan = resolvePlanId(products);
const seats = props.team.useItem("seats");
const activePlan = resolveTeamPlan(products);
return (
<Card className="overflow-hidden">
@ -73,10 +79,9 @@ function ProductList(props: { team: ReturnType<NonNullable<ReturnType<typeof use
</div>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-3 md:grid-cols-3">
<Metric label="Emails remaining" value={emails.nonNegativeQuantity.toLocaleString()} />
<Metric label="Raw email quantity" value={emails.quantity.toLocaleString()} />
<Metric label="Expected free quota" value={PLAN_LIMITS.free.emailsPerMonth.toLocaleString()} />
<div className="grid gap-3 md:grid-cols-2">
<Metric label="Seats granted" value={seats.quantity.toLocaleString()} />
<Metric label="Seats available" value={seats.nonNegativeQuantity.toLocaleString()} />
</div>
<div className="overflow-auto rounded-md border">
@ -118,8 +123,8 @@ function ProductList(props: { team: ReturnType<NonNullable<ReturnType<typeof use
</div>
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
<CheckoutButton team={props.team} productId="team" label="Buy Team" />
<CheckoutButton team={props.team} productId="growth" label="Buy Growth" />
<CheckoutButton team={props.team} productId="team_pro" label="Buy Team Pro" />
<CheckoutButton team={props.team} productId="extra_seats" label="Buy Extra Seat (add-on)" />
</CardFooter>
</Card>
);
@ -136,7 +141,7 @@ function Metric(props: { label: string, value: string }) {
function CheckoutButton(props: {
team: ReturnType<NonNullable<ReturnType<typeof useUser>>["useTeams"]>[number],
productId: "team" | "growth",
productId: "team_pro" | "extra_seats",
label: string,
}) {
const [loading, setLoading] = useState(false);
@ -186,7 +191,7 @@ export default function PaymentsDemoPage() {
await user.setSelectedTeam(team);
setResult({
label: "Created team",
detail: `${team.displayName} (${team.id}). Free plan should appear after the billing grant job/webhook path catches up.`,
detail: `${team.displayName} (${team.id}). The team starts with no products — buy Team Pro below to start a subscription.`,
});
setTeamName(`Payments demo ${new Date().toISOString()}`);
};
@ -224,7 +229,7 @@ export default function PaymentsDemoPage() {
<div>
<Typography type="h1">Payments Demo</Typography>
<Typography className="max-w-3xl text-gray-600 dark:text-gray-400">
Manual test surface for Hexclave internal team plans, Stripe checkout, subscription ending, and email quota deductions.
Manual test surface for the demo team plans (Team Pro + Extra Seats add-on), Stripe checkout, subscription ending, and seat item quantities.
</Typography>
</div>
<Link className="text-sm font-medium underline" href={internalDashboardUrl}>
@ -244,7 +249,7 @@ export default function PaymentsDemoPage() {
<div className="grid gap-3 md:grid-cols-[160px_auto_1fr]">
<Input value={emailCount} onChange={(e) => setEmailCount(e.target.value)} inputMode="numeric" />
<Button onClick={() => runAsynchronouslyWithAlert(sendTestEmails)}>Send quota emails</Button>
<Button onClick={() => runAsynchronouslyWithAlert(sendTestEmails)}>Send test emails</Button>
<Button variant="secondary" onClick={() => runAsynchronouslyWithAlert(runConfigCheck)}>
Check free/config guardrails
</Button>
@ -253,7 +258,7 @@ export default function PaymentsDemoPage() {
<div className="rounded-md border bg-white p-3 text-sm dark:bg-black">
<div><span className="font-medium">Project:</span> {project.displayName} ({project.id})</div>
<div><span className="font-medium">Selected team:</span> {user.selectedTeam?.displayName ?? "none"}</div>
<div><span className="font-medium">Manual Stripe end test:</span> buy Team/Growth, end that customer subscription in Stripe Connect, wait for the webhook, then refresh here. The paid plan should disappear and Free should return.</div>
<div><span className="font-medium">Manual Stripe end test:</span> buy Team Pro, end that customer subscription in Stripe Connect, wait for the webhook, then refresh here. The paid plan should disappear.</div>
</div>
{result && (