feat(portal): M11.1 catalog flow + M12.1 self-serve trial
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user