diff --git a/app/(marketing)/MarketingRuleStackSection.tsx b/app/(marketing)/MarketingRuleStackSection.tsx new file mode 100644 index 0000000..5672fd0 --- /dev/null +++ b/app/(marketing)/MarketingRuleStackSection.tsx @@ -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: () => ( +
+ ), + 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 ; +} diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index 7daabfd..ce576cf 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -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: () => ( -
- ), - ssr: true, -}); - const FeatureGrid = dynamic( () => import("../components/sections/FeatureGrid"), { @@ -98,7 +93,13 @@ export default function Page() { - + + } + > + + diff --git a/app/(marketing)/templates/TemplatesPageClient.tsx b/app/(marketing)/templates/TemplatesPageClient.tsx new file mode 100644 index 0000000..09e6353 --- /dev/null +++ b/app/(marketing)/templates/TemplatesPageClient.tsx @@ -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 ( +
+
+
+ +
+
+ { + router.push( + `/create/review-template/${encodeURIComponent(slug)}`, + ); + }} + /> +
+
+
+ ); +} diff --git a/app/(marketing)/templates/page.tsx b/app/(marketing)/templates/page.tsx index f70e242..29c005f 100644 --- a/app/(marketing)/templates/page.tsx +++ b/app/(marketing)/templates/page.tsx @@ -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 ( -
-
-
- -
-
- { - router.push( - `/create/review-template/${encodeURIComponent(slug)}`, - ); - }} - /> -
-
-
- ); +export default async function TemplatesPage() { + const rows = await listRuleTemplatesFromDb(); + const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows); + return ; } diff --git a/app/api/templates/route.ts b/app/api/templates/route.ts index 28ddada..b28b7ec 100644 --- a/app/api/templates/route.ts +++ b/app/api/templates/route.ts @@ -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 }); } diff --git a/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx b/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx index 81977b8..95a2896 100644 --- a/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx +++ b/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx @@ -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); diff --git a/app/components/sections/GovernanceTemplateGrid/GovernanceTemplateGridSkeleton.tsx b/app/components/sections/GovernanceTemplateGrid/GovernanceTemplateGridSkeleton.tsx new file mode 100644 index 0000000..2f47595 --- /dev/null +++ b/app/components/sections/GovernanceTemplateGrid/GovernanceTemplateGridSkeleton.tsx @@ -0,0 +1,33 @@ +/** + * Placeholder grid matching GovernanceTemplateGrid layout (loading state). + */ +export function GovernanceTemplateGridSkeleton({ count }: { count: number }) { + return ( +
+ {Array.from({ length: count }, (_, i) => ( +
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/app/components/sections/RuleStack/RuleStack.container.tsx b/app/components/sections/RuleStack/RuleStack.container.tsx index 88fdae9..0cb28bd 100644 --- a/app/components/sections/RuleStack/RuleStack.container.tsx +++ b/app/components/sections/RuleStack/RuleStack.container.tsx @@ -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(({ className = "" }) => { +const RuleStackContainer = memo( + ({ className = "", initialGridEntries }) => { const router = useRouter(); + const [gridEntries, setGridEntries] = useState( + () => 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(({ className = "" }) => { ); -}); + }, +); RuleStackContainer.displayName = "RuleStack"; diff --git a/app/components/sections/RuleStack/RuleStack.types.ts b/app/components/sections/RuleStack/RuleStack.types.ts index 62e0969..6e0248d 100644 --- a/app/components/sections/RuleStack/RuleStack.types.ts +++ b/app/components/sections/RuleStack/RuleStack.types.ts @@ -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; } diff --git a/app/components/sections/RuleStack/RuleStack.view.tsx b/app/components/sections/RuleStack/RuleStack.view.tsx index 536d1e9..ce041f9 100644 --- a/app/components/sections/RuleStack/RuleStack.view.tsx +++ b/app/components/sections/RuleStack/RuleStack.view.tsx @@ -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" /> - + {gridEntries === null ? ( + + ) : ( + + )}
{ + 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]); diff --git a/lib/create/fetchTemplates.ts b/lib/create/fetchTemplates.ts index 5252d09..3d2787e 100644 --- a/lib/create/fetchTemplates.ts +++ b/lib/create/fetchTemplates.ts @@ -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 { 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 { - const result = await fetchTemplates(); + const result = await fetchTemplates(options); if ("error" in result) { return result; } diff --git a/lib/server/ruleTemplates.ts b/lib/server/ruleTemplates.ts new file mode 100644 index 0000000..ba2d8f5 --- /dev/null +++ b/lib/server/ruleTemplates.ts @@ -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 { + 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 []; + } +} diff --git a/lib/templates/templateGridPresentation.ts b/lib/templates/templateGridPresentation.ts new file mode 100644 index 0000000..aa85095 --- /dev/null +++ b/lib/templates/templateGridPresentation.ts @@ -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); +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 4e30b8b..1986c42 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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"), }, ]; diff --git a/stories/sections/RuleStack.stories.js b/stories/sections/RuleStack.stories.js index 1da655d..e554a37 100644 --- a/stories/sections/RuleStack.stories.js +++ b/stories/sections/RuleStack.stories.js @@ -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 ; +} export default { title: "Components/Sections/RuleStack", component: RuleStack, + decorators: [withMockTemplatesApi], parameters: { layout: "fullscreen", docs: { diff --git a/tests/unit/RuleStack.test.jsx b/tests/unit/RuleStack.test.jsx index b6456b5..70fa471 100644 --- a/tests/unit/RuleStack.test.jsx +++ b/tests/unit/RuleStack.test.jsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await waitForRuleStackCards(); const circlesCard = screen .getByText("Circles") @@ -142,6 +199,7 @@ describe("RuleStack Component", () => { .mockImplementation(() => undefined); render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await waitForRuleStackCards(); const electedBoardCard = screen.getByText("Elected Board").closest("div"); await user.click(electedBoardCard);