Load rule templates from API

This commit is contained in:
adilallo
2026-04-12 21:56:34 -06:00
parent cae4df261e
commit a39b4aa04b
17 changed files with 698 additions and 429 deletions
@@ -11,13 +11,8 @@ import {
} from "../../../../lib/create/templateReviewMapping";
import {
getGovernanceTemplateCatalogEntry,
governanceTemplateIconPath,
} from "../../../../lib/templates/governanceTemplateCatalog";
const FALLBACK_PRESENTATION = {
iconPath: governanceTemplateIconPath("consensus"),
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
};
import { TEMPLATE_GRID_FALLBACK_PRESENTATION } from "../../../../lib/templates/templateGridPresentation";
export interface TemplateReviewCardProps {
template: RuleTemplateDto;
@@ -37,7 +32,7 @@ export function TemplateReviewCard({
size = "L",
}: TemplateReviewCardProps) {
const catalog = getGovernanceTemplateCatalogEntry(template.slug);
const pres = catalog ?? FALLBACK_PRESENTATION;
const pres = catalog ?? TEMPLATE_GRID_FALLBACK_PRESENTATION;
const categories = templateBodyToCategories(template.body);
const summary = templateSummaryFromBody(template.description, template.body);
@@ -0,0 +1,33 @@
/**
* Placeholder grid matching GovernanceTemplateGrid layout (loading state).
*/
export function GovernanceTemplateGridSkeleton({ count }: { count: number }) {
return (
<div
className="
flex flex-col gap-[18px]
min-[768px]:grid min-[768px]:grid-cols-2 min-[768px]:gap-[18px]
min-[1024px]:gap-[24px]
"
aria-busy
aria-label="Loading templates"
>
{Array.from({ length: count }, (_, i) => (
<div
key={i}
className="
flex min-h-[120px] animate-pulse flex-col gap-3 rounded-[var(--measures-radius-200,8px)]
bg-[var(--color-surface-default-secondary,#262626)] p-4
min-[640px]:min-h-[140px] min-[640px]:rounded-[var(--measures-radius-300,12px)]
min-[1024px]:min-h-[160px] min-[1024px]:rounded-[var(--radius-measures-radius-small)]
"
>
<div className="h-10 w-10 rounded bg-[var(--color-surface-default-tertiary,#404040)] min-[640px]:h-14 min-[640px]:w-14" />
<div className="h-4 w-[55%] max-w-[280px] rounded bg-[var(--color-surface-default-tertiary,#404040)]" />
<div className="h-3 w-full max-w-[400px] rounded bg-[var(--color-surface-default-tertiary,#404040)]" />
<div className="h-3 w-[72%] max-w-[360px] rounded bg-[var(--color-surface-default-tertiary,#404040)]" />
</div>
))}
</div>
);
}
@@ -1,8 +1,15 @@
"use client";
import { memo } from "react";
import { memo, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { logger } from "../../../../lib/logger";
import {
fetchTemplates,
isTemplatesFetchAborted,
} from "../../../../lib/create/fetchTemplates";
import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../../../lib/templates/governanceTemplateCatalog";
import { gridEntriesForSlugOrderWithCatalogFallback } from "../../../../lib/templates/templateGridPresentation";
import type { TemplateGridCardEntry } from "../../../../lib/templates/templateGridPresentation";
import { RuleStackView } from "./RuleStack.view";
import type { RuleStackProps } from "./RuleStack.types";
@@ -19,8 +26,53 @@ declare global {
}
}
const RuleStackContainer = memo<RuleStackProps>(({ className = "" }) => {
const RuleStackContainer = memo<RuleStackProps>(
({ className = "", initialGridEntries }) => {
const router = useRouter();
const [gridEntries, setGridEntries] = useState<TemplateGridCardEntry[] | null>(
() => initialGridEntries ?? null,
);
useEffect(() => {
if (initialGridEntries !== undefined) {
return;
}
const ac = new AbortController();
let cancelled = false;
void (async () => {
try {
const result = await fetchTemplates({ signal: ac.signal });
if (cancelled) return;
if ("error" in result) {
setGridEntries(
gridEntriesForSlugOrderWithCatalogFallback(
[],
GOVERNANCE_TEMPLATE_HOME_SLUGS,
),
);
return;
}
setGridEntries(
gridEntriesForSlugOrderWithCatalogFallback(
result,
GOVERNANCE_TEMPLATE_HOME_SLUGS,
),
);
} catch (e) {
if (cancelled || isTemplatesFetchAborted(e)) return;
setGridEntries(
gridEntriesForSlugOrderWithCatalogFallback(
[],
GOVERNANCE_TEMPLATE_HOME_SLUGS,
),
);
}
})();
return () => {
cancelled = true;
ac.abort();
};
}, [initialGridEntries]);
const handleTemplateClick = (slug: string) => {
// Basic analytics tracking
@@ -44,9 +96,11 @@ const RuleStackContainer = memo<RuleStackProps>(({ className = "" }) => {
<RuleStackView
className={className}
onTemplateClick={handleTemplateClick}
gridEntries={gridEntries}
/>
);
});
},
);
RuleStackContainer.displayName = "RuleStack";
@@ -1,8 +1,17 @@
import type { TemplateGridCardEntry } from "../../../../lib/templates/templateGridPresentation";
export interface RuleStackProps {
className?: string;
/**
* When set (e.g. from a Server Component), first paint uses this data and
* the client skips the `/api/templates` request.
*/
initialGridEntries?: TemplateGridCardEntry[];
}
export interface RuleStackViewProps {
className: string;
onTemplateClick: (_slug: string) => void;
/** `null` while loading curated templates from the API. */
gridEntries: TemplateGridCardEntry[] | null;
}
@@ -4,14 +4,13 @@ import { useTranslation } from "../../../contexts/MessagesContext";
import SectionHeader from "../SectionHeader";
import Button from "../../buttons/Button";
import { GovernanceTemplateGrid } from "../GovernanceTemplateGrid";
import { getGovernanceTemplatesForHome } from "../../../../lib/templates/governanceTemplateCatalog";
import { GovernanceTemplateGridSkeleton } from "../GovernanceTemplateGrid/GovernanceTemplateGridSkeleton";
import type { RuleStackViewProps } from "./RuleStack.types";
const homeFeaturedTemplates = getGovernanceTemplatesForHome();
export function RuleStackView({
className,
onTemplateClick,
gridEntries,
}: RuleStackViewProps) {
const t = useTranslation("pages.home.ruleStack");
const buttonText = t("button.seeAllTemplates");
@@ -37,10 +36,14 @@ export function RuleStackView({
variant="multi-line"
/>
<GovernanceTemplateGrid
entries={homeFeaturedTemplates}
onTemplateClick={onTemplateClick}
/>
{gridEntries === null ? (
<GovernanceTemplateGridSkeleton count={4} />
) : (
<GovernanceTemplateGrid
entries={gridEntries}
onTemplateClick={onTemplateClick}
/>
)}
<div
className="