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 ? (