diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 909ba75..c69c82c 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. @@ -41,6 +42,10 @@ Use `npx prisma studio` to inspect the database. Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so **signed-in** users can persist create-flow drafts to Postgres via **Save & Exit** and so **anonymous** progress can be **uploaded after magic-link sign-in** from the save-progress exit modal. Without it, server **PUT** `/api/drafts/me` is skipped; anonymous work stays in **browser `localStorage`**, but after sign-in with a `?syncDraft=1` return URL the app still **merges that local draft into the in-memory create flow** (no server write) so you can continue and publish. +### Create flow URLs (custom wizard) + +The **custom** create-rule wizard lives under **`/create/…`**. The header links to **`/create`**, which redirects to the first step. **Semantic** URL segments (e.g. `community-name`, `community-size`) match Figma intent; order is **`FLOW_STEP_ORDER`** in `app/create/utils/flowSteps.ts`, with UI from **`app/create/[screenId]/page.tsx`** and **`CREATE_FLOW_SCREEN_REGISTRY`** for Figma traceability. **Figma** stages: **Create Community** (through `review`), **Create Custom CommunityRule** (`cards`–`right-rail`), **Review and complete** (`confirm-stakeholders`–`completed`). **`/create/review-template/[slug]`** is a template **preview** only. Full tables and persistence are in **[docs/create-flow.md](docs/create-flow.md)**; engineering tracking: Linear **CR-89** / Ticket 17 in [docs/backend-linear-tickets.md](docs/backend-linear-tickets.md). + ## Frontend / tests See [docs/TESTING_GUIDE.md](docs/TESTING_GUIDE.md) and the root [README.md](README.md). 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 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 new file mode 100644 index 0000000..29c005f --- /dev/null +++ b/app/(marketing)/templates/page.tsx @@ -0,0 +1,9 @@ +import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates"; +import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation"; +import TemplatesPageClient from "./TemplatesPageClient"; + +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/RuleCard/RuleCard.view.tsx b/app/components/cards/RuleCard/RuleCard.view.tsx index dd7af0c..c58c3a9 100644 --- a/app/components/cards/RuleCard/RuleCard.view.tsx +++ b/app/components/cards/RuleCard/RuleCard.view.tsx @@ -39,13 +39,19 @@ export function RuleCardView({ className?.includes("pb-["); const hasResponsiveGap = className?.includes("gap-["); - const cardPadding = hasResponsivePadding - ? "" // If className has responsive padding, don't add size-based padding - : isLarge || isSmall - ? "p-[24px]" - : isMedium - ? "p-[16px]" - : "pb-[24px] pt-[12px] px-[12px]"; // XS: asymmetric padding + // Expanded + size: uniform padding on all sides (overrides conflicting utilities from `className`). + const cardPadding = + expanded && isLarge + ? "!p-[24px]" + : expanded && isMedium + ? "!p-[16px]" + : hasResponsivePadding + ? "" + : isLarge || isSmall + ? "p-[24px]" + : isMedium + ? "p-[16px]" + : "pb-[24px] pt-[12px] px-[12px]"; // XS: asymmetric padding const cardGap = expanded ? "gap-[16px]" : hasResponsiveGap @@ -184,7 +190,7 @@ export function RuleCardView({ {/* Outermost container with bottom border - taller to match Figma */}
{/* Inner container for header text with padding */} @@ -232,7 +238,7 @@ export function RuleCardView({ `} >

{title}

@@ -245,7 +251,11 @@ export function RuleCardView({ <> {/* Categories Section - Using MultiSelect */} {categories && categories.length > 0 && ( -
+
{categories.map((category, categoryIndex) => ( -

{description}

+
+

+ {description} +

)} @@ -288,7 +302,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..95a2896 --- /dev/null +++ b/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx @@ -0,0 +1,65 @@ +"use client"; + +import Image from "next/image"; +import RuleCard from "../RuleCard"; +import type { RuleCardProps } from "../RuleCard/RuleCard.types"; +import { getAssetPath } from "../../../../lib/assetUtils"; +import type { RuleTemplateDto } from "../../../../lib/create/fetchTemplates"; +import { + templateBodyToCategories, + templateSummaryFromBody, +} from "../../../../lib/create/templateReviewMapping"; +import { + getGovernanceTemplateCatalogEntry, +} from "../../../../lib/templates/governanceTemplateCatalog"; +import { TEMPLATE_GRID_FALLBACK_PRESENTATION } from "../../../../lib/templates/templateGridPresentation"; + +export interface TemplateReviewCardProps { + template: RuleTemplateDto; + /** Merged onto RuleCard `className` (e.g. final-review desktop vs mobile radius/padding). */ + ruleCardClassName?: string; + /** RuleCard size; create-flow passes `L` at/above `md`, `M` below (640px). */ + size?: RuleCardProps["size"]; +} + +/** + * Expanded RuleCard for template review: surfaces + icon from Figma catalog (21764-16435); + * tag rows from API `body`. + */ +export function TemplateReviewCard({ + template, + ruleCardClassName = "", + size = "L", +}: TemplateReviewCardProps) { + const catalog = getGovernanceTemplateCatalogEntry(template.slug); + const pres = catalog ?? TEMPLATE_GRID_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/controls/TextArea/TextArea.types.ts b/app/components/controls/TextArea/TextArea.types.ts index 3974612..0997525 100644 --- a/app/components/controls/TextArea/TextArea.types.ts +++ b/app/components/controls/TextArea/TextArea.types.ts @@ -49,10 +49,10 @@ export interface TextAreaProps extends Omit< className?: string; rows?: number; /** - * Whether to show hint text below textarea (Figma prop). + * Hint below the textarea: `true` shows placeholder copy, or pass a string (e.g. character count). * @default false */ - textHint?: boolean; + textHint?: boolean | string; /** * Whether to show form header (label and help icon) above textarea (Figma prop). * @default true @@ -92,7 +92,7 @@ export interface TextAreaViewProps { handleChange: (_e: React.ChangeEvent) => void; handleFocus: (_e: React.FocusEvent) => void; handleBlur: (_e: React.FocusEvent) => void; - textHint?: boolean; + textHint?: boolean | string; formHeader?: boolean; showHelpIcon?: boolean; appearance?: "default" | "embedded"; diff --git a/app/components/controls/TextArea/TextArea.view.tsx b/app/components/controls/TextArea/TextArea.view.tsx index cf8783c..8b6e788 100644 --- a/app/components/controls/TextArea/TextArea.view.tsx +++ b/app/components/controls/TextArea/TextArea.view.tsx @@ -78,13 +78,13 @@ export const TextAreaView = forwardRef( {...props} />
- {textHint && ( + {textHint ? (

- Hint text here + {typeof textHint === "string" ? textHint : "Hint text here"}

- )} + ) : null}
); }, diff --git a/app/components/controls/Upload/Upload.container.tsx b/app/components/controls/Upload/Upload.container.tsx index 8f220e2..9b73ef2 100644 --- a/app/components/controls/Upload/Upload.container.tsx +++ b/app/components/controls/Upload/Upload.container.tsx @@ -5,12 +5,20 @@ import UploadView from "./Upload.view"; import type { UploadProps } from "./Upload.types"; const UploadContainer = memo( - ({ active = true, label, showHelpIcon = true, onClick, className = "" }) => { + ({ + active = true, + label, + showHelpIcon = true, + hintText = "Add image from your device", + onClick, + className = "", + }) => { return ( diff --git a/app/components/controls/Upload/Upload.types.ts b/app/components/controls/Upload/Upload.types.ts index d9ce7ad..9940390 100644 --- a/app/components/controls/Upload/Upload.types.ts +++ b/app/components/controls/Upload/Upload.types.ts @@ -15,6 +15,11 @@ export interface UploadProps { * @default true */ showHelpIcon?: boolean; + /** + * Copy beside the upload button (Figma Flow — Upload `20094:41524`). + * @default "Add image from your device" + */ + hintText?: string; /** * Callback when upload button is clicked */ @@ -29,6 +34,7 @@ export interface UploadViewProps { active: boolean; label?: string; showHelpIcon: boolean; + hintText: string; onClick?: () => void; className: string; } diff --git a/app/components/controls/Upload/Upload.view.tsx b/app/components/controls/Upload/Upload.view.tsx index 065f560..96748bc 100644 --- a/app/components/controls/Upload/Upload.view.tsx +++ b/app/components/controls/Upload/Upload.view.tsx @@ -8,6 +8,7 @@ function UploadView({ active = true, label, showHelpIcon = true, + hintText, onClick, className = "", }: UploadViewProps) { @@ -54,7 +55,7 @@ function UploadView({
diff --git a/app/components/modals/Login/LoginForm.tsx b/app/components/modals/Login/LoginForm.tsx index 250c437..b79e3b8 100644 --- a/app/components/modals/Login/LoginForm.tsx +++ b/app/components/modals/Login/LoginForm.tsx @@ -9,7 +9,7 @@ import TextInput from "../../controls/TextInput"; import ContentLockup from "../../type/ContentLockup"; import { requestMagicLink } from "../../../../lib/create/api"; import { safeInternalPath } from "../../../../lib/safeInternalPath"; -import { setTransferPendingFlag } from "../../../create/anonymousDraftStorage"; +import { setTransferPendingFlag } from "../../../create/utils/anonymousDraftStorage"; /** Mail icon for login modal (inline SVG; same pattern as InfoMessageBox ExclamationIconInline). */ function MailIconInline() { diff --git a/app/components/navigation/TopNav/TopNav.container.tsx b/app/components/navigation/TopNav/TopNav.container.tsx index b2f7379..ff8cc50 100644 --- a/app/components/navigation/TopNav/TopNav.container.tsx +++ b/app/components/navigation/TopNav/TopNav.container.tsx @@ -197,7 +197,7 @@ const TopNavContainer = memo( size={buttonSize} buttonType={buttonType} palette={palette} - onClick={() => router.push("/create/informational")} + onClick={() => router.push("/create")} ariaLabel={t("ariaLabels.createNewRule")} > {renderAvatarGroup(containerSize, avatarSize)} diff --git a/app/components/progress/ProportionBar/ProportionBar.container.tsx b/app/components/progress/ProportionBar/ProportionBar.container.tsx index 517aa02..62cd255 100644 --- a/app/components/progress/ProportionBar/ProportionBar.container.tsx +++ b/app/components/progress/ProportionBar/ProportionBar.container.tsx @@ -1,11 +1,13 @@ "use client"; import { memo } from "react"; +import { normalizeProportionBarVariant } from "../../../../lib/propNormalization"; import { ProportionBarView } from "./ProportionBar.view"; import type { ProportionBarProps } from "./ProportionBar.types"; const ProportionBarContainer = memo( - ({ progress = "3-2", className = "" }) => { + ({ progress = "3-2", className = "", variant: variantProp }) => { + const variant = normalizeProportionBarVariant(variantProp); const barClasses = `h-[8px] relative w-full`; return ( @@ -13,6 +15,7 @@ const ProportionBarContainer = memo( progress={progress} className={className} barClasses={barClasses} + variant={variant} /> ); }, diff --git a/app/components/progress/ProportionBar/ProportionBar.types.ts b/app/components/progress/ProportionBar/ProportionBar.types.ts index 3a6510e..2223bf8 100644 --- a/app/components/progress/ProportionBar/ProportionBar.types.ts +++ b/app/components/progress/ProportionBar/ProportionBar.types.ts @@ -1,3 +1,5 @@ +import type { ProportionBarVariantValue } from "../../../../lib/propNormalization"; + export type ProportionBarState = | "1-0" | "1-1" @@ -12,13 +14,20 @@ export type ProportionBarState = | "3-1" | "3-2"; +export type ProportionBarVariant = ProportionBarVariantValue; + export interface ProportionBarProps { progress?: ProportionBarState; className?: string; + /** + * `segmented` (Figma: create-flow footer): pill-shaped partial fills inside each segment. + */ + variant?: ProportionBarVariant; } export interface ProportionBarViewProps { progress: ProportionBarState; className: string; barClasses: string; + variant: "default" | "segmented"; } diff --git a/app/components/progress/ProportionBar/ProportionBar.view.tsx b/app/components/progress/ProportionBar/ProportionBar.view.tsx index ea01386..9ac4315 100644 --- a/app/components/progress/ProportionBar/ProportionBar.view.tsx +++ b/app/components/progress/ProportionBar/ProportionBar.view.tsx @@ -4,9 +4,11 @@ export function ProportionBarView({ progress, className, barClasses, + variant, }: ProportionBarViewProps) { // Proportion bar type const [fullSegments, partialSegment] = progress.split("-").map(Number); + const segmented = variant === "segmented"; // Calculate total progress: // - For 1-X: first section is (X+1)/6 filled // - For 2-X: first section full, second section X/3 filled @@ -58,7 +60,11 @@ export function ProportionBarView({
{fullSegments === 1 ? (
) : fullSegments >= 2 ? ( @@ -70,7 +76,11 @@ export function ProportionBarView({ {fullSegments === 2 ? ( partialSegment > 0 ? (
) : null @@ -84,8 +94,12 @@ export function ProportionBarView({ {fullSegments === 3 && partialSegment > 0 ? (
= 3 ? "rounded-r-[var(--radius-full)]" : "" - }`} + segmented + ? "rounded-l-[var(--radius-full)] rounded-r-[var(--radius-full)]" + : partialSegment >= 3 + ? "rounded-r-[var(--radius-full)]" + : "" + }`.trim()} style={{ width: `${Math.min((partialSegment / 3) * 100, 100)}%` }} /> ) : null} 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/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/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..0cb28bd 100644 --- a/app/components/sections/RuleStack/RuleStack.container.tsx +++ b/app/components/sections/RuleStack/RuleStack.container.tsx @@ -1,7 +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"; @@ -18,31 +26,81 @@ declare global { } } -const RuleStackContainer = memo(({ className = "" }) => { - const handleTemplateClick = (templateName: string) => { +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 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 ( ); -}); + }, +); RuleStackContainer.displayName = "RuleStack"; diff --git a/app/components/sections/RuleStack/RuleStack.types.ts b/app/components/sections/RuleStack/RuleStack.types.ts index fd635c1..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: (_templateName: string) => void; + 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 54e3b9c..ce041f9 100644 --- a/app/components/sections/RuleStack/RuleStack.view.tsx +++ b/app/components/sections/RuleStack/RuleStack.view.tsx @@ -1,91 +1,20 @@ "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 { GovernanceTemplateGridSkeleton } from "../GovernanceTemplateGrid/GovernanceTemplateGridSkeleton"; import type { RuleStackViewProps } from "./RuleStack.types"; export function RuleStackView({ className, onTemplateClick, + gridEntries, }: 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)} - /> - ))} -
+ {gridEntries === null ? ( + + ) : ( + + )} - {/* See all templates button */}
-
diff --git a/app/components/type/HeaderLockup/HeaderLockup.types.ts b/app/components/type/HeaderLockup/HeaderLockup.types.ts index cac76f2..44d07fa 100644 --- a/app/components/type/HeaderLockup/HeaderLockup.types.ts +++ b/app/components/type/HeaderLockup/HeaderLockup.types.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + export type HeaderLockupJustificationValue = | "left" | "center" @@ -16,9 +18,9 @@ export interface HeaderLockupProps { */ title: string; /** - * Description text (optional) + * Description (optional). String for plain copy, or ReactNode for rich inline content (e.g. linked words). */ - description?: string; + description?: ReactNode; /** * Text justification. Accepts both PascalCase (Figma) and lowercase (codebase). * Figma uses PascalCase, codebase uses lowercase - both are supported. @@ -38,7 +40,7 @@ export interface HeaderLockupProps { export interface HeaderLockupViewProps { title: string; - description?: string; + description?: ReactNode; justification: "left" | "center"; size: "L" | "M"; palette: "default" | "inverse"; diff --git a/app/components/type/HeaderLockup/HeaderLockup.view.tsx b/app/components/type/HeaderLockup/HeaderLockup.view.tsx index b68ffae..798634a 100644 --- a/app/components/type/HeaderLockup/HeaderLockup.view.tsx +++ b/app/components/type/HeaderLockup/HeaderLockup.view.tsx @@ -43,17 +43,18 @@ function HeaderLockupView({
{/* Description */} - {description && ( -

- {description} -

- )} + {description != null && + !(typeof description === "string" && description.length === 0) && ( +

+ {description} +

+ )}
); } diff --git a/app/components/utility/CardStack/CardStack.container.tsx b/app/components/utility/CardStack/CardStack.container.tsx index 48bc071..d684867 100644 --- a/app/components/utility/CardStack/CardStack.container.tsx +++ b/app/components/utility/CardStack/CardStack.container.tsx @@ -21,6 +21,7 @@ const CardStackContainer = memo( title = "", description = "", layout = "default", + headerLockupSize, className = "", }) => { const [internalExpanded, setInternalExpanded] = useState(false); @@ -74,6 +75,7 @@ const CardStackContainer = memo( title={title} description={description} layout={layout} + headerLockupSize={headerLockupSize} className={className} /> ); diff --git a/app/components/utility/CardStack/CardStack.types.ts b/app/components/utility/CardStack/CardStack.types.ts index 2c23324..6109af5 100644 --- a/app/components/utility/CardStack/CardStack.types.ts +++ b/app/components/utility/CardStack/CardStack.types.ts @@ -1,3 +1,5 @@ +import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup.types"; + export interface CardStackItem { id: string; label: string; @@ -19,6 +21,8 @@ export interface CardStackProps { description?: string; /** "default" = compact grid/column + expanded grid; "singleStack" = always one column, expand shows more in same stack */ layout?: "default" | "singleStack"; + /** Optional title/description lockup size (create-flow passes `md`-matched `L`/`M`). Defaults to `L`. */ + headerLockupSize?: HeaderLockupSizeValue; className?: string; } @@ -34,5 +38,6 @@ export interface CardStackViewProps { title: string; description: string; layout: "default" | "singleStack"; + headerLockupSize: HeaderLockupSizeValue | undefined; className: string; } diff --git a/app/components/utility/CardStack/CardStack.view.tsx b/app/components/utility/CardStack/CardStack.view.tsx index 34af000..ad74746 100644 --- a/app/components/utility/CardStack/CardStack.view.tsx +++ b/app/components/utility/CardStack/CardStack.view.tsx @@ -16,8 +16,10 @@ export function CardStackView({ title, description, layout, + headerLockupSize, className, }: CardStackViewProps) { + const lockupSize = headerLockupSize ?? "L"; const isSelected = (id: string) => selectedIds.includes(id); // Compact: recommended only (up to 5). Expanded: all cards. const compactCards = cards.filter((c) => c.recommended ?? false).slice(0, 5); @@ -33,7 +35,7 @@ export function CardStackView({ title={title} description={description} justification="center" - size="L" + size={lockupSize} />
) : null} @@ -73,7 +75,7 @@ export function CardStackView({ title={title} description={description} justification="center" - size="L" + size={lockupSize} />
) : null} diff --git a/app/components/utility/CreateFlowFooter/CreateFlowFooter.container.tsx b/app/components/utility/CreateFlowFooter/CreateFlowFooter.container.tsx index 4b964fd..ff67759 100644 --- a/app/components/utility/CreateFlowFooter/CreateFlowFooter.container.tsx +++ b/app/components/utility/CreateFlowFooter/CreateFlowFooter.container.tsx @@ -5,11 +5,20 @@ import { CreateFlowFooterView } from "./CreateFlowFooter.view"; import type { CreateFlowFooterProps } from "./CreateFlowFooter.types"; const CreateFlowFooterContainer = memo( - ({ secondButton, progressBar = true, onBackClick, className = "" }) => { + ({ + secondButton, + progressBar = true, + proportionBarProgress, + proportionBarVariant, + onBackClick, + className = "", + }) => { return ( diff --git a/app/components/utility/CreateFlowFooter/CreateFlowFooter.types.ts b/app/components/utility/CreateFlowFooter/CreateFlowFooter.types.ts index 0ca62bb..ad169d1 100644 --- a/app/components/utility/CreateFlowFooter/CreateFlowFooter.types.ts +++ b/app/components/utility/CreateFlowFooter/CreateFlowFooter.types.ts @@ -1,3 +1,8 @@ +import type { + ProportionBarState, + ProportionBarVariant, +} from "../../progress/ProportionBar/ProportionBar.types"; + /** * Type definitions for CreateFlowFooter component * @@ -13,6 +18,16 @@ export interface CreateFlowFooterProps { * @default true */ progressBar?: boolean; + /** + * `ProportionBar` state when the bar is shown (driven by create-flow step). + * @default "1-0" + */ + proportionBarProgress?: ProportionBarState; + /** + * `ProportionBar` layout variant (Figma create-flow footer uses `segmented`). + * @default "default" + */ + proportionBarVariant?: ProportionBarVariant; /** * Callback function for Back button click */ diff --git a/app/components/utility/CreateFlowFooter/CreateFlowFooter.view.tsx b/app/components/utility/CreateFlowFooter/CreateFlowFooter.view.tsx index 4929653..4ddf793 100644 --- a/app/components/utility/CreateFlowFooter/CreateFlowFooter.view.tsx +++ b/app/components/utility/CreateFlowFooter/CreateFlowFooter.view.tsx @@ -1,3 +1,4 @@ +import { normalizeProportionBarVariant } from "../../../../lib/propNormalization"; import ProportionBar from "../../progress/ProportionBar"; import Button from "../../buttons/Button"; import type { CreateFlowFooterProps } from "./CreateFlowFooter.types"; @@ -5,9 +6,14 @@ import type { CreateFlowFooterProps } from "./CreateFlowFooter.types"; export function CreateFlowFooterView({ secondButton, progressBar = true, + proportionBarProgress = "1-0", + proportionBarVariant: proportionBarVariantProp, onBackClick, className = "", }: CreateFlowFooterProps) { + const proportionBarVariant = normalizeProportionBarVariant( + proportionBarVariantProp, + ); return (
- +
)} diff --git a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx index bf2420d..3148eee 100644 --- a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx +++ b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx @@ -43,10 +43,10 @@ export function CreateFlowTopNavView({ palette={buttonPalette} size="xsmall" onClick={onShare} - ariaLabel="Share" + ariaLabel={t("shareAriaLabel")} className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]" > - Share + {t("share")} )} @@ -56,10 +56,10 @@ export function CreateFlowTopNavView({ palette={buttonPalette} size="xsmall" onClick={onExport} - ariaLabel="Export" + ariaLabel={t("exportAriaLabel")} className="justify-center gap-[var(--spacing-scale-002,2px)] !pl-[var(--spacing-scale-012,12px)] !pr-[var(--spacing-scale-006,6px)] md:!pr-[var(--spacing-scale-006,6px)] !text-[10px] md:!text-[12px] !leading-[12px] md:!leading-[14px] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]" > - Export + {t("export")} - Edit + {t("edit")} )} diff --git a/app/create/CreateFlowLayoutClient.tsx b/app/create/CreateFlowLayoutClient.tsx index 290c433..2ee4581 100644 --- a/app/create/CreateFlowLayoutClient.tsx +++ b/app/create/CreateFlowLayoutClient.tsx @@ -12,14 +12,28 @@ import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext"; import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation"; import { useCreateFlowExit } from "./hooks/useCreateFlowExit"; import CreateFlowTopNav from "../components/utility/CreateFlowTopNav"; -import { getStepIndex } from "./utils/flowSteps"; +import { getNextStep, getStepIndex } from "./utils/flowSteps"; +import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress"; +import { createFlowStepUsesCenteredTextLayout } from "./utils/createFlowScreenRegistry"; import CreateFlowFooter from "../components/utility/CreateFlowFooter"; import Button from "../components/buttons/Button"; import { buildPublishPayload } from "../../lib/create/buildPublishPayload"; -import { fetchAuthSession, publishRule } from "../../lib/create/api"; +import { isValidCreateFlowSaveEmail } from "../../lib/create/isValidCreateFlowSaveEmail"; +import { + fetchAuthSession, + publishRule, + requestMagicLink, +} from "../../lib/create/api"; +import { safeInternalPath } from "../../lib/safeInternalPath"; +import { setTransferPendingFlag } from "./utils/anonymousDraftStorage"; 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 { useMessages, useTranslation } from "../contexts/MessagesContext"; import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; import { SignedInDraftHydration } from "./SignedInDraftHydration"; import Alert from "../components/modals/Alert"; @@ -28,8 +42,8 @@ import { useCreateFlowDraftSaveBanner, } from "./context/CreateFlowDraftSaveBannerContext"; -/** First step where Save & Exit is offered (after informational + name / `text`). */ -const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select"); +/** First step where Save & Exit is offered (first Create Community select per Figma). */ +const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure"); function CreateFlowSessionShell({ children }: { children: ReactNode }) { const [sessionUser, setSessionUser] = useState< @@ -72,6 +86,10 @@ function CreateFlowLayoutContent({ sessionUser: { id: string; email: string } | null | undefined; sessionResolved: boolean; }) { + const { create } = useMessages(); + const footer = create.footer; + const communitySaveMessages = create.communitySave; + const tLogin = useTranslation("pages.login"); const router = useRouter(); const pathname = usePathname(); const { openLogin } = useAuthModal(); @@ -82,13 +100,35 @@ function CreateFlowLayoutContent({ goToNextStep, goToPreviousStep, } = useCreateFlowNavigation(); - const { state, clearState } = useCreateFlow(); + const { state, clearState, updateState } = useCreateFlow(); const { draftSaveBannerMessage, setDraftSaveBannerMessage } = useCreateFlowDraftSaveBanner(); const [publishBannerMessage, setPublishBannerMessage] = useState< string | null >(null); const [isPublishing, setIsPublishing] = useState(false); + const [templateReviewApplyError, setTemplateReviewApplyError] = useState< + string | null + >(null); + const [isApplyingTemplate, setIsApplyingTemplate] = useState(false); + const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] = + useState(false); + const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState< + string | null + >(null); + const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] = + useState(false); + + const templateReviewMatch = pathname?.match( + /\/create\/review-template\/([^/?#]+)/, + ); + const templateReviewSlug = templateReviewMatch?.[1] + ? decodeURIComponent(templateReviewMatch[1]) + : null; + /** Match anywhere in path so locale/basePath variants still get template footer + layout. */ + const isTemplateReviewRoute = Boolean( + pathname?.includes("/create/review-template/"), + ); const handleFinalize = useCallback(async () => { setPublishBannerMessage(null); @@ -134,6 +174,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 +222,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"}?syncDraft=1`, backdropVariant: "blurredYellow", }); return; @@ -161,19 +240,96 @@ function CreateFlowLayoutContent({ await runAuthenticatedExit(opts); }; + useEffect(() => { + if (currentStep !== "community-save") { + setCommunitySaveMagicLinkError(null); + setCommunitySaveMagicLinkSuccess(false); + setCommunitySaveMagicLinkSubmitting(false); + } + }, [currentStep]); + + const handleCommunitySaveMagicLinkSubmit = useCallback(async () => { + setCommunitySaveMagicLinkError(null); + setCommunitySaveMagicLinkSuccess(false); + const raw = state.communitySaveEmail; + const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : ""; + if (!isValidCreateFlowSaveEmail(trimmed)) return; + + setCommunitySaveMagicLinkSubmitting(true); + try { + const stepAfterSave = getNextStep("community-save"); + const segment = stepAfterSave ?? "review"; + const rawNext = `/create/${segment}?syncDraft=1`; + const nextPath = safeInternalPath(rawNext); + const result = await requestMagicLink(trimmed, nextPath); + if (result.ok === false) { + if (result.retryAfterMs != null && result.retryAfterMs > 0) { + const seconds = Math.ceil(result.retryAfterMs / 1000); + setCommunitySaveMagicLinkError( + tLogin("errors.rateLimited").replace("{seconds}", String(seconds)), + ); + } else { + setCommunitySaveMagicLinkError( + result.error || tLogin("errors.generic"), + ); + } + return; + } + setTransferPendingFlag(); + updateState({ communitySaveEmail: trimmed }); + setCommunitySaveMagicLinkSuccess(true); + } catch { + setCommunitySaveMagicLinkError(tLogin("errors.network")); + } finally { + setCommunitySaveMagicLinkSubmitting(false); + } + }, [state.communitySaveEmail, tLogin, updateState]); + const isCompletedStep = currentStep === "completed"; const isRightRailStep = currentStep === "right-rail"; - const useFullHeightMain = isCompletedStep || isRightRailStep; + const isFinalReviewStep = currentStep === "final-review"; + const isCardsStep = currentStep === "cards"; const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1; + + /** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */ + const mainContentClass = isCompletedStep + ? "items-stretch overflow-y-auto md:overflow-hidden" + : isRightRailStep + ? "items-stretch overflow-hidden" + : isFinalReviewStep || isCardsStep || isTemplateReviewRoute + ? "items-start justify-center overflow-y-auto" + : "items-start justify-center overflow-y-auto md:items-center"; + + const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep); + const mainMaxMdJustify = + isTextStep && !isCompletedStep && !isRightRailStep + ? "max-md:justify-center" + : "max-md:justify-start"; + const mainMaxMdCross = + isCompletedStep || isRightRailStep + ? "max-md:flex-col max-md:items-stretch" + : "max-md:flex-col max-md:items-center"; + const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`; const saveDraftOnExit = Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX; - const hasErrorOverlays = - Boolean(draftSaveBannerMessage) || Boolean(publishBannerMessage); + const proportionBarProgress = getProportionBarProgressForCreateFlowStep( + currentStep, + ); + + const footerPrimaryButtonClass = + "md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"; + + const hasTopOverlays = + Boolean(draftSaveBannerMessage) || + Boolean(publishBannerMessage) || + Boolean(templateReviewApplyError) || + Boolean(communitySaveMagicLinkError) || + Boolean(communitySaveMagicLinkSuccess); return (
- {hasErrorOverlays ? ( + {hasTopOverlays ? (
) : null} + {templateReviewApplyError ? ( +
+ setTemplateReviewApplyError(null)} + className="w-full" + /> +
+ ) : null} + {communitySaveMagicLinkError ? ( +
+ setCommunitySaveMagicLinkError(null)} + className="w-full" + /> +
+ ) : null} + {communitySaveMagicLinkSuccess ? ( +
+ setCommunitySaveMagicLinkSuccess(false)} + className="w-full" + /> +
+ ) : null}
) : null} @@ -230,27 +422,144 @@ function CreateFlowLayoutContent({ }`.trim()} />
{children}
{!isCompletedStep && ( + + +
+ ) : currentStep === "community-name" && nextStep ? ( +
+ + +
+ ) : currentStep === "community-save" && nextStep ? ( +
+ + +
+ ) : currentStep === "review" && nextStep ? ( +
+ + +
+ ) : nextStep ? ( ) : null } - onBackClick={previousStep ? goToPreviousStep : undefined} + onBackClick={ + isTemplateReviewRoute + ? () => router.push("/") + : previousStep + ? goToPreviousStep + : undefined + } /> )}
diff --git a/app/create/PostLoginDraftTransfer.tsx b/app/create/PostLoginDraftTransfer.tsx index 154346a..92725e7 100644 --- a/app/create/PostLoginDraftTransfer.tsx +++ b/app/create/PostLoginDraftTransfer.tsx @@ -6,9 +6,9 @@ import { clearAnonymousCreateFlowStorage, hasTransferPendingFlag, readAnonymousCreateFlowState, -} from "./anonymousDraftStorage"; +} from "./utils/anonymousDraftStorage"; import { useCreateFlow } from "./context/CreateFlowContext"; -import { isValidStep } from "./utils/flowSteps"; +import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps"; import { saveDraftToServer } from "../../lib/create/api"; import messages from "../../messages/en/index"; @@ -56,8 +56,8 @@ export function PostLoginDraftTransfer({ return; } - const segment = pathname?.split("/").pop() ?? ""; - const step = isValidStep(segment) ? segment : undefined; + const step = + parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined; const payload = { ...local, ...(step ? { currentStep: step } : {}), @@ -100,8 +100,8 @@ export function PostLoginDraftTransfer({ return; } - const segment = pathname?.split("/").pop() ?? ""; - const step = isValidStep(segment) ? segment : undefined; + const step = + parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined; const payload = { ...local, ...(step ? { currentStep: step } : {}), diff --git a/app/create/SignedInDraftHydration.tsx b/app/create/SignedInDraftHydration.tsx index 229fb30..a68e7d4 100644 --- a/app/create/SignedInDraftHydration.tsx +++ b/app/create/SignedInDraftHydration.tsx @@ -8,7 +8,7 @@ import { clearAnonymousCreateFlowStorage, hasTransferPendingFlag, readAnonymousCreateFlowState, -} from "./anonymousDraftStorage"; +} from "./utils/anonymousDraftStorage"; import { useCreateFlow } from "./context/CreateFlowContext"; import { fetchDraftFromServer } from "../../lib/create/api"; import messages from "../../messages/en/index"; diff --git a/app/create/[screenId]/page.tsx b/app/create/[screenId]/page.tsx new file mode 100644 index 0000000..2992f30 --- /dev/null +++ b/app/create/[screenId]/page.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { notFound, useRouter } from "next/navigation"; +import { use, useEffect } from "react"; +import { CreateFlowScreenView } from "../screens/CreateFlowScreenView"; +import { isValidStep } from "../utils/flowSteps"; +import type { CreateFlowStep } from "../types"; + +interface PageProps { + params: Promise<{ screenId: string }>; +} + +export default function CreateFlowScreenPage({ params }: PageProps) { + const { screenId: raw } = use(params); + const router = useRouter(); + + useEffect(() => { + if (raw === "community-reflection") { + router.replace("/create/community-save"); + } + }, [raw, router]); + + if (raw === "community-reflection") { + return null; + } + + if (!isValidStep(raw)) { + notFound(); + } + + const screenId = raw as CreateFlowStep; + return ; +} diff --git a/app/create/[step]/page.tsx b/app/create/[step]/page.tsx deleted file mode 100644 index 94517c2..0000000 --- a/app/create/[step]/page.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import { notFound } from "next/navigation"; -import { use } from "react"; -import { VALID_STEPS } from "../utils/flowSteps"; - -interface PageProps { - params: Promise<{ step: string }>; -} - -/** - * Dynamic route handler for create flow steps - * - * Handles all flow steps via dynamic routing: /create/[step] - * Validates step exists and renders appropriate template (placeholder for now) - */ -export default function CreateFlowStepPage({ params }: PageProps) { - const { step } = use(params); - - // Validate step exists - if (!(VALID_STEPS as readonly string[]).includes(step)) { - notFound(); - } - - // Placeholder content - templates will be implemented in CR-51-55 - return ( -
-
-

