feat(portal): M11.1 catalog flow + M12.1 self-serve trial

M11.1 — /[slug]/catalog page renders the live catalog from tenant-registry,
gates already-owned products with an 'Active' chip, and exposes two
server actions per remaining card:
  - Request → POST /v1/catalog/request (emits an audit event; sales
    follow-up flow will pick this up when M8.x lands ERPNext + the
    Lead webhook)
  - Start 14-day trial → POST /v1/catalog/trial-request (provisions
    the entitlement immediately; 14-day expiry per M4.2)
Flash banner on success/error (?ok= / ?err= query params).

M12.1 — Public /start route. Server action calls
createTenant({slug, name, plan, admin_email}) → tenant-registry's
KC-aware POST /v1/tenants → user lands at /<slug>/dashboard. The
dashboard now renders a trial-days-left banner when status=trial
and trial_ends_at is set (urgent styling when ≤3 days remain).

Library:
  src/lib/tenant-registry.ts widened from one-call client to the
  full read+mutate surface (fetchCatalog, fetchEntitlements,
  requestProduct, startTrial, createTenant). Returns typed
  {ok: true, ...} | {ok: false, error: '...'} so server actions
  branch cleanly. 22 vitest cases, 100% line+branch+function
  coverage of src/lib/.

Catalog tests rely on the mock-fetch pattern; the user-visible
flow is exercised by Playwright when the dev stack is up.

Refs: M11.1 + M12.1
This commit is contained in:
2026-05-19 18:23:25 +02:00
parent 8ab82c8b37
commit 800e0a4868
5 changed files with 519 additions and 43 deletions
+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>
);
}