feat: Updated plans and catalogue pages
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 1m58s

This commit is contained in:
2026-08-25 14:35:47 +00:00
parent 270d55e6a6
commit 3e4865884c
18 changed files with 825 additions and 390 deletions
+9 -3
View File
@@ -8,6 +8,9 @@
* customer-facing and should be shown verbatim).
*/
/* catalogue.ts imports only types from here, so this is not a cycle. */
import { rowsForPlan } from "@/lib/catalogue";
export const API_BASE = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, "");
export class NotConnected extends Error {
@@ -176,6 +179,11 @@ export interface Plan {
export interface CatalogueRow {
kind: "base" | "limit" | "feature";
/* "plan" rows carry a deployment and tier and belong to that plan alone.
* "shared" rows leave both empty and are sold by every paid plan, which is
* why a price ID is typed once rather than four times. Read them through
* rowsForPlan in lib/catalogue, never by filtering on deployment. */
scope: "plan" | "shared";
deployment: Deployment;
tier: Tier;
limit_key?: string;
@@ -213,9 +221,7 @@ export function lineItemsFor(
const env = opts.env;
const plan = opts.plans.find((p) => p.deployment === deployment && p.tier === choice.tier);
if (!plan) return [];
const rows = opts.catalogue.filter(
(r) => r.deployment === deployment && r.tier === choice.tier,
);
const rows = rowsForPlan(opts.catalogue, deployment, choice.tier);
const priceOf = (r: CatalogueRow) => r.price_ids?.[env]?.[choice.term] ?? "";
const base = plan.base_limits.max_servers;
const items: { priceId: string; quantity: number }[] = [];
+38
View File
@@ -0,0 +1,38 @@
import type { CatalogueRow, Deployment, Tier } from "@/lib/api";
/*
* rowsForPlan is the TypeScript half of Go's models.CatalogueFor, and the two
* must change together — the same shape of hazard as web/lib/targets.ts.
*
* A plan sells its own base row plus every shared add-on row. Shared rows leave
* deployment and tier empty, so the filter this replaced — `r.deployment === dep
* && r.tier === tier` — now returns a plan priced by its base fee and nothing
* else. There were five copies of that filter; this is why it is a module.
*/
export function rowsForPlan(
catalogue: CatalogueRow[],
deployment: Deployment,
tier: Tier,
): CatalogueRow[] {
return catalogue.filter(
(r) => r.scope === "shared" || (r.deployment === deployment && r.tier === tier),
);
}
/* Every add-on a paid plan can be sold, in one list. The staff catalogue editor
* shows these once; the purchase form reads them per plan through rowsForPlan. */
export function sharedRows(catalogue: CatalogueRow[]): CatalogueRow[] {
return catalogue.filter((r) => r.scope === "shared");
}
/* The base fee rows, which are genuinely one per plan because each is its own
* Paddle product at its own price. */
export function planRows(catalogue: CatalogueRow[]): CatalogueRow[] {
return catalogue.filter((r) => r.scope !== "shared");
}
/* A stable identity for a row, used as a React key and as the draft key in the
* staff editor. Mirrors the natural key the API addresses a row by. */
export function rowKey(r: CatalogueRow): string {
return [r.scope ?? "plan", r.deployment ?? "", r.tier ?? "", r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/");
}