Load rule templates from API
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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="
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user