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]);
|
||||
|
||||
|
||||
@@ -9,16 +9,36 @@ export type RuleTemplateDto = {
|
||||
category: string | null;
|
||||
description: string | null;
|
||||
body: unknown;
|
||||
sortOrder: number;
|
||||
featured: boolean;
|
||||
};
|
||||
|
||||
type TemplatesResponse = { templates?: RuleTemplateDto[] };
|
||||
|
||||
export async function fetchTemplates(): Promise<
|
||||
RuleTemplateDto[] | { error: string }
|
||||
> {
|
||||
export type FetchTemplatesOptions = {
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
function isAbortError(e: unknown): boolean {
|
||||
return (
|
||||
(e instanceof DOMException && e.name === "AbortError") ||
|
||||
(e instanceof Error && e.name === "AbortError")
|
||||
);
|
||||
}
|
||||
|
||||
/** For callers that `catch` around `fetchTemplates` / `fetchTemplateBySlug`. */
|
||||
export function isTemplatesFetchAborted(e: unknown): boolean {
|
||||
return isAbortError(e);
|
||||
}
|
||||
|
||||
export async function fetchTemplates(
|
||||
options?: FetchTemplatesOptions,
|
||||
): Promise<RuleTemplateDto[] | { error: string }> {
|
||||
try {
|
||||
const res = await fetch("/api/templates", { credentials: "include" });
|
||||
const res = await fetch("/api/templates", {
|
||||
credentials: "include",
|
||||
signal: options?.signal,
|
||||
});
|
||||
const data = (await res.json()) as TemplatesResponse & { error?: string };
|
||||
if (!res.ok) {
|
||||
return {
|
||||
@@ -29,15 +49,19 @@ export async function fetchTemplates(): Promise<
|
||||
};
|
||||
}
|
||||
return Array.isArray(data.templates) ? data.templates : [];
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) {
|
||||
throw e;
|
||||
}
|
||||
return { error: "Could not load templates" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTemplateBySlug(
|
||||
slug: string,
|
||||
options?: FetchTemplatesOptions,
|
||||
): Promise<RuleTemplateDto | null | { error: string }> {
|
||||
const result = await fetchTemplates();
|
||||
const result = await fetchTemplates(options);
|
||||
if ("error" in result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { RuleTemplateDto } from "../create/fetchTemplates";
|
||||
import { prisma } from "./db";
|
||||
import { isDatabaseConfigured } from "./env";
|
||||
|
||||
/**
|
||||
* Curated templates for public list UIs (same query as GET /api/templates).
|
||||
* Returns [] when the database is not configured or on query failure.
|
||||
*/
|
||||
export async function listRuleTemplatesFromDb(): Promise<RuleTemplateDto[]> {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return await prisma.ruleTemplate.findMany({
|
||||
orderBy: [{ featured: "desc" }, { sortOrder: "asc" }, { title: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
category: true,
|
||||
description: true,
|
||||
body: true,
|
||||
sortOrder: true,
|
||||
featured: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { RuleTemplateDto } from "../create/fetchTemplates";
|
||||
import { templateSummaryFromBody } from "../create/templateReviewMapping";
|
||||
import type { GovernanceTemplateCatalogEntry } from "./governanceTemplateCatalog";
|
||||
import {
|
||||
GOVERNANCE_TEMPLATE_CATALOG,
|
||||
getGovernanceTemplateCatalogEntry,
|
||||
governanceTemplateIconPath,
|
||||
} from "./governanceTemplateCatalog";
|
||||
|
||||
/** Matches TemplateReviewCard when slug is absent from the Figma catalog. */
|
||||
export const TEMPLATE_GRID_FALLBACK_PRESENTATION = {
|
||||
iconPath: governanceTemplateIconPath("consensus"),
|
||||
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
|
||||
} as const;
|
||||
|
||||
export type TemplateGridCardEntry = GovernanceTemplateCatalogEntry;
|
||||
|
||||
function presentationForSlug(slug: string): Pick<
|
||||
GovernanceTemplateCatalogEntry,
|
||||
"iconPath" | "backgroundColor"
|
||||
> {
|
||||
const catalog = getGovernanceTemplateCatalogEntry(slug);
|
||||
return catalog ?? TEMPLATE_GRID_FALLBACK_PRESENTATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* One grid card: API copy + Figma icon/surface from catalog (or fallback).
|
||||
*/
|
||||
export function ruleTemplateToGridEntry(template: RuleTemplateDto): TemplateGridCardEntry {
|
||||
const pres = presentationForSlug(template.slug);
|
||||
const description = templateSummaryFromBody(template.description, template.body);
|
||||
return {
|
||||
slug: template.slug,
|
||||
title: template.title,
|
||||
description,
|
||||
iconPath: pres.iconPath,
|
||||
backgroundColor: pres.backgroundColor,
|
||||
};
|
||||
}
|
||||
|
||||
const bySlug = (templates: RuleTemplateDto[]) =>
|
||||
new Map(templates.map((t) => [t.slug, t] as const));
|
||||
|
||||
/**
|
||||
* Ordered subset for home: follow `slugOrder`; skip missing slugs.
|
||||
*/
|
||||
export function gridEntriesForSlugOrder(
|
||||
templates: RuleTemplateDto[],
|
||||
slugOrder: readonly string[],
|
||||
): TemplateGridCardEntry[] {
|
||||
const map = bySlug(templates);
|
||||
const out: TemplateGridCardEntry[] = [];
|
||||
for (const slug of slugOrder) {
|
||||
const t = map.get(slug);
|
||||
if (t) out.push(ruleTemplateToGridEntry(t));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Home row: prefer API row per slug; if missing, use static Figma catalog entry.
|
||||
*/
|
||||
export function gridEntriesForSlugOrderWithCatalogFallback(
|
||||
templates: RuleTemplateDto[],
|
||||
slugOrder: readonly string[],
|
||||
): TemplateGridCardEntry[] {
|
||||
const map = bySlug(templates);
|
||||
const out: TemplateGridCardEntry[] = [];
|
||||
for (const slug of slugOrder) {
|
||||
const t = map.get(slug);
|
||||
if (t) {
|
||||
out.push(ruleTemplateToGridEntry(t));
|
||||
continue;
|
||||
}
|
||||
const cat = getGovernanceTemplateCatalogEntry(slug);
|
||||
if (cat) out.push(cat);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full templates index: `featured` first, then `sortOrder`, then title.
|
||||
*/
|
||||
export function gridEntriesForFullCatalog(templates: RuleTemplateDto[]): TemplateGridCardEntry[] {
|
||||
const withSort = [...templates].sort((a, b) => {
|
||||
if (a.featured !== b.featured) return a.featured ? -1 : 1;
|
||||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
return withSort.map(ruleTemplateToGridEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marketing `/templates`: use API order when rows exist; otherwise static catalog.
|
||||
*/
|
||||
export function gridEntriesForFullCatalogWithFallback(
|
||||
templates: RuleTemplateDto[],
|
||||
): TemplateGridCardEntry[] {
|
||||
if (templates.length === 0) {
|
||||
return [...GOVERNANCE_TEMPLATE_CATALOG];
|
||||
}
|
||||
return gridEntriesForFullCatalog(templates);
|
||||
}
|
||||
+178
-314
@@ -6,7 +6,10 @@ import { PrismaClient, type Prisma } from "@prisma/client";
|
||||
* DB titles/descriptions should stay aligned with `governanceTemplateCatalog.ts`.
|
||||
* `body.sections` use the same category row labels as the final-review RuleCard
|
||||
* (Values, Communication, Membership, Decision-making, Conflict management) so
|
||||
* template review matches that layout; `entries[].title` = chip labels, `body` = long text for documents.
|
||||
* template review matches that layout; `entries[].title` = chip labels, `body` = supporting text.
|
||||
* Chip titles per template are sourced from the product **Template Composition** workbook (xlsx column
|
||||
* layout: Decision-making, Membership Policies, Values, Communication, Conflict Management), mapped in
|
||||
* `COMPOSITION_BY_SLUG` below.
|
||||
*/
|
||||
|
||||
/** Starter `body` for catalog templates — five category rows match template review / final-review layout. */
|
||||
@@ -57,6 +60,165 @@ function governancePatternBody(coreValues: string): Prisma.InputJsonValue {
|
||||
};
|
||||
}
|
||||
|
||||
/** Chip copy from Template Composition.xlsx (Decision-making, Membership, Values, Communication, Conflict). */
|
||||
const COMPOSITION_CHIP_BODY =
|
||||
"Suggested focus for this governance area. Replace with your own language in the create flow.";
|
||||
|
||||
function entriesFromCompositionCell(cell: string): { title: string; body: string }[] {
|
||||
const trimmed = cell.trim();
|
||||
if (!trimmed) return [];
|
||||
return trimmed
|
||||
.split(/,\s*/)
|
||||
.map((title) => title.trim())
|
||||
.filter(Boolean)
|
||||
.map((title) => ({ title, body: COMPOSITION_CHIP_BODY }));
|
||||
}
|
||||
|
||||
function bodyFromXlsxComposition(row: {
|
||||
decisionMaking: string;
|
||||
membership: string;
|
||||
values: string;
|
||||
communication: string;
|
||||
conflict: string;
|
||||
}): Prisma.InputJsonValue {
|
||||
return {
|
||||
sections: [
|
||||
{ categoryName: "Values", entries: entriesFromCompositionCell(row.values) },
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: entriesFromCompositionCell(row.communication),
|
||||
},
|
||||
{
|
||||
categoryName: "Membership",
|
||||
entries: entriesFromCompositionCell(row.membership),
|
||||
},
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: entriesFromCompositionCell(row.decisionMaking),
|
||||
},
|
||||
{
|
||||
categoryName: "Conflict management",
|
||||
entries: entriesFromCompositionCell(row.conflict),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated template chip rows — sourced from product Template Composition.xlsx
|
||||
* (Governance Template × category columns).
|
||||
*/
|
||||
const COMPOSITION_BY_SLUG: Record<
|
||||
string,
|
||||
{
|
||||
decisionMaking: string;
|
||||
membership: string;
|
||||
values: string;
|
||||
communication: string;
|
||||
conflict: string;
|
||||
}
|
||||
> = {
|
||||
consensus: {
|
||||
decisionMaking: "Consensus Decision-Making, Modified Consensus",
|
||||
membership: "Consensus or Vote-Based Approval, Peer Sponsorship",
|
||||
values: "Consensus, Community Care, Horizontalism",
|
||||
communication: "In-Person Meetings, Loomio",
|
||||
conflict: "Consensus Building, Facilitated Negotiation",
|
||||
},
|
||||
"consensus-clusters": {
|
||||
decisionMaking: "Sociocracy, Holacracy",
|
||||
membership: "Contribution Based, Orientation Required",
|
||||
values: "Decentralization, Adaptability, Autonomy",
|
||||
communication: "Slack, Matrix / Element",
|
||||
conflict: "Circle Processes, Restorative Practices",
|
||||
},
|
||||
"solidarity-network": {
|
||||
decisionMaking: "Do-ocracy, Modified Consensus",
|
||||
membership: "Open Access, Peer Sponsorship",
|
||||
values: "Solidarity, Mutual Aid, Anti-oppression",
|
||||
communication: "Signal, Matrix / Element",
|
||||
conflict: "Peer Mediation, Restorative Practices",
|
||||
},
|
||||
"sortition-jury": {
|
||||
decisionMaking: "Lottery/Sortition, Deliberative Polling",
|
||||
membership: "Lottery / Sortition",
|
||||
values: "Fairness, Equity, Transparency",
|
||||
communication: "In-Person Meetings, Video Meetings",
|
||||
conflict: "Lottery/Sortition, Rotational Judging",
|
||||
},
|
||||
"liquid-democracy": {
|
||||
decisionMaking: "Delegated Decision-Making, Continuous Voting",
|
||||
membership: "Identity Verification, Open Access",
|
||||
values: "Agency, Flexibility, Transparency",
|
||||
communication: "Loomio, Discourse (Forum)",
|
||||
conflict: "Ad Hoc Arbitration, Peer Mediation",
|
||||
},
|
||||
"do-ocracy": {
|
||||
decisionMaking: "Do-ocracy, Lazy Consensus",
|
||||
membership: "Contribution Based, Skill-Based Contribution",
|
||||
values: "Agency, Autonomy, Voluntarism",
|
||||
communication: "GitHub / GitLab, Discord",
|
||||
conflict: "Peer Mediation, Facilitated Negotiation",
|
||||
},
|
||||
"quadratic-governance": {
|
||||
decisionMaking: "Quadratic Voting",
|
||||
membership: "Identity Verification, Pay-to-Join",
|
||||
values: "Fairness, Innovation, Independence",
|
||||
communication: "Discourse (Forum), Discord",
|
||||
conflict: "Supermajority Vote, Conflict Resolution Council",
|
||||
},
|
||||
"federated-clusters": {
|
||||
decisionMaking: "Consensus Seeking with Delegates",
|
||||
membership: "Hybrid Approval Process, Membership Agreement or Pledge",
|
||||
values: "Interoperability, Localism, Interdependence",
|
||||
communication: "Matrix / Element, Discourse (Forum)",
|
||||
conflict: "Internal Tribunal, Ad Hoc Arbitration",
|
||||
},
|
||||
devolution: {
|
||||
decisionMaking: "Autocratic Decision-Making, Delegated Decision-Making",
|
||||
membership: "Invitation Only, Open Access",
|
||||
values: "Capacity Building, Education, Maintenance",
|
||||
communication: "Discord, GitHub / GitLab",
|
||||
conflict: "Conflict Workshops, Managerial Decision",
|
||||
},
|
||||
"benevolent-dictator": {
|
||||
decisionMaking: "Autocratic Decision-Making, Hierarchical Decision-Making",
|
||||
membership: "Invitation Only, Mentorship",
|
||||
values: "Reliability, Stewardship, Leadership",
|
||||
communication: "Email Distribution List, GitHub / GitLab",
|
||||
conflict: "Managerial Decision, Binding Contracts",
|
||||
},
|
||||
petition: {
|
||||
decisionMaking: "Ranked Choice Voting, Majority Rule",
|
||||
membership: "Open Access, Identity Verification",
|
||||
values: "Freedom of Information, Accountability, Participation",
|
||||
communication: "Loomio, Discourse (Forum)",
|
||||
conflict: "Supermajority Vote, Binding Arbitration",
|
||||
},
|
||||
"self-appointed-board": {
|
||||
decisionMaking: "Advisory Committees, Executive Committees",
|
||||
membership: "Invitation Only, Application & Review",
|
||||
values: "Stewardship, Resilience, Reliability",
|
||||
communication: "Video Meetings, Email Distribution List",
|
||||
conflict: "Judicial Committees, Internal Tribunal",
|
||||
},
|
||||
"elected-board": {
|
||||
decisionMaking: "Elected Board of Directors, Majority Rule",
|
||||
membership: "Application & Review, Membership Agreement or Pledge",
|
||||
values: "Accountability, Transparency, Trust",
|
||||
communication: "Email Distribution List, Slack",
|
||||
conflict: "Conflict Resolution Council, Mediation",
|
||||
},
|
||||
};
|
||||
|
||||
function bodyFromSlugComposition(slug: string): Prisma.InputJsonValue {
|
||||
const row = COMPOSITION_BY_SLUG[slug];
|
||||
if (!row) {
|
||||
return governancePatternBody("Template composition pending.");
|
||||
}
|
||||
return bodyFromXlsxComposition(row);
|
||||
}
|
||||
|
||||
const TEMPLATES: {
|
||||
slug: string;
|
||||
title: string;
|
||||
@@ -74,239 +236,27 @@ const TEMPLATES: {
|
||||
"Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council.",
|
||||
sortOrder: 0,
|
||||
featured: true,
|
||||
body: {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{
|
||||
title: "Distributed authority",
|
||||
body: "Authority lives in Circles close to the work. Domains are explicit so power is visible and negotiable.",
|
||||
},
|
||||
{
|
||||
title: "Transparency",
|
||||
body: "Decisions, roles, and metrics that affect members are easy to find and updated regularly.",
|
||||
},
|
||||
{
|
||||
title: "Equivalence",
|
||||
body: "Policy affects people in proportion to their stake; no silent vetoes from outside a domain.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [
|
||||
{
|
||||
title: "Circle channels",
|
||||
body: "Each Circle maintains channels for async updates and synchronous sense-making.",
|
||||
},
|
||||
{
|
||||
title: "Council cadence",
|
||||
body: "The Council meets on a fixed rhythm to align strategy, resolve overlaps, and hear escalations.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Membership",
|
||||
entries: [
|
||||
{
|
||||
title: "Circle membership",
|
||||
body: "People join Circles by agreement of that Circle and clarity on domain contribution.",
|
||||
},
|
||||
{
|
||||
title: "Link roles",
|
||||
body: "Members link Circles as delegates or representatives when decisions span domains.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: [
|
||||
{
|
||||
title: "Consent within Circles",
|
||||
body: "Circles act when there is no reasoned objection from anyone in the Circle with a stake.",
|
||||
},
|
||||
{
|
||||
title: "Cross-domain consent",
|
||||
body: "When work spans Circles, proposals include impacted domains and integrate their concerns.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Conflict management",
|
||||
entries: [
|
||||
{
|
||||
title: "Objection testing",
|
||||
body: "Objections must show how a proposal fails the aim or creates harm; the group integrates or adapts.",
|
||||
},
|
||||
{
|
||||
title: "Mediation",
|
||||
body: "Facilitators help parties hear each other before escalating to Council or broader processes.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
body: bodyFromSlugComposition("consensus-clusters"),
|
||||
},
|
||||
{
|
||||
slug: "consensus",
|
||||
title: "Consensus",
|
||||
category: "Governance pattern",
|
||||
description:
|
||||
"Important decisions require broad agreement. Proposals move forward when serious objections are resolved and the group can stand behind the outcome.",
|
||||
"Important decisions require unanimous agreement. Proposals pass only if no serious objections remain.",
|
||||
sortOrder: 1,
|
||||
featured: true,
|
||||
body: {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{
|
||||
title: "Consciousness",
|
||||
body: "We make decisions with awareness of impact on people, ecosystems, and future generations.",
|
||||
},
|
||||
{
|
||||
title: "Ecology",
|
||||
body: "We design governance to reduce harm and regenerate the systems we depend on.",
|
||||
},
|
||||
{
|
||||
title: "Abundance",
|
||||
body: "We assume enough for needs when resources are shared fairly and waste is reduced.",
|
||||
},
|
||||
{
|
||||
title: "Art",
|
||||
body: "We leave room for creativity, culture, and expression in how we organize.",
|
||||
},
|
||||
{
|
||||
title: "Decisiveness",
|
||||
body: "We balance care with forward motion—clear timelines and roles prevent endless loops.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [
|
||||
{
|
||||
title: "Signal",
|
||||
body: "We use Signal (or equivalent) for sensitive coordination and timely member updates.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Membership",
|
||||
entries: [
|
||||
{
|
||||
title: "Open Admission",
|
||||
body: "People who share our values and agree to practices can participate without unnecessary gatekeeping.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: [
|
||||
{
|
||||
title: "Lazy Consensus",
|
||||
body: "Proposals advance if no blocking concern is raised within the discussion window.",
|
||||
},
|
||||
{
|
||||
title: "Modified Consensus",
|
||||
body: "For larger decisions we use structured consensus with documented objections and integration steps.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Conflict management",
|
||||
entries: [
|
||||
{
|
||||
title: "Code of Conduct",
|
||||
body: "We uphold a code of conduct that sets expectations and pathways for accountability.",
|
||||
},
|
||||
{
|
||||
title: "Restorative Justice",
|
||||
body: "When harm occurs we prioritize repair, learning, and changed conditions over punishment.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
body: bodyFromSlugComposition("consensus"),
|
||||
},
|
||||
{
|
||||
slug: "elected-board",
|
||||
title: "Elected Board",
|
||||
category: "Governance pattern",
|
||||
description:
|
||||
"Members elect a board to steward policy and operations. The board coordinates implementation and remains accountable through transparent reporting and elections.",
|
||||
"An elected board determines policies and organizes their implementation.",
|
||||
sortOrder: 2,
|
||||
featured: true,
|
||||
body: {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{
|
||||
title: "Accountability",
|
||||
body: "The board answers to the membership through elections, published decisions, and recall where applicable.",
|
||||
},
|
||||
{
|
||||
title: "Service",
|
||||
body: "Board service is a temporary duty with term limits and clarity on scope of authority.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [
|
||||
{
|
||||
title: "Board minutes",
|
||||
body: "Minutes summarize decisions, rationales, and next steps; members can access them on a regular cadence.",
|
||||
},
|
||||
{
|
||||
title: "Member forums",
|
||||
body: "The board hosts open sessions for questions, priorities, and feedback from the membership.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Membership",
|
||||
entries: [
|
||||
{
|
||||
title: "Eligibility to vote",
|
||||
body: "Voting members are defined clearly; associate or advisory roles are distinguished from full votes.",
|
||||
},
|
||||
{
|
||||
title: "Board terms",
|
||||
body: "Staggered terms keep continuity while refreshing leadership on a predictable schedule.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: [
|
||||
{
|
||||
title: "Board vote",
|
||||
body: "The board decides matters in its charter by majority or supermajority as specified.",
|
||||
},
|
||||
{
|
||||
title: "Member ratification",
|
||||
body: "Major structural changes require member approval according to your bylaws.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Conflict management",
|
||||
entries: [
|
||||
{
|
||||
title: "Recusal",
|
||||
body: "Directors recuse themselves when personal or financial conflicts appear.",
|
||||
},
|
||||
{
|
||||
title: "Appeals",
|
||||
body: "Members can appeal board decisions through a defined, fair process.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
body: bodyFromSlugComposition("elected-board"),
|
||||
},
|
||||
{
|
||||
slug: "petition",
|
||||
@@ -316,75 +266,7 @@ const TEMPLATES: {
|
||||
"Any participant can propose a rule change. If enough sign it, it goes to a general vote.",
|
||||
sortOrder: 3,
|
||||
featured: true,
|
||||
body: {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{
|
||||
title: "Open participation",
|
||||
body: "Legitimate voices can bring proposals without needing informal gatekeepers.",
|
||||
},
|
||||
{
|
||||
title: "Legitimacy",
|
||||
body: "Outcomes are trusted when process, quorum, and notice rules are followed consistently.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [
|
||||
{
|
||||
title: "Discussion period",
|
||||
body: "Every proposal has a visible discussion window before voting closes.",
|
||||
},
|
||||
{
|
||||
title: "Announcements",
|
||||
body: "Calls to vote and results are posted where all participants can see them.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Membership",
|
||||
entries: [
|
||||
{
|
||||
title: "Voting pool",
|
||||
body: "Who may vote is explicit (e.g. members in good standing for 30 days).",
|
||||
},
|
||||
{
|
||||
title: "Quorum",
|
||||
body: "Votes count only when quorum is met so decisions reflect an engaged subset.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: [
|
||||
{
|
||||
title: "Petition threshold",
|
||||
body: "Sponsors or seconders may be required so proposals show a minimal base of support.",
|
||||
},
|
||||
{
|
||||
title: "Majority rules",
|
||||
body: "Adoption thresholds (simple majority, supermajority) are defined per decision type.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Conflict management",
|
||||
entries: [
|
||||
{
|
||||
title: "Good faith",
|
||||
body: "Debate focuses on substance; harassment or bad-faith tactics are addressed under conduct policies.",
|
||||
},
|
||||
{
|
||||
title: "Ombuds",
|
||||
body: "A neutral contact helps people navigate disputes about process or interpretation.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
body: bodyFromSlugComposition("petition"),
|
||||
},
|
||||
{
|
||||
slug: "solidarity-network",
|
||||
@@ -394,9 +276,7 @@ const TEMPLATES: {
|
||||
'Power is held by autonomous "cells." A central hub acts as a switchboard for resources but cannot dictate cell activities.',
|
||||
sortOrder: 4,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
'Power is held by autonomous "cells." A central hub acts as a switchboard for resources but cannot dictate cell activities.',
|
||||
),
|
||||
body: bodyFromSlugComposition("solidarity-network"),
|
||||
},
|
||||
{
|
||||
slug: "sortition-jury",
|
||||
@@ -406,9 +286,7 @@ const TEMPLATES: {
|
||||
"A representative sample of the community is chosen by lottery to form a temporary council.",
|
||||
sortOrder: 5,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"A representative sample of the community is chosen by lottery to form a temporary council.",
|
||||
),
|
||||
body: bodyFromSlugComposition("sortition-jury"),
|
||||
},
|
||||
{
|
||||
slug: "liquid-democracy",
|
||||
@@ -418,9 +296,7 @@ const TEMPLATES: {
|
||||
"Members can vote directly or delegate their vote to a trusted peer on a per-topic basis.",
|
||||
sortOrder: 6,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"Members can vote directly or delegate their vote to a trusted peer on a per-topic basis.",
|
||||
),
|
||||
body: bodyFromSlugComposition("liquid-democracy"),
|
||||
},
|
||||
{
|
||||
slug: "do-ocracy",
|
||||
@@ -430,9 +306,7 @@ const TEMPLATES: {
|
||||
"Authority is granted to those doing the work. If you do the task, you decide how it gets done.",
|
||||
sortOrder: 7,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"Authority is granted to those doing the work. If you do the task, you decide how it gets done.",
|
||||
),
|
||||
body: bodyFromSlugComposition("do-ocracy"),
|
||||
},
|
||||
{
|
||||
slug: "quadratic-governance",
|
||||
@@ -442,9 +316,7 @@ const TEMPLATES: {
|
||||
"Voting cost is squared (V²), preventing a majority from steamrolling a passionate minority.",
|
||||
sortOrder: 8,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"Voting cost is squared (V²), preventing a majority from steamrolling a passionate minority.",
|
||||
),
|
||||
body: bodyFromSlugComposition("quadratic-governance"),
|
||||
},
|
||||
{
|
||||
slug: "federated-clusters",
|
||||
@@ -454,9 +326,7 @@ const TEMPLATES: {
|
||||
"Independent groups share a central brand/charter but have total autonomy over internal rules.",
|
||||
sortOrder: 9,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"Independent groups share a central brand/charter but have total autonomy over internal rules.",
|
||||
),
|
||||
body: bodyFromSlugComposition("federated-clusters"),
|
||||
},
|
||||
{
|
||||
slug: "devolution",
|
||||
@@ -466,9 +336,7 @@ const TEMPLATES: {
|
||||
"Starts as a Dictatorship for speed, moving to a Board, and finally to full community ownership.",
|
||||
sortOrder: 10,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"Starts as a Dictatorship for speed, moving to a Board, and finally to full community ownership.",
|
||||
),
|
||||
body: bodyFromSlugComposition("devolution"),
|
||||
},
|
||||
{
|
||||
slug: "benevolent-dictator",
|
||||
@@ -478,9 +346,7 @@ const TEMPLATES: {
|
||||
"A single individual holds ultimate power, usually intended as a temporary state until the project is stable.",
|
||||
sortOrder: 11,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"A single individual holds ultimate power, usually intended as a temporary state until the project is stable.",
|
||||
),
|
||||
body: bodyFromSlugComposition("benevolent-dictator"),
|
||||
},
|
||||
{
|
||||
slug: "self-appointed-board",
|
||||
@@ -490,9 +356,7 @@ const TEMPLATES: {
|
||||
"An existing board selects its own successors to preserve a specific mission over time.",
|
||||
sortOrder: 12,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"An existing board selects its own successors to preserve a specific mission over time.",
|
||||
),
|
||||
body: bodyFromSlugComposition("self-appointed-board"),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,8 +1,44 @@
|
||||
import React from "react";
|
||||
import RuleStack from "../../app/components/sections/RuleStack";
|
||||
import { GOVERNANCE_TEMPLATE_CATALOG } from "../../lib/templates/governanceTemplateCatalog";
|
||||
|
||||
function buildStoryTemplatesPayload() {
|
||||
return GOVERNANCE_TEMPLATE_CATALOG.map((c, i) => ({
|
||||
id: `story-${c.slug}`,
|
||||
slug: c.slug,
|
||||
title: c.title,
|
||||
category: "Governance pattern",
|
||||
description: c.description,
|
||||
body: { sections: [] },
|
||||
sortOrder: i,
|
||||
featured: i < 4,
|
||||
}));
|
||||
}
|
||||
|
||||
function withMockTemplatesApi(Story) {
|
||||
React.useLayoutEffect(() => {
|
||||
const prev = global.fetch;
|
||||
global.fetch = async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
if (String(url).includes("/api/templates")) {
|
||||
return new Response(
|
||||
JSON.stringify({ templates: buildStoryTemplatesPayload() }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return prev(input, init);
|
||||
};
|
||||
return () => {
|
||||
global.fetch = prev;
|
||||
};
|
||||
}, []);
|
||||
return <Story />;
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "Components/Sections/RuleStack",
|
||||
component: RuleStack,
|
||||
decorators: [withMockTemplatesApi],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
cleanup,
|
||||
waitFor,
|
||||
} from "../utils/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { vi, describe, test, expect, afterEach, beforeEach } from "vitest";
|
||||
@@ -10,22 +11,70 @@ import RuleStack from "../../app/components/sections/RuleStack";
|
||||
import { testRouter } from "../mocks/navigation";
|
||||
import {
|
||||
GOVERNANCE_TEMPLATE_CATALOG,
|
||||
GOVERNANCE_TEMPLATE_HOME_SLUGS,
|
||||
getGovernanceTemplatesForHome,
|
||||
} from "../../lib/templates/governanceTemplateCatalog";
|
||||
|
||||
const homeFeatured = getGovernanceTemplatesForHome();
|
||||
|
||||
function mockTemplatesApiSuccess() {
|
||||
const templatesPayload = GOVERNANCE_TEMPLATE_HOME_SLUGS.map((slug, i) => {
|
||||
const cat = GOVERNANCE_TEMPLATE_CATALOG.find((e) => e.slug === slug);
|
||||
if (!cat) throw new Error(`missing catalog slug ${slug}`);
|
||||
return {
|
||||
id: `test-${slug}`,
|
||||
slug,
|
||||
title: cat.title,
|
||||
category: "Governance pattern",
|
||||
description: cat.description,
|
||||
body: { sections: [] },
|
||||
sortOrder: i,
|
||||
featured: true,
|
||||
};
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
if (url.endsWith("/api/templates")) {
|
||||
return new Response(JSON.stringify({ templates: templatesPayload }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testRouter.push.mockClear();
|
||||
mockTemplatesApiSuccess();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
async function waitForRuleStackCards() {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Circles")).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
|
||||
describe("RuleStack Component", () => {
|
||||
test("renders four featured governance template cards on the home row", () => {
|
||||
test("skips client fetch when initialGridEntries is provided (SSR path)", () => {
|
||||
const fetchMock = vi.mocked(global.fetch);
|
||||
const callsBefore = fetchMock.mock.calls.length;
|
||||
render(<RuleStack initialGridEntries={homeFeatured} />);
|
||||
expect(screen.getByText("Circles")).toBeInTheDocument();
|
||||
expect(fetchMock.mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
|
||||
test("renders four featured governance template cards on the home row", async () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
for (const entry of homeFeatured) {
|
||||
expect(screen.getByText(entry.title)).toBeInTheDocument();
|
||||
@@ -38,15 +87,17 @@ describe("RuleStack Component", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with custom className", () => {
|
||||
test("renders with custom className", async () => {
|
||||
render(<RuleStack className="custom-class" />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const section = document.querySelector("section");
|
||||
expect(section).toHaveClass("custom-class");
|
||||
});
|
||||
|
||||
test("renders sample rule card descriptions from featured catalog", () => {
|
||||
test("renders sample rule card descriptions from featured catalog", async () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
expect(
|
||||
screen.getByText(/Units called Circles have the ability to decide/),
|
||||
@@ -66,8 +117,9 @@ describe("RuleStack Component", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders rule card icons with image assets", () => {
|
||||
test("renders rule card icons with image assets", async () => {
|
||||
const { container } = render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const imgs = container.querySelectorAll("img");
|
||||
const circles = [...imgs].find((el) => {
|
||||
@@ -90,23 +142,26 @@ describe("RuleStack Component", () => {
|
||||
expect(consensus).toBeTruthy();
|
||||
});
|
||||
|
||||
test("renders see-all-templates link to full templates page", () => {
|
||||
test("renders see-all-templates link to full templates page", async () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const link = screen.getByRole("link", { name: "See all templates" });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "/templates");
|
||||
});
|
||||
|
||||
test("applies correct CSS classes", () => {
|
||||
test("applies correct CSS classes", async () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const section = document.querySelector("section");
|
||||
expect(section).toHaveClass("w-full", "bg-transparent");
|
||||
});
|
||||
|
||||
test("renders with design tokens", () => {
|
||||
test("renders with design tokens", async () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const section = document.querySelector("section");
|
||||
expect(section).toHaveClass("px-[20px]", "py-[32px]");
|
||||
@@ -114,15 +169,17 @@ describe("RuleStack Component", () => {
|
||||
expect(section?.className).toMatch(/min-\[640px\]:py-\[48px\]/);
|
||||
});
|
||||
|
||||
test("applies responsive grid layout", () => {
|
||||
test("applies responsive grid layout", async () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const grid = document.querySelector('[class*="flex flex-col gap-[18px]"]');
|
||||
expect(grid).toHaveClass("min-[768px]:grid", "min-[768px]:grid-cols-2");
|
||||
});
|
||||
|
||||
test("renders RuleCard components with catalog surface colors", () => {
|
||||
test("renders RuleCard components with catalog surface colors", async () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const circlesCard = screen
|
||||
.getByText("Circles")
|
||||
@@ -142,6 +199,7 @@ describe("RuleStack Component", () => {
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const consensusCard = screen.getByText("Consensus").closest("div");
|
||||
await user.click(consensusCard);
|
||||
@@ -154,8 +212,9 @@ describe("RuleStack Component", () => {
|
||||
debugSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("renders with proper semantic structure", () => {
|
||||
test("renders with proper semantic structure", async () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const section = document.querySelector("section");
|
||||
expect(section).toBeInTheDocument();
|
||||
@@ -164,16 +223,18 @@ describe("RuleStack Component", () => {
|
||||
expect(headings).toHaveLength(1 + homeFeatured.length);
|
||||
});
|
||||
|
||||
test("applies responsive spacing", () => {
|
||||
test("applies responsive spacing", async () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const section = document.querySelector("section");
|
||||
expect(section?.className).toMatch(/min-\[640px\]:py-\[48px\]/);
|
||||
expect(section?.className).toMatch(/min-\[1024px\]:py-\[64px\]/);
|
||||
});
|
||||
|
||||
test("renders icons with correct attributes", () => {
|
||||
test("renders icons with correct attributes", async () => {
|
||||
const { container } = render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const imgs = container.querySelectorAll("img");
|
||||
const circlesIcon = [...imgs].find((el) => {
|
||||
@@ -197,8 +258,9 @@ describe("RuleStack Component", () => {
|
||||
expect(circlesIcon?.className).toMatch(/min-\[1440px\]:h-\[90px\]/);
|
||||
});
|
||||
|
||||
test("applies different background colors to featured cards", () => {
|
||||
test("applies different background colors to featured cards", async () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const buttons = document.querySelectorAll('[role="button"]');
|
||||
const templateSurfaces = [...buttons].filter((el) =>
|
||||
@@ -207,16 +269,18 @@ describe("RuleStack Component", () => {
|
||||
expect(templateSurfaces.length).toBe(homeFeatured.length);
|
||||
});
|
||||
|
||||
test("renders with proper see-all link styling", () => {
|
||||
test("renders with proper see-all link styling", async () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const link = screen.getByRole("link", { name: "See all templates" });
|
||||
expect(link?.className).toMatch(/bg-transparent/);
|
||||
expect(link?.className).toMatch(/border/);
|
||||
});
|
||||
|
||||
test("applies flex layout for see-all link container", () => {
|
||||
test("applies flex layout for see-all link container", async () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const linkContainer = screen
|
||||
.getByRole("link", { name: "See all templates" })
|
||||
@@ -224,6 +288,18 @@ describe("RuleStack Component", () => {
|
||||
expect(linkContainer).toHaveClass("flex", "justify-center");
|
||||
});
|
||||
|
||||
test("falls back to static catalog when templates API errors", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("Server error", { status: 500 })),
|
||||
);
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
for (const entry of homeFeatured) {
|
||||
expect(screen.getByText(entry.title)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("handles analytics tracking", async () => {
|
||||
const user = userEvent.setup();
|
||||
const gtagSpy = vi.fn();
|
||||
@@ -239,6 +315,7 @@ describe("RuleStack Component", () => {
|
||||
});
|
||||
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const electedBoardCard = screen.getByText("Elected Board").closest("div");
|
||||
await user.click(electedBoardCard);
|
||||
|
||||
Reference in New Issue
Block a user