800e0a4868
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
173 lines
4.6 KiB
TypeScript
173 lines
4.6 KiB
TypeScript
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,
|
|
}: {
|
|
params: Promise<{ slug: string }>;
|
|
}) {
|
|
const { slug } = await params;
|
|
const session = (await auth()) as SessionWithExtras | null;
|
|
|
|
if (!session) {
|
|
async function login() {
|
|
"use server";
|
|
await signIn("keycloak", { redirectTo: `/${slug}/dashboard` });
|
|
}
|
|
return (
|
|
<section style={{ maxWidth: 480 }}>
|
|
<h1 style={{ fontSize: 28, marginBottom: 12 }}>Sign in to {slug}</h1>
|
|
<form action={login}>
|
|
<button
|
|
type="submit"
|
|
style={{
|
|
padding: "10px 16px",
|
|
background: "#0070f3",
|
|
color: "white",
|
|
border: "none",
|
|
borderRadius: 6,
|
|
fontSize: 14,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
Sign in with Keycloak
|
|
</button>
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
async function logout() {
|
|
"use server";
|
|
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
|
|
as <code>{session.org_roles?.join(", ") ?? "(no roles)"}</code>.
|
|
</p>
|
|
|
|
<h2 style={{ fontSize: 18, marginTop: 24, marginBottom: 12 }}>Your products</h2>
|
|
{products.length === 0 ? (
|
|
<ShellEmpty
|
|
title="No products yet"
|
|
description="Browse the catalog and request access to a product, or start a 14-day trial."
|
|
milestone="M11.1"
|
|
/>
|
|
) : (
|
|
<ul
|
|
style={{
|
|
listStyle: "none",
|
|
padding: 0,
|
|
display: "grid",
|
|
gap: 12,
|
|
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
|
}}
|
|
>
|
|
{products.map((p) => (
|
|
<li
|
|
key={p}
|
|
style={{
|
|
padding: 16,
|
|
border: "1px solid #eaeaea",
|
|
borderRadius: 8,
|
|
background: "white",
|
|
}}
|
|
>
|
|
<strong style={{ textTransform: "capitalize" }}>{p}</strong>
|
|
<p style={{ color: "#666", fontSize: 13, marginTop: 4 }}>
|
|
Tile content lands in <code>M10.1</code>.
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<form action={logout} style={{ marginTop: 32 }}>
|
|
<button
|
|
type="submit"
|
|
style={{
|
|
padding: "8px 12px",
|
|
background: "white",
|
|
color: "#0070f3",
|
|
border: "1px solid #0070f3",
|
|
borderRadius: 6,
|
|
fontSize: 13,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
Sign out
|
|
</button>
|
|
</form>
|
|
</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>
|
|
);
|
|
}
|