From ec5afd146450aa9feede5c505a8a2dfe0edc1ca7 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:17:52 -0600 Subject: [PATCH] RuleTemplate seed and create flow --- CONTRIBUTING.md | 3 +- app/(dev)/components-preview/page.tsx | 4 +- app/(marketing)/templates/page.tsx | 47 ++ .../cards/RuleCard/RuleCard.view.tsx | 22 +- .../TemplateReviewCard/TemplateReviewCard.tsx | 66 +++ .../cards/TemplateReviewCard/index.ts | 2 + .../GovernanceTemplateGrid.tsx | 99 ++++ .../sections/GovernanceTemplateGrid/index.ts | 2 + .../RuleStack/RuleStack.container.tsx | 12 +- .../sections/RuleStack/RuleStack.types.ts | 2 +- .../sections/RuleStack/RuleStack.view.tsx | 136 +---- app/create/CreateFlowLayoutClient.tsx | 116 +++- app/create/review-template/[slug]/page.tsx | 130 +++++ lib/create/fetchTemplates.ts | 45 ++ lib/create/templateReviewMapping.ts | 68 +++ lib/templates/governanceTemplateCatalog.ts | 161 ++++++ messages/en/create/templateReview.json | 17 + messages/en/index.ts | 4 + messages/en/pages/templates.json | 5 + package-lock.json | 21 + package.json | 4 + prisma/seed.ts | 539 ++++++++++++++++++ public/assets/Icon_Consensus.svg | 10 - public/assets/Icon_ElectedBoard.svg | 9 - public/assets/Icon_Petition.svg | 13 - .../template-mark/benevolent-dictator.svg | 3 + .../consensus-clusters.svg} | 0 public/assets/template-mark/consensus.svg | 10 + public/assets/template-mark/devolution.svg | 11 + public/assets/template-mark/do-ocracy.svg | 3 + public/assets/template-mark/elected-board.svg | 9 + .../template-mark/federated-clusters.svg | 3 + .../assets/template-mark/liquid-democracy.svg | 8 + public/assets/template-mark/petition.svg | 13 + .../template-mark/quadratic-governance.svg | 11 + .../template-mark/self-appointed-board.svg | 6 + .../template-mark/solidarity-network.svg | 15 + .../assets/template-mark/sortition-jury.svg | 3 + stories/cards/RuleCard.stories.js | 18 +- tests/e2e/critical-journeys.spec.ts | 2 +- tests/mocks/navigation.ts | 20 + tests/pages/page-flow.test.jsx | 15 +- tests/pages/templates.test.jsx | 53 ++ tests/pages/user-journey.test.jsx | 4 +- tests/unit/RuleStack.test.jsx | 177 +++--- tests/unit/templateReviewMapping.test.ts | 49 ++ vitest.setup.ts | 1 + 47 files changed, 1706 insertions(+), 265 deletions(-) create mode 100644 app/(marketing)/templates/page.tsx create mode 100644 app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx create mode 100644 app/components/cards/TemplateReviewCard/index.ts create mode 100644 app/components/sections/GovernanceTemplateGrid/GovernanceTemplateGrid.tsx create mode 100644 app/components/sections/GovernanceTemplateGrid/index.ts create mode 100644 app/create/review-template/[slug]/page.tsx create mode 100644 lib/create/fetchTemplates.ts create mode 100644 lib/create/templateReviewMapping.ts create mode 100644 lib/templates/governanceTemplateCatalog.ts create mode 100644 messages/en/create/templateReview.json create mode 100644 messages/en/pages/templates.json create mode 100644 prisma/seed.ts delete mode 100644 public/assets/Icon_Consensus.svg delete mode 100644 public/assets/Icon_ElectedBoard.svg delete mode 100644 public/assets/Icon_Petition.svg create mode 100644 public/assets/template-mark/benevolent-dictator.svg rename public/assets/{Icon_Sociocracy.svg => template-mark/consensus-clusters.svg} (100%) create mode 100644 public/assets/template-mark/consensus.svg create mode 100644 public/assets/template-mark/devolution.svg create mode 100644 public/assets/template-mark/do-ocracy.svg create mode 100644 public/assets/template-mark/elected-board.svg create mode 100644 public/assets/template-mark/federated-clusters.svg create mode 100644 public/assets/template-mark/liquid-democracy.svg create mode 100644 public/assets/template-mark/petition.svg create mode 100644 public/assets/template-mark/quadratic-governance.svg create mode 100644 public/assets/template-mark/self-appointed-board.svg create mode 100644 public/assets/template-mark/solidarity-network.svg create mode 100644 public/assets/template-mark/sortition-jury.svg create mode 100644 tests/mocks/navigation.ts create mode 100644 tests/pages/templates.test.jsx create mode 100644 tests/unit/templateReviewMapping.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 909ba75..d45b7a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,8 @@ 2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only need Postgres; with `SMTP_URL` unset, the **magic-link verify URL** is printed in the dev server log (see `.env.example`). 3. Install dependencies: `npm ci` 4. Apply migrations: `npx prisma migrate dev` -5. Run the app: `npm run dev` +5. (Optional) Seed curated rule templates: `npx prisma db seed` — requires `DATABASE_URL` and applied migrations. Safe to re-run; rows are upserted by `slug` so duplicates are not created. +6. Run the app: `npm run dev` Use `npx prisma studio` to inspect the database. diff --git a/app/(dev)/components-preview/page.tsx b/app/(dev)/components-preview/page.tsx index f448360..5bf532a 100644 --- a/app/(dev)/components-preview/page.tsx +++ b/app/(dev)/components-preview/page.tsx @@ -904,7 +904,7 @@ export default function ComponentsPreview() { className="w-[525px]" icon={ Sociocracy +
+
+ +
+
+ { + router.push( + `/create/review-template/${encodeURIComponent(slug)}`, + ); + }} + /> +
+
+ + ); +} diff --git a/app/components/cards/RuleCard/RuleCard.view.tsx b/app/components/cards/RuleCard/RuleCard.view.tsx index dd7af0c..a00b5db 100644 --- a/app/components/cards/RuleCard/RuleCard.view.tsx +++ b/app/components/cards/RuleCard/RuleCard.view.tsx @@ -184,7 +184,7 @@ export function RuleCardView({ {/* Outermost container with bottom border - taller to match Figma */}
{/* Inner container for header text with padding */} @@ -232,7 +232,7 @@ export function RuleCardView({ `} >

{title}

@@ -279,8 +279,12 @@ export function RuleCardView({ )} {/* Footer: Description */} {description && ( -
-

{description}

+
+

+ {description} +

)} @@ -288,7 +292,9 @@ export function RuleCardView({ /* Collapsed State: Description */ description && (
-

+

{description}

diff --git a/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx b/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx new file mode 100644 index 0000000..de7ef98 --- /dev/null +++ b/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx @@ -0,0 +1,66 @@ +"use client"; + +import Image from "next/image"; +import RuleCard from "../RuleCard"; +import { getAssetPath } from "../../../../lib/assetUtils"; +import type { RuleTemplateDto } from "../../../../lib/create/fetchTemplates"; +import { + templateBodyToCategories, + templateSummaryFromBody, +} 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)]", +}; + +export interface TemplateReviewCardProps { + template: RuleTemplateDto; + /** Merged onto RuleCard `className` (e.g. final-review desktop vs mobile radius/padding). */ + ruleCardClassName?: string; +} + +/** + * Expanded RuleCard for template review: surfaces + icon from Figma catalog (21764-16435); + * tag rows from API `body`. + */ +export function TemplateReviewCard({ + template, + ruleCardClassName = "", +}: TemplateReviewCardProps) { + const catalog = getGovernanceTemplateCatalogEntry(template.slug); + const pres = catalog ?? FALLBACK_PRESENTATION; + const categories = templateBodyToCategories(template.body); + const summary = templateSummaryFromBody(template.description, template.body); + + return ( + {}} + icon={ + {template.title} + } + /> + ); +} diff --git a/app/components/cards/TemplateReviewCard/index.ts b/app/components/cards/TemplateReviewCard/index.ts new file mode 100644 index 0000000..8470005 --- /dev/null +++ b/app/components/cards/TemplateReviewCard/index.ts @@ -0,0 +1,2 @@ +export { TemplateReviewCard } from "./TemplateReviewCard"; +export type { TemplateReviewCardProps } from "./TemplateReviewCard"; diff --git a/app/components/sections/GovernanceTemplateGrid/GovernanceTemplateGrid.tsx b/app/components/sections/GovernanceTemplateGrid/GovernanceTemplateGrid.tsx new file mode 100644 index 0000000..beadfb9 --- /dev/null +++ b/app/components/sections/GovernanceTemplateGrid/GovernanceTemplateGrid.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Image from "next/image"; +import { useMediaQuery } from "../../../hooks/useMediaQuery"; +import RuleCard from "../../cards/RuleCard"; +import { getAssetPath } from "../../../../lib/assetUtils"; +import type { GovernanceTemplateCatalogEntry } from "../../../../lib/templates/governanceTemplateCatalog"; + +export interface GovernanceTemplateGridProps { + entries: GovernanceTemplateCatalogEntry[]; + onTemplateClick: (_slug: string) => void; +} + +export function GovernanceTemplateGrid({ + entries, + onTemplateClick, +}: GovernanceTemplateGridProps) { + const [isMounted, setIsMounted] = useState(false); + + const isMax639 = useMediaQuery("(max-width: 639px)"); + const isMin640Max1023 = useMediaQuery( + "(min-width: 640px) and (max-width: 1023px)", + ); + const isMin1024Max1439 = useMediaQuery( + "(min-width: 1024px) and (max-width: 1439px)", + ); + const isMin1440 = useMediaQuery("(min-width: 1440px)"); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- breakpoint sizing after mount (matches SSR default "M") + setIsMounted(true); + }, []); + + const cardSize = isMounted + ? isMax639 + ? "XS" + : isMin640Max1023 + ? "S" + : isMin1024Max1439 + ? "M" + : isMin1440 + ? "L" + : "M" + : "M"; + + return ( +
+ {entries.map((card) => ( + + } + backgroundColor={card.backgroundColor} + onClick={() => { + onTemplateClick(card.slug); + }} + /> + ))} +
+ ); +} diff --git a/app/components/sections/GovernanceTemplateGrid/index.ts b/app/components/sections/GovernanceTemplateGrid/index.ts new file mode 100644 index 0000000..7fddbec --- /dev/null +++ b/app/components/sections/GovernanceTemplateGrid/index.ts @@ -0,0 +1,2 @@ +export { GovernanceTemplateGrid } from "./GovernanceTemplateGrid"; +export type { GovernanceTemplateGridProps } from "./GovernanceTemplateGrid"; diff --git a/app/components/sections/RuleStack/RuleStack.container.tsx b/app/components/sections/RuleStack/RuleStack.container.tsx index 6c4fb25..88fdae9 100644 --- a/app/components/sections/RuleStack/RuleStack.container.tsx +++ b/app/components/sections/RuleStack/RuleStack.container.tsx @@ -1,6 +1,7 @@ "use client"; import { memo } from "react"; +import { useRouter } from "next/navigation"; import { logger } from "../../../../lib/logger"; import { RuleStackView } from "./RuleStack.view"; import type { RuleStackProps } from "./RuleStack.types"; @@ -19,21 +20,24 @@ declare global { } const RuleStackContainer = memo(({ className = "" }) => { - const handleTemplateClick = (templateName: string) => { + const router = useRouter(); + + const handleTemplateClick = (slug: string) => { // Basic analytics tracking if (typeof window !== "undefined") { if (window.gtag) { window.gtag("event", "template_click", { - template_name: templateName, + template_slug: slug, }); } if (window.analytics) { window.analytics.track("Template Clicked", { - templateName: templateName, + templateSlug: slug, }); } } - logger.debug(`${templateName} template clicked`); + logger.debug(`${slug} template clicked`); + router.push(`/create/review-template/${encodeURIComponent(slug)}`); }; return ( diff --git a/app/components/sections/RuleStack/RuleStack.types.ts b/app/components/sections/RuleStack/RuleStack.types.ts index fd635c1..62e0969 100644 --- a/app/components/sections/RuleStack/RuleStack.types.ts +++ b/app/components/sections/RuleStack/RuleStack.types.ts @@ -4,5 +4,5 @@ export interface RuleStackProps { export interface RuleStackViewProps { className: string; - onTemplateClick: (_templateName: string) => void; + onTemplateClick: (_slug: string) => void; } diff --git a/app/components/sections/RuleStack/RuleStack.view.tsx b/app/components/sections/RuleStack/RuleStack.view.tsx index 54e3b9c..536d1e9 100644 --- a/app/components/sections/RuleStack/RuleStack.view.tsx +++ b/app/components/sections/RuleStack/RuleStack.view.tsx @@ -1,91 +1,21 @@ "use client"; -import { useState, useEffect } from "react"; -import Image from "next/image"; import { useTranslation } from "../../../contexts/MessagesContext"; -import { useMediaQuery } from "../../../hooks/useMediaQuery"; -import RuleCard from "../../cards/RuleCard"; import SectionHeader from "../SectionHeader"; import Button from "../../buttons/Button"; -import { getAssetPath } from "../../../../lib/assetUtils"; +import { GovernanceTemplateGrid } from "../GovernanceTemplateGrid"; +import { getGovernanceTemplatesForHome } from "../../../../lib/templates/governanceTemplateCatalog"; import type { RuleStackViewProps } from "./RuleStack.types"; +const homeFeaturedTemplates = getGovernanceTemplatesForHome(); + export function RuleStackView({ className, onTemplateClick, }: RuleStackViewProps) { const t = useTranslation("pages.home.ruleStack"); - const [isMounted, setIsMounted] = useState(false); - - // Debug: Log button text to ensure translation works const buttonText = t("button.seeAllTemplates"); - // Determine current breakpoint for RuleCard size - // 320-639: XS, 640-767: S, 768-1023: S, 1024-1439: M, 1440+: L - const isMax639 = useMediaQuery("(max-width: 639px)"); - const isMin640Max1023 = useMediaQuery( - "(min-width: 640px) and (max-width: 1023px)", - ); - const isMin1024Max1439 = useMediaQuery( - "(min-width: 1024px) and (max-width: 1439px)", - ); - const isMin1440 = useMediaQuery("(min-width: 1440px)"); - - // Handle hydration: only use media queries after mount - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer breakpoint until after mount to avoid hydration mismatch - setIsMounted(true); - }, []); - - // Use CSS classes for responsive sizing to avoid hydration mismatch - // Default to M size for SSR, then let CSS handle the responsive sizing - const cardSize = isMounted - ? isMax639 - ? "XS" - : isMin640Max1023 - ? "S" - : isMin1024Max1439 - ? "M" - : isMin1440 - ? "L" - : "M" - : "M"; - - // Icon sizes: XS=40px, S=56px, M=56px, L=90px - // Use a large default (90px) and let CSS handle responsive sizing - - // Card data - const cards = [ - { - title: t("cards.consensusClusters.title"), - description: t("cards.consensusClusters.description"), - iconAlt: t("cards.consensusClusters.iconAlt"), - iconPath: "assets/Icon_Sociocracy.svg", - backgroundColor: "bg-[var(--color-surface-default-brand-lime)]", - }, - { - title: t("cards.consensus.title"), - description: t("cards.consensus.description"), - iconAlt: t("cards.consensus.iconAlt"), - iconPath: "assets/Icon_Consensus.svg", - backgroundColor: "bg-[var(--color-surface-default-brand-rust)]", - }, - { - title: t("cards.electedBoard.title"), - description: t("cards.electedBoard.description"), - iconAlt: t("cards.electedBoard.iconAlt"), - iconPath: "assets/Icon_ElectedBoard.svg", - backgroundColor: "bg-[var(--color-surface-default-brand-red)]", - }, - { - title: t("cards.petition.title"), - description: t("cards.petition.description"), - iconAlt: t("cards.petition.iconAlt"), - iconPath: "assets/Icon_Petition.svg", - backgroundColor: "bg-[var(--color-surface-default-brand-teal)]", - }, - ]; - return (
- {/* Section Header */} - {/* Cards Container */} -
- {cards.map((card, index) => ( - - } - backgroundColor={card.backgroundColor} - onClick={() => onTemplateClick(card.title)} - /> - ))} -
+ - {/* See all templates button */}
-
diff --git a/app/create/CreateFlowLayoutClient.tsx b/app/create/CreateFlowLayoutClient.tsx index 290c433..2145a73 100644 --- a/app/create/CreateFlowLayoutClient.tsx +++ b/app/create/CreateFlowLayoutClient.tsx @@ -18,6 +18,10 @@ import Button from "../components/buttons/Button"; import { buildPublishPayload } from "../../lib/create/buildPublishPayload"; import { fetchAuthSession, publishRule } from "../../lib/create/api"; import { writeLastPublishedRule } from "../../lib/create/lastPublishedRule"; +import { + fetchTemplateBySlug, + type RuleTemplateDto, +} from "../../lib/create/fetchTemplates"; import messages from "../../messages/en/index"; import { useAuthModal } from "../contexts/AuthModalContext"; import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; @@ -89,6 +93,18 @@ function CreateFlowLayoutContent({ string | null >(null); const [isPublishing, setIsPublishing] = useState(false); + const [templateReviewApplyError, setTemplateReviewApplyError] = useState< + string | null + >(null); + const [isApplyingTemplate, setIsApplyingTemplate] = useState(false); + + const templateReviewMatch = pathname?.match( + /^\/create\/review-template\/([^/]+)$/, + ); + const templateReviewSlug = templateReviewMatch?.[1] + ? decodeURIComponent(templateReviewMatch[1]) + : null; + const isTemplateReviewRoute = Boolean(templateReviewSlug); const handleFinalize = useCallback(async () => { setPublishBannerMessage(null); @@ -134,6 +150,39 @@ function CreateFlowLayoutContent({ ); }, [state, router, openLogin]); + const handleUseTemplateWithoutChanges = useCallback(async () => { + if (!templateReviewSlug) return; + setTemplateReviewApplyError(null); + setIsApplyingTemplate(true); + const result = await fetchTemplateBySlug(templateReviewSlug); + setIsApplyingTemplate(false); + if (result === null) { + setTemplateReviewApplyError(messages.create.templateReview.errors.notFound); + return; + } + if ("error" in result) { + setTemplateReviewApplyError(result.error); + return; + } + const template: RuleTemplateDto = result; + const doc = template.body; + if (!doc || typeof doc !== "object" || Array.isArray(doc)) { + setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed); + return; + } + const summaryRaw = + typeof template.description === "string" + ? template.description.trim() + : ""; + writeLastPublishedRule({ + id: `template:${template.slug}`, + title: template.title, + summary: summaryRaw.length > 0 ? summaryRaw : null, + document: doc as Record, + }); + router.push("/create/completed"); + }, [router, templateReviewSlug]); + const runAuthenticatedExit = useCreateFlowExit({ state, currentStep, @@ -149,9 +198,15 @@ function CreateFlowLayoutContent({ if (sessionUser === null) { if (saveDraft) return; + const returnToTemplateReview = + templateReviewSlug != null + ? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1` + : null; openLogin({ variant: "saveProgress", - nextPath: `${pathname ?? "/create/informational"}?syncDraft=1`, + nextPath: + returnToTemplateReview ?? + `${pathname ?? "/create/informational"}?syncDraft=1`, backdropVariant: "blurredYellow", }); return; @@ -169,7 +224,9 @@ function CreateFlowLayoutContent({ Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX; const hasErrorOverlays = - Boolean(draftSaveBannerMessage) || Boolean(publishBannerMessage); + Boolean(draftSaveBannerMessage) || + Boolean(publishBannerMessage) || + Boolean(templateReviewApplyError); return (
@@ -202,6 +259,18 @@ function CreateFlowLayoutContent({ />
) : null} + {templateReviewApplyError ? ( +
+ setTemplateReviewApplyError(null)} + className="w-full" + /> +
+ ) : null}
) : null} @@ -243,8 +312,41 @@ function CreateFlowLayoutContent({ {!isCompletedStep && ( + + +
+ ) : nextStep ? (