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
@@ -0,0 +1,23 @@
import dynamic from "next/dynamic";
import { listRuleTemplatesFromDb } from "../../lib/server/ruleTemplates";
import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../lib/templates/governanceTemplateCatalog";
import { gridEntriesForSlugOrderWithCatalogFallback } from "../../lib/templates/templateGridPresentation";
const RuleStack = dynamic(() => import("../components/sections/RuleStack"), {
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
),
ssr: true,
});
/**
* Server-loaded “Popular templates” row so the first paint has card data without a client fetch.
*/
export async function MarketingRuleStackSection() {
const rows = await listRuleTemplatesFromDb();
const initialGridEntries = gridEntriesForSlugOrderWithCatalogFallback(
rows,
GOVERNANCE_TEMPLATE_HOME_SLUGS,
);
return <RuleStack initialGridEntries={initialGridEntries} />;
}
+9 -8
View File
@@ -1,8 +1,10 @@
import dynamic from "next/dynamic";
import { Suspense } from "react";
import messages from "../../messages/en/index";
import { getTranslation } from "../../lib/i18n/getTranslation";
import HeroBanner from "../components/sections/HeroBanner";
import AskOrganizer from "../components/sections/AskOrganizer";
import { MarketingRuleStackSection } from "./MarketingRuleStackSection";
// Code split below-the-fold components to reduce initial bundle size
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
@@ -22,13 +24,6 @@ const NumberedCards = dynamic(
},
);
const RuleStack = dynamic(() => import("../components/sections/RuleStack"), {
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
),
ssr: true,
});
const FeatureGrid = dynamic(
() => import("../components/sections/FeatureGrid"),
{
@@ -98,7 +93,13 @@ export default function Page() {
<HeroBanner {...heroBannerData} />
<LogoWall />
<NumberedCards {...numberedCardsData} />
<RuleStack />
<Suspense
fallback={
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
}
>
<MarketingRuleStackSection />
</Suspense>
<FeatureGrid {...featureGridData} />
<QuoteBlock />
<AskOrganizer {...askOrganizerData} />
@@ -0,0 +1,54 @@
"use client";
import { useRouter } from "next/navigation";
import HeaderLockup from "../../components/type/HeaderLockup";
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
import { useTranslation } from "../../contexts/MessagesContext";
export interface TemplatesPageClientProps {
initialGridEntries: TemplateGridCardEntry[];
}
/**
* Full templates index — Figma 22142-898446 (title, intro, 2-col card grid).
* `initialGridEntries` is computed on the server to avoid a client-side loading flash.
*/
export default function TemplatesPageClient({
initialGridEntries,
}: TemplatesPageClientProps) {
const router = useRouter();
const t = useTranslation("pages.templates");
return (
<div className="w-full bg-black text-[var(--color-content-default-primary,white)]">
<div
className="
mx-auto w-full max-w-[1280px]
px-[20px] py-[32px]
min-[640px]:px-[32px] min-[640px]:py-[40px]
min-[1024px]:px-[64px] min-[1024px]:py-[48px]
"
>
<div className="flex w-full flex-col gap-2 py-3">
<HeaderLockup
title={t("title")}
description={t("subtitle")}
justification="left"
size="L"
/>
</div>
<div className="mt-6 min-[1024px]:mt-8">
<GovernanceTemplateGrid
entries={initialGridEntries}
onTemplateClick={(slug) => {
router.push(
`/create/review-template/${encodeURIComponent(slug)}`,
);
}}
/>
</div>
</div>
</div>
);
}
+7 -45
View File
@@ -1,47 +1,9 @@
"use client";
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation";
import TemplatesPageClient from "./TemplatesPageClient";
import { useRouter } from "next/navigation";
import HeaderLockup from "../../components/type/HeaderLockup";
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
import { GOVERNANCE_TEMPLATE_CATALOG } from "../../../lib/templates/governanceTemplateCatalog";
import { useTranslation } from "../../contexts/MessagesContext";
/**
* Full templates index — Figma 22142-898446 (title, intro, 2-col card grid).
*/
export default function TemplatesPage() {
const router = useRouter();
const t = useTranslation("pages.templates");
return (
<div className="w-full bg-black text-[var(--color-content-default-primary,white)]">
<div
className="
mx-auto w-full max-w-[1280px]
px-[20px] py-[32px]
min-[640px]:px-[32px] min-[640px]:py-[40px]
min-[1024px]:px-[64px] min-[1024px]:py-[48px]
"
>
<div className="flex w-full flex-col gap-2 py-3">
<HeaderLockup
title={t("title")}
description={t("subtitle")}
justification="left"
size="L"
/>
</div>
<div className="mt-6 min-[1024px]:mt-8">
<GovernanceTemplateGrid
entries={GOVERNANCE_TEMPLATE_CATALOG}
onTemplateClick={(slug) => {
router.push(
`/create/review-template/${encodeURIComponent(slug)}`,
);
}}
/>
</div>
</div>
</div>
);
export default async function TemplatesPage() {
const rows = await listRuleTemplatesFromDb();
const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows);
return <TemplatesPageClient initialGridEntries={initialGridEntries} />;
}
+2 -13
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../lib/server/db";
import { isDatabaseConfigured } from "../../../lib/server/env";
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
import { dbUnavailable } from "../../../lib/server/responses";
/**
@@ -11,18 +11,7 @@ export async function GET() {
return dbUnavailable();
}
const templates = await prisma.ruleTemplate.findMany({
orderBy: [{ featured: "desc" }, { sortOrder: "asc" }, { title: "asc" }],
select: {
id: true,
slug: true,
title: true,
category: true,
description: true,
body: true,
featured: true,
},
});
const templates = await listRuleTemplatesFromDb();
return NextResponse.json({ templates });
}
@@ -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="
+23 -11
View File
@@ -5,6 +5,7 @@ import { TemplateReviewCard } from "../../../components/cards/TemplateReviewCard
import { useTranslation } from "../../../contexts/MessagesContext";
import {
fetchTemplateBySlug,
isTemplatesFetchAborted,
type RuleTemplateDto,
} from "../../../../lib/create/fetchTemplates";
import messages from "../../../../messages/en/index";
@@ -35,28 +36,39 @@ export default function ReviewTemplatePage({ params }: PageProps) {
const [loading, setLoading] = useState(true);
useEffect(() => {
const ac = new AbortController();
let cancelled = false;
void (async () => {
if (!cancelled) {
setLoading(true);
setError(null);
}
const result = await fetchTemplateBySlug(slug);
if (cancelled) return;
if (result === null) {
setError(messages.create.templateReview.errors.notFound);
try {
const result = await fetchTemplateBySlug(slug, {
signal: ac.signal,
});
if (cancelled) return;
if (result === null) {
setError(messages.create.templateReview.errors.notFound);
setTemplate(null);
} else if ("error" in result) {
setError(result.error);
setTemplate(null);
} else {
setTemplate(result);
setError(null);
}
} catch (e) {
if (cancelled || isTemplatesFetchAborted(e)) return;
setError(messages.create.templateReview.errors.loadFailed);
setTemplate(null);
} else if ("error" in result) {
setError(result.error);
setTemplate(null);
} else {
setTemplate(result);
setError(null);
} finally {
if (!cancelled) setLoading(false);
}
setLoading(false);
})();
return () => {
cancelled = true;
ac.abort();
};
}, [slug]);