- Create Flow Step: {step} -

-

- Template implementation coming in CR-51 through CR-55 -

-
-
- ); -} diff --git a/app/create/cards/page.tsx b/app/create/cards/page.tsx deleted file mode 100644 index ef87873..0000000 --- a/app/create/cards/page.tsx +++ /dev/null @@ -1,317 +0,0 @@ -"use client"; - -import { useState, useCallback } from "react"; -import HeaderLockup from "../../components/type/HeaderLockup"; -import { useCreateFlow } from "../context/CreateFlowContext"; -import CardStack from "../../components/utility/CardStack"; -import Create from "../../components/modals/Create"; -import TextArea from "../../components/controls/TextArea"; - -const COMPACT_TITLE = "How should this community communicate with each-other?"; -const COMPACT_DESCRIPTION = - "You can select multiple methods for different needs or add your own"; -const EXPANDED_TITLE = - "What method should this community use to communicate with eachother?"; -const EXPANDED_DESCRIPTION = COMPACT_DESCRIPTION; - -/** Create is a shell; which variant shows is determined by which card was clicked; we pass different props and children by pendingCardId. */ - -/** Card ids for "Add platform" Create modal variants. */ -const IN_PERSON_CARD_ID = "in-person-meetings"; -const SIGNAL_CARD_ID = "signal"; -const VIDEO_MEETINGS_CARD_ID = "video-meetings"; - -/** Copy for the default confirm modal (non–add-platform cards). */ -const CONFIRM_MODAL = { - title: "Confirm selection", - description: "Confirm to select this option.", - nextButtonText: "Confirm", - showBackButton: false, - currentStep: undefined, - totalSteps: undefined, -} as const; - -/** - * "Add platform" variants share the same header pattern and "Add Platform" button. - * Each has its own title, description, and body (three TextArea sections). - */ -const ADD_PLATFORM_MODALS: Record< - string, - { title: string; description: string; nextButtonText: string } -> = { - [IN_PERSON_CARD_ID]: { - title: "In-Person Meetings", - description: - "Physical gatherings for high-bandwidth communication and relationship building.", - nextButtonText: "Add Platform", - }, - [SIGNAL_CARD_ID]: { - title: "Signal", - description: - "End-to-end encrypted messaging ideal for small, security-minded groups", - nextButtonText: "Add Platform", - }, - [VIDEO_MEETINGS_CARD_ID]: { - title: "Video Meetings", - description: "Synchronous video calls for remote face-to-face interaction.", - nextButtonText: "Add Platform", - }, -}; - -const SECTION_KEYS = [ - "Core Principle & Scope", - "Logistics, Admin & Norms", - "Code of Conduct", -] as const; -type SectionKey = (typeof SECTION_KEYS)[number]; - -/** Default section text per platform (Figma 20647-18271, 20647-18273, 20736-12668). */ -const ADD_PLATFORM_SECTION_DEFAULTS: Record< - string, - Record -> = { - [IN_PERSON_CARD_ID]: { - "Core Principle & Scope": `We value the highest bandwidth of communication, physical presence, to build trust that digital tools cannot match. Consequently, we reserve this high-trust space for annual retreats, strategic planning, and high-stakes interpersonal repair where body language is essential.`, - "Logistics, Admin & Norms": `Logistics focus on physical accessibility, venue security, and travel equity. Organizers control entry via keys or door staff. Culturally, participants are expected to maintain mission focus and adhere strictly to the itinerary to respect everyone's time. Side conversations or distracting behaviors that derail the agenda are discouraged.`, - "Code of Conduct": `We aspire to operate within these principles. We don't need to see eye to eye on everything, but we believe the world can be improved by collective action. Aspire to do no harm to members of this community. Violence or physical intimidation will not be tolerated. We have a zero-tolerance policy for racism, sexism, and bigotry.`, - }, - [SIGNAL_CARD_ID]: { - "Core Principle & Scope": `We use Signal for all operational communication. To keep our workspace organized, official channels are prepended with an emoji (e.g., 🤓). Public channels are open to all volunteers, while Core Channels are restricted to coordinators. All Core Members are designated as admins to share the technical workload.`, - "Logistics, Admin & Norms": `We encourage direct messages to build friendship, but all operational logistics must happen in group channels. To respect everyone's time, use "Emoji Reactions" (👍, ♥️) to acknowledge messages rather than typing "thanks," which triggers notifications for everyone. Text is a poor medium for nuance: if a conversation needs more context, move it to a call or in person.`, - "Code of Conduct": `This space relies on collective responsibility. Posting content that attracts unwanted legal attention or exposes members' real-world identities without consent is prohibited. We aspire to do no harm by practicing strict operational security. Intentionally leaking information violates our safety. We have a zero-tolerance policy for harassment or abuse.`, - }, - [VIDEO_MEETINGS_CARD_ID]: { - "Core Principle & Scope": `We prioritize synchronous connection to read facial expressions without the barrier of travel, using this tool for weekly syncs and quick consensus checks that benefit from real-time debate before moving to a vote.`, - "Logistics, Admin & Norms": `The host manages technical security via waiting rooms to prevent intrusion. Culturally, the focus is on maximizing the value of synchronous time. Norms include muting when not speaking, using the "Raise Hand" feature to queue, and utilizing the chat box for non-interruptive side comments. Distractions should be minimized.`, - "Code of Conduct": `We have a zero-tolerance policy for racism, sexism, and bigotry, whether spoken or shared in the chat. We aspire to do no harm. "Zoom-bombing" or broadcasting graphic content is prohibited. Willfully spreading obviously false information will not be tolerated. Do not discuss sensitive data that could attract legal or security risk.`, - }, -}; - -/** - * Section with heading + info icon and an editable TextArea. - * This variant uses TextArea only (no TextInput); design is "Add Signal" / "Add Video Meetings". - */ -function CreateModalSection({ - title, - value: _value, - onChange, -}: { - title: string; - value: string; - onChange: (_value: string) => void; -}) { - return ( -
-
-

- {title} -

- - ? - -
-