fix(products): source products and entitlements from the registry, not hard-coded ids #25
@@ -107,13 +107,22 @@ export default async function ProductsPage({
|
|||||||
title="Findings across products"
|
title="Findings across products"
|
||||||
tail={
|
tail={
|
||||||
<span className="row" style={{ gap: 6 }}>
|
<span className="row" style={{ gap: 6 }}>
|
||||||
{["all", "compliance-scanner", "certifai"].map((o) => (
|
{/* Derived from the tenant's own products, not a hard-coded list —
|
||||||
|
a hard-coded id (e.g. "compliance-scanner") silently filters to
|
||||||
|
nothing when the registry calls the product something else. */}
|
||||||
<Link
|
<Link
|
||||||
key={o}
|
href={`/${slug}/products`}
|
||||||
href={o === "all" ? `/${slug}/products` : `/${slug}/products?p=${o}`}
|
className={"btn btn-sm" + (productFilter === "all" ? " btn-primary" : "")}
|
||||||
className={"btn btn-sm" + (productFilter === o ? " btn-primary" : "")}
|
|
||||||
>
|
>
|
||||||
{o === "all" ? "All" : o === "certifai" ? "CERTifAI" : "Scanner"}
|
All
|
||||||
|
</Link>
|
||||||
|
{t.products.map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
href={`/${slug}/products?p=${p.slug}`}
|
||||||
|
className={"btn btn-sm" + (productFilter === p.slug ? " btn-primary" : "")}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+65
-5
@@ -10,11 +10,51 @@
|
|||||||
// Once the registry is enriched to carry the design fields end-to-end this
|
// Once the registry is enriched to carry the design fields end-to-end this
|
||||||
// module collapses into a thin pass-through.
|
// module collapses into a thin pass-through.
|
||||||
|
|
||||||
import { tenantBySlug, type TenantRecord } from "@/lib/fixtures";
|
import { tenantBySlug, type ProductDef, type TenantRecord } from "@/lib/fixtures";
|
||||||
import { fetchTenantBySlug, type Tenant } from "@/lib/tenant-registry";
|
import {
|
||||||
|
fetchCatalog,
|
||||||
|
fetchEntitlements,
|
||||||
|
fetchTenantBySlug,
|
||||||
|
type CatalogEntry,
|
||||||
|
type Entitlement,
|
||||||
|
type Tenant,
|
||||||
|
} from "@/lib/tenant-registry";
|
||||||
|
|
||||||
export type PortalTenant = TenantRecord;
|
export type PortalTenant = TenantRecord;
|
||||||
|
|
||||||
|
/** Registry catalog entry -> the shape the product screens render.
|
||||||
|
*
|
||||||
|
* The registry `key` becomes both `id` and `slug`, deliberately. The screens
|
||||||
|
* compare `entitled.includes(p.id)`, and `entitled` is built from the SAME
|
||||||
|
* registry keys below — so the two sides cannot drift apart. Hard-coding a
|
||||||
|
* product list here is what previously made a real entitlement (`compliance`)
|
||||||
|
* fail to match a hand-written product id (`compliance-scanner`).
|
||||||
|
*
|
||||||
|
* `frameworks` is empty because the registry contract does not carry it
|
||||||
|
* (PRODUCT_INTEGRATION_SPEC has products publish a manifest later); the UI
|
||||||
|
* already renders an empty list without complaint.
|
||||||
|
*/
|
||||||
|
function productFromCatalog(entry: CatalogEntry): ProductDef {
|
||||||
|
return {
|
||||||
|
id: entry.key,
|
||||||
|
slug: entry.key,
|
||||||
|
name: entry.name,
|
||||||
|
mono: entry.name.replace(/[^A-Za-z]/g, "").slice(0, 2).toUpperCase() || "??",
|
||||||
|
status: "live",
|
||||||
|
blurb: entry.description,
|
||||||
|
frameworks: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entitlements the tenant may actually use right now. */
|
||||||
|
function activeEntitlements(items: Entitlement[], nowMs: number): Entitlement[] {
|
||||||
|
return items.filter((e) => {
|
||||||
|
if (!e.enabled) return false;
|
||||||
|
if (!e.expires_at) return true;
|
||||||
|
return new Date(e.expires_at).getTime() > nowMs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadTenantForShell(slug: string): Promise<PortalTenant | null> {
|
export async function loadTenantForShell(slug: string): Promise<PortalTenant | null> {
|
||||||
const fx = tenantBySlug(slug);
|
const fx = tenantBySlug(slug);
|
||||||
if (fx) return fx;
|
if (fx) return fx;
|
||||||
@@ -30,6 +70,26 @@ export async function loadTenantForShell(slug: string): Promise<PortalTenant | n
|
|||||||
}
|
}
|
||||||
if (!live) return null;
|
if (!live) return null;
|
||||||
|
|
||||||
|
// Entitlements and catalog are what make a started trial actually show as
|
||||||
|
// entitled. Both are best-effort: the shell must still render if the
|
||||||
|
// registry answers the tenant but not these, so a failure degrades to an
|
||||||
|
// empty product grid rather than a 404 on the whole page.
|
||||||
|
const now = Date.now();
|
||||||
|
const [catalog, entitlements] = await Promise.all([
|
||||||
|
fetchCatalog().catch(() => [] as CatalogEntry[]),
|
||||||
|
fetchEntitlements(live.id).catch(() => [] as Entitlement[]),
|
||||||
|
]);
|
||||||
|
const active = activeEntitlements(entitlements, now);
|
||||||
|
const entitled = active.map((e) => e.product);
|
||||||
|
const trialing = active
|
||||||
|
.filter((e) => Boolean(e.expires_at))
|
||||||
|
.map((e) => e.product);
|
||||||
|
// Show the catalog, with the entitled ones first so the grid leads with what
|
||||||
|
// the tenant can actually open.
|
||||||
|
const products = catalog
|
||||||
|
.map(productFromCatalog)
|
||||||
|
.sort((a, b) => Number(entitled.includes(b.id)) - Number(entitled.includes(a.id)));
|
||||||
|
|
||||||
// Minimal shim so the shell can render. Design-rich fields fall back to
|
// Minimal shim so the shell can render. Design-rich fields fall back to
|
||||||
// placeholders that won't blow up the layout.
|
// placeholders that won't blow up the layout.
|
||||||
return {
|
return {
|
||||||
@@ -51,12 +111,12 @@ export async function loadTenantForShell(slug: string): Promise<PortalTenant | n
|
|||||||
contactEmail: "—",
|
contactEmail: "—",
|
||||||
renewal: "—",
|
renewal: "—",
|
||||||
since: live.created_at?.slice(0, 10) ?? "—",
|
since: live.created_at?.slice(0, 10) ?? "—",
|
||||||
entitled: [],
|
entitled,
|
||||||
trialing: [],
|
trialing,
|
||||||
trialEnds: live.trial_ends_at ?? undefined,
|
trialEnds: live.trial_ends_at ?? undefined,
|
||||||
seed: 0,
|
seed: 0,
|
||||||
findingCount: 0,
|
findingCount: 0,
|
||||||
products: [],
|
products,
|
||||||
findings: [],
|
findings: [],
|
||||||
activity: [],
|
activity: [],
|
||||||
audit: [],
|
audit: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user