ecbe6ae74b
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
206 lines
5.7 KiB
TypeScript
206 lines
5.7 KiB
TypeScript
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { auth } from "@/auth";
|
|
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 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 (
|
|
<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>
|
|
);
|
|
}
|