mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
fix(demo): align payments-demo with its own hexclave.config.ts catalog (#1651)
## Problem
The demo's `payments-demo` page white-screens on load with:
```
Item with ID "emails_per_month" not found. (KnownError.fromJson)
```
## Root cause
The demo runs (via the CLI `dev` / development-environment flow) against
an auto-created **Development Environment Project**, which is
provisioned from `examples/demo/hexclave.config.ts`. That config
declares items `api_calls` / `seats` and products `pro` / `team_pro` /
`extra_seats`.
But the demo **code** still referenced the **internal** project's plan
catalog from `@hexclave/shared/plans` — `emails_per_month`, products
`team` / `growth`, `PLAN_LIMITS`, `resolvePlanId`. None of those exist
in the demo's own config, so `team.useItem("emails_per_month")` threw
`ItemNotFound` and crashed the whole page.
Confirmed by dumping the live rendered config
(`tenancy.config.payments`, the exact path the SDK reads) for the
dev-environment project: items `["seats","api_calls"]`, products
`["pro","team_pro","extra_seats"]` — no `emails_per_month`. The
`internal` project's config is healthy and unrelated; this is purely
demo code drift from its own config file.
## Fix
Align the demo code with its own `hexclave.config.ts` (the CLI's
declared source of truth):
- `useItem("seats")` + seat metrics instead of email-quota metrics
- Buy `team_pro` / `extra_seats` instead of `team` / `growth`
- Local `resolveTeamPlan()` instead of `resolvePlanId()` / `PLAN_LIMITS`
- `create-checkout-url` + `config-check` routes updated to the new
catalog
- Copy updates (header, "Free plan" note, manual-end-test instructions)
No backend or provisioning changes — the dev-environment project already
had the correct catalog; only the demo code was stale.
## Verification
- `pnpm --filter @hexclave/example-demo-app typecheck` — pass
- `pnpm --filter @hexclave/example-demo-app lint` — pass
- Demo page loads cleanly: team card renders (active plan "none", 0
seats granted/available), **Buy Team Pro** / **Buy Extra Seat (add-on)**
buttons present, no crash.
## Note
The demo is treated here as a generic SaaS example (its
`hexclave.config.ts` is the curated source of truth). If the intent was
instead for the demo to exercise the internal project's real plans +
email-quota, the alternative fix would be to update `hexclave.config.ts`
rather than the demo code.
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes the payments demo white-screen by aligning it with its own
`hexclave.config.ts` catalog. The page now loads and uses seat-based
metrics and the correct product IDs.
- **Bug Fixes**
- Use `seats` item and seat metrics; drop
`emails_per_month`/`PLAN_LIMITS`/`resolvePlanId` from
`@hexclave/shared/plans` in favor of a local `resolveTeamPlan()`.
- Switch checkout product IDs to `team_pro` and `extra_seats`; update
API validation, labels, and copy.
- Update `config-check` to expect `api_calls`, `seats`, and
`teamProSeats: 25`.
<sup>Written for commit 8e5bd9a575.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1651?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Transitioned subscription model from email-quota plans to seat-based
Team Pro with Extra Seats add-on
* Updated checkout system and payment configuration for new product
offerings
* Updated demo interface labels and metrics display to reflect
seat-based model
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
3e53da8fce
commit
64ddb41374
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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.");
|
||||
|
||||
@ -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 && (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user