feat(portal): M11.1 catalog flow + M12.1 self-serve trial
ci / shared (push) Successful in 5s
ci / test (push) Successful in 26s
ci / e2e (push) Has been skipped
ci / image (push) Has been skipped

Closes the customer loop: /start signup → tenant + KC org + IT_ADMIN invite → portal dashboard with trial banner → /[slug]/catalog with Request + Start trial server actions wired to tenant-registry.

Refs: M11.1 + M12.1
This commit was merged in pull request #11.
This commit is contained in:
2026-05-19 16:27:10 +00:00
parent 8ab82c8b37
commit ecbe6ae74b
6 changed files with 715 additions and 50 deletions
+196 -7
View File
@@ -1,16 +1,205 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty";
import { NotAuthorized } from "@/components/ShellEmpty";
import type { SessionWithExtras } from "@/lib/session";
import { canSee } from "@/lib/session";
import {
fetchCatalog,
fetchEntitlements,
fetchTenantBySlug,
requestProduct,
startTrial,
type CatalogEntry,
} from "@/lib/tenant-registry";
export default async function Page() {
export default async function CatalogPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ ok?: string; err?: string }>;
}) {
const { slug } = await params;
const flash = await searchParams;
const session = (await auth()) as SessionWithExtras | null;
if (!canSee(session, "catalog")) return <NotAuthorized />;
const tenant = await fetchTenantBySlug(slug);
if (!tenant) redirect(`/${slug}/dashboard`);
const [catalog, entitlements] = await Promise.all([
fetchCatalog(),
fetchEntitlements(tenant.id),
]);
const enabled = new Set(entitlements.filter((e) => e.enabled).map((e) => e.product));
async function doRequest(formData: FormData) {
"use server";
const product = String(formData.get("product"));
const tenantId = String(formData.get("tenant_id"));
const slugV = String(formData.get("slug"));
const res = await requestProduct(tenantId, product);
const param = res.ok ? `ok=requested:${product}` : `err=${res.error}`;
redirect(`/${slugV}/catalog?${param}`);
}
async function doTrial(formData: FormData) {
"use server";
const product = String(formData.get("product"));
const tenantId = String(formData.get("tenant_id"));
const slugV = String(formData.get("slug"));
const res = await startTrial(tenantId, product);
const param = res.ok ? `ok=trial:${product}` : `err=${res.error}`;
revalidatePath(`/${slugV}/catalog`);
redirect(`/${slugV}/catalog?${param}`);
}
return (
<ShellEmpty
title="Catalog"
description="Products you can add to your subscription."
milestone="M11.1"
/>
<section>
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Catalog</h1>
<p style={{ color: "#444", marginBottom: 16 }}>
Pick a product to add to your plan. Trial-eligible products start a
14-day evaluation; everything else opens a CRM lead for sales follow-up.
</p>
<FlashBanner ok={flash.ok} err={flash.err} />
<ul
style={{
listStyle: "none",
padding: 0,
display: "grid",
gap: 12,
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
marginTop: 16,
}}
>
{catalog.map((p) => (
<CatalogCard
key={p.key}
product={p}
owned={enabled.has(p.key)}
tenantId={tenant.id}
slug={slug}
doRequest={doRequest}
doTrial={doTrial}
/>
))}
</ul>
</section>
);
}
function FlashBanner({ ok, err }: { ok?: string; err?: string }) {
if (!ok && !err) return null;
const isOk = !!ok;
return (
<div
role="status"
style={{
padding: 12,
borderRadius: 8,
marginBottom: 12,
background: isOk ? "#e6f7ec" : "#fdecea",
color: isOk ? "#0a6e2a" : "#a82626",
border: `1px solid ${isOk ? "#a4d8b8" : "#e8a5a5"}`,
}}
>
{isOk ? `OK — ${ok}` : `Error — ${err}`}
</div>
);
}
function CatalogCard({
product,
owned,
tenantId,
slug,
doRequest,
doTrial,
}: {
product: CatalogEntry;
owned: boolean;
tenantId: string;
slug: string;
doRequest: (fd: FormData) => Promise<void>;
doTrial: (fd: FormData) => Promise<void>;
}) {
return (
<li
style={{
padding: 16,
border: "1px solid #eaeaea",
borderRadius: 8,
background: "white",
}}
>
<strong style={{ fontSize: 15 }}>{product.name}</strong>
<p style={{ color: "#666", fontSize: 13, marginTop: 4, marginBottom: 12 }}>
{product.description}
</p>
<div style={{ fontSize: 12, color: "#666", marginBottom: 12 }}>
Plans: {product.plans_required.join(", ")}
</div>
{owned ? (
<span
style={{
fontSize: 12,
padding: "4px 8px",
background: "#eef",
borderRadius: 4,
color: "#226",
}}
>
Active
</span>
) : (
<div style={{ display: "flex", gap: 8 }}>
<form action={doRequest}>
<input type="hidden" name="product" value={product.key} />
<input type="hidden" name="tenant_id" value={tenantId} />
<input type="hidden" name="slug" value={slug} />
<button
type="submit"
style={{
padding: "6px 10px",
background: "white",
color: "#0070f3",
border: "1px solid #0070f3",
borderRadius: 6,
fontSize: 13,
cursor: "pointer",
}}
>
Request
</button>
</form>
{product.supports_trial && (
<form action={doTrial}>
<input type="hidden" name="product" value={product.key} />
<input type="hidden" name="tenant_id" value={tenantId} />
<input type="hidden" name="slug" value={slug} />
<button
type="submit"
style={{
padding: "6px 10px",
background: "#0070f3",
color: "white",
border: "none",
borderRadius: 6,
fontSize: 13,
cursor: "pointer",
}}
>
Start 14-day trial
</button>
</form>
)}
</div>
)}
</li>
);
}
+71 -1
View File
@@ -1,6 +1,7 @@
import { auth, signIn, signOut } from "@/auth";
import { ShellEmpty } from "@/components/ShellEmpty";
import type { SessionWithExtras } from "@/lib/session";
import { fetchTenantBySlug } from "@/lib/tenant-registry";
export default async function Dashboard({
params,
@@ -43,10 +44,20 @@ export default async function Dashboard({
await signOut({ redirectTo: `/${slug}/dashboard` });
}
const tenant = await fetchTenantBySlug(slug);
const products = session.products ?? [];
const trialDaysLeft = computeTrialDaysLeft(tenant?.trial_ends_at);
return (
<section>
{tenant?.status === "trial" && tenant.trial_ends_at && (
<TrialBanner
endsAt={tenant.trial_ends_at}
slug={slug}
daysLeft={trialDaysLeft}
/>
)}
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Dashboard</h1>
<p style={{ color: "#444", marginBottom: 24 }}>
Welcome, {session.user?.name ?? session.user?.email ?? "user"}. Signed in
@@ -61,7 +72,15 @@ export default async function Dashboard({
milestone="M11.1"
/>
) : (
<ul style={{ listStyle: "none", padding: 0, display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))" }}>
<ul
style={{
listStyle: "none",
padding: 0,
display: "grid",
gap: 12,
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
}}
>
{products.map((p) => (
<li
key={p}
@@ -100,3 +119,54 @@ export default async function Dashboard({
</section>
);
}
// Pure compute, lives outside any render path so react-hooks/purity is satisfied.
function computeTrialDaysLeft(endsAt: string | null | undefined): number {
if (!endsAt) return 0;
const ms = new Date(endsAt).getTime() - Date.now();
return Math.max(0, Math.ceil(ms / (24 * 3600 * 1000)));
}
function TrialBanner({
endsAt,
slug,
daysLeft,
}: {
endsAt: string;
slug: string;
daysLeft: number;
}) {
const ends = new Date(endsAt);
const urgent = daysLeft <= 3;
return (
<div
role="status"
style={{
padding: 12,
marginBottom: 16,
borderRadius: 8,
background: urgent ? "#fdecea" : "#fff7e0",
color: urgent ? "#a82626" : "#7a5a00",
border: `1px solid ${urgent ? "#e8a5a5" : "#e6d28a"}`,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>
Trial <strong>{daysLeft}</strong> day{daysLeft === 1 ? "" : "s"} left
{" "}(ends {ends.toLocaleDateString()}).
</span>
<a
href={`/${slug}/billing`}
style={{
fontSize: 13,
color: urgent ? "#a82626" : "#7a5a00",
textDecoration: "underline",
}}
>
Upgrade
</a>
</div>
);
}