Wire Publish rule from create flow

This commit is contained in:
adilallo
2026-04-07 22:26:25 -06:00
parent a4f0b449b6
commit 8f932e95cd
16 changed files with 839 additions and 252 deletions
+2 -2
View File
@@ -25,7 +25,7 @@ Use `npx prisma studio` to inspect the database.
| GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect |
| POST | `/api/auth/logout` | Clear session |
| GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) |
| GET / POST | `/api/rules` | List or publish rules |
| GET / POST | `/api/rules` | List or publish rules (each **Finalize** creates a new published row until an update/edit-published API exists) |
| GET | `/api/templates` | List curated templates |
### Email magic link (sign-in)
@@ -39,7 +39,7 @@ Use `npx prisma studio` to inspect the database.
### Optional draft sync
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`** only.
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.
## Frontend / tests
+9 -2
View File
@@ -103,7 +103,7 @@ export default function LoginForm({
}
return;
}
if (isSaveProgress) {
if (isSaveProgress || nextPath.includes("syncDraft=1")) {
setTransferPendingFlag();
}
setEmail(trimmed);
@@ -113,7 +113,14 @@ export default function LoginForm({
} finally {
setSubmitting(false);
}
}, [email, isSaveProgress, magicLinkNextPath, nextParam, stripErrorQuery, t]);
}, [
email,
isSaveProgress,
magicLinkNextPath,
nextParam,
stripErrorQuery,
t,
]);
const urlErrorMessage =
errorParam === "expired_link"
+285
View File
@@ -0,0 +1,285 @@
"use client";
import {
Suspense,
useCallback,
useEffect,
useState,
type ReactNode,
} from "react";
import { usePathname, useRouter } from "next/navigation";
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 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 { writeLastPublishedRule } from "../../lib/create/lastPublishedRule";
import messages from "../../messages/en/index";
import { useAuthModal } from "../contexts/AuthModalContext";
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
import { SignedInDraftHydration } from "./SignedInDraftHydration";
import Alert from "../components/modals/Alert";
import {
CreateFlowDraftSaveBannerProvider,
useCreateFlowDraftSaveBanner,
} from "./context/CreateFlowDraftSaveBannerContext";
/** First step where Save & Exit is offered (after informational + name / `text`). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select");
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const [sessionUser, setSessionUser] = useState<
{ id: string; email: string } | null | undefined
>(undefined);
useEffect(() => {
let cancelled = false;
void fetchAuthSession().then(({ user }) => {
if (!cancelled) setSessionUser(user);
});
return () => {
cancelled = true;
};
}, []);
const sessionResolved = sessionUser !== undefined;
const enableAnonymousPersistence = sessionResolved && sessionUser === null;
return (
<CreateFlowProvider enableAnonymousPersistence={enableAnonymousPersistence}>
<CreateFlowDraftSaveBannerProvider>
<CreateFlowLayoutContent
sessionUser={sessionUser}
sessionResolved={sessionResolved}
>
{children}
</CreateFlowLayoutContent>
</CreateFlowDraftSaveBannerProvider>
</CreateFlowProvider>
);
}
function CreateFlowLayoutContent({
children,
sessionUser,
sessionResolved,
}: {
children: ReactNode;
sessionUser: { id: string; email: string } | null | undefined;
sessionResolved: boolean;
}) {
const router = useRouter();
const pathname = usePathname();
const { openLogin } = useAuthModal();
const {
currentStep,
nextStep,
previousStep,
goToNextStep,
goToPreviousStep,
} = useCreateFlowNavigation();
const { state, clearState } = useCreateFlow();
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
useCreateFlowDraftSaveBanner();
const [publishBannerMessage, setPublishBannerMessage] = useState<
string | null
>(null);
const [isPublishing, setIsPublishing] = useState(false);
const handleFinalize = useCallback(async () => {
setPublishBannerMessage(null);
const payloadResult = buildPublishPayload(state);
if (payloadResult.ok === false) {
setPublishBannerMessage(
payloadResult.error === "missingCommunityName"
? messages.create.publish.missingCommunityName
: payloadResult.error,
);
return;
}
const { title, summary, document: ruleDocument } = payloadResult;
setIsPublishing(true);
const publishResult = await publishRule({
title,
summary,
document: ruleDocument,
});
setIsPublishing(false);
if (publishResult.ok === true) {
writeLastPublishedRule({
id: publishResult.id,
title,
summary: summary ?? null,
document: ruleDocument,
});
router.push("/create/completed");
return;
}
if (publishResult.status === 401) {
openLogin({
variant: "default",
nextPath: "/create/final-review?syncDraft=1",
backdropVariant: "blurredYellow",
});
return;
}
setPublishBannerMessage(
publishResult.error.trim() !== ""
? publishResult.error
: messages.create.publish.genericPublishFailed,
);
}, [state, router, openLogin]);
const runAuthenticatedExit = useCreateFlowExit({
state,
currentStep,
clearState,
router,
user: sessionUser ?? null,
setDraftSaveBannerMessage,
});
const handleExit = async (opts?: { saveDraft?: boolean }) => {
const saveDraft = opts?.saveDraft ?? false;
if (!sessionResolved) return;
if (sessionUser === null) {
if (saveDraft) return;
openLogin({
variant: "saveProgress",
nextPath: `${pathname ?? "/create/informational"}?syncDraft=1`,
backdropVariant: "blurredYellow",
});
return;
}
if (!sessionUser) return;
await runAuthenticatedExit(opts);
};
const isCompletedStep = currentStep === "completed";
const isRightRailStep = currentStep === "right-rail";
const useFullHeightMain = isCompletedStep || isRightRailStep;
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
const saveDraftOnExit =
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
const hasErrorOverlays =
Boolean(draftSaveBannerMessage) || Boolean(publishBannerMessage);
return (
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
{hasErrorOverlays ? (
<div
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]"
aria-live="polite"
>
{draftSaveBannerMessage ? (
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
<Alert
type="banner"
status="danger"
title={messages.create.topNav.draftSaveBannerTitle}
description={draftSaveBannerMessage}
onClose={() => setDraftSaveBannerMessage(null)}
className="w-full"
/>
</div>
) : null}
{publishBannerMessage ? (
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
<Alert
type="banner"
status="danger"
title={messages.create.publish.finalizeBannerTitle}
description={publishBannerMessage}
onClose={() => setPublishBannerMessage(null)}
className="w-full"
/>
</div>
) : null}
</div>
) : null}
<Suspense fallback={null}>
<SignedInDraftHydration
sessionUser={sessionUser}
sessionResolved={sessionResolved}
/>
</Suspense>
<Suspense fallback={null}>
<PostLoginDraftTransfer sessionUser={sessionUser} />
</Suspense>
<CreateFlowTopNav
hasShare={isCompletedStep}
hasExport={isCompletedStep}
hasEdit={isCompletedStep}
saveDraftOnExit={saveDraftOnExit}
onEdit={
isCompletedStep
? () => router.push("/create/final-review")
: undefined
}
onExit={(opts) => void handleExit(opts)}
buttonPalette={isCompletedStep ? "inverse" : undefined}
className={`shrink-0 ${
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
}`.trim()}
/>
<main
className={`flex min-h-0 flex-1 justify-center ${
useFullHeightMain
? isCompletedStep
? "items-stretch overflow-y-auto sm:overflow-hidden"
: "items-stretch overflow-hidden"
: "flex-row items-center justify-center overflow-y-auto"
}`}
>
{children}
</main>
{!isCompletedStep && (
<CreateFlowFooter
className="shrink-0"
secondButton={
nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isPublishing}
className="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)]"
onClick={() => {
if (currentStep === "final-review") {
void handleFinalize();
} else {
goToNextStep();
}
}}
>
{currentStep === "final-review"
? isPublishing
? messages.create.publish.finalizeButtonPublishing
: "Finalize CommunityRule"
: currentStep === "confirm-stakeholders"
? "Confirm Stakeholders"
: "Next"}
</Button>
) : null
}
onBackClick={previousStep ? goToPreviousStep : undefined}
/>
)}
</div>
);
}
export default function CreateFlowLayoutClient({
children,
}: {
children: ReactNode;
}) {
return <CreateFlowSessionShell>{children}</CreateFlowSessionShell>;
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import dynamic from "next/dynamic";
import type { ReactNode } from "react";
const CreateFlowLayoutClient = dynamic(
() => import("./CreateFlowLayoutClient"),
{
ssr: false,
loading: () => (
<div
className="flex h-screen min-h-0 flex-col overflow-hidden bg-black"
aria-busy="true"
aria-label="Loading create flow"
/>
),
},
);
export default function CreateFlowLayoutGate({
children,
}: {
children: ReactNode;
}) {
return <CreateFlowLayoutClient>{children}</CreateFlowLayoutClient>;
}
+41 -13
View File
@@ -16,7 +16,8 @@ const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
/**
* After magic-link verify, redirects to `/create/...?syncDraft=1` with session cookie.
* Uploads anonymous localStorage draft to `RuleDraft` once, then hydrates context.
* With backend sync: PUT draft once then hydrates context. Without sync: hydrates from
* `create-flow-anonymous` localStorage only (no server write).
*/
export function PostLoginDraftTransfer({
sessionUser,
@@ -38,19 +39,46 @@ export function PostLoginDraftTransfer({
if (attemptedRef.current) return;
if (!SYNC_ENABLED) {
if (attemptedRef.current) return;
attemptedRef.current = true;
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync-off path: show one-shot error then strip query
setTransferError(
"Saving to your account is not available (server sync is disabled). Your progress stays on this device.",
);
if (pathname) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
router.replace(q ? `${pathname}?${q}` : pathname);
}
return;
let cancelled = false;
void (async () => {
const local = readAnonymousCreateFlowState();
const pending = hasTransferPendingFlag();
if (Object.keys(local).length === 0 && !pending) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
if (pathname) {
router.replace(q ? `${pathname}?${q}` : pathname);
}
attemptedRef.current = false;
return;
}
const segment = pathname?.split("/").pop() ?? "";
const step = isValidStep(segment) ? segment : undefined;
const payload = {
...local,
...(step ? { currentStep: step } : {}),
};
if (cancelled) return;
clearAnonymousCreateFlowStorage();
replaceState(payload);
if (cancelled) return;
if (pathname) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
router.replace(q ? `${pathname}?${q}` : pathname);
}
})();
return () => {
cancelled = true;
};
}
attemptedRef.current = true;
+29 -8
View File
@@ -6,9 +6,12 @@ import HeaderLockup from "../../components/type/HeaderLockup";
import CommunityRuleDocument from "../../components/sections/CommunityRuleDocument";
import type { CommunityRuleDocumentSection } from "../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
import Alert from "../../components/modals/Alert";
import { parseDocumentSectionsForDisplay } from "../../../lib/create/buildPublishPayload";
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
const TITLE = "Mutual Aid Mondays";
const DESCRIPTION =
/** Demo copy when `/create/completed` is opened without a prior publish in this tab. */
const FALLBACK_TITLE = "Mutual Aid Mondays";
const FALLBACK_DESCRIPTION =
"Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.";
const TOAST_TITLE = "This is what folks see when you share your CommunityRule";
@@ -91,6 +94,12 @@ const COMPLETED_RULE_SECTIONS: CommunityRuleDocumentSection[] = [
export default function CompletedPage() {
const [isMounted, setIsMounted] = useState(false);
const [toastDismissed, setToastDismissed] = useState(false);
const [headerTitle, setHeaderTitle] = useState(FALLBACK_TITLE);
const [headerDescription, setHeaderDescription] = useState<
string | undefined
>(FALLBACK_DESCRIPTION);
const [documentSections, setDocumentSections] =
useState<CommunityRuleDocumentSection[]>(COMPLETED_RULE_SECTIONS);
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
useEffect(() => {
@@ -98,6 +107,18 @@ export default function CompletedPage() {
setIsMounted(true);
}, []);
useEffect(() => {
const stored = readLastPublishedRule();
if (!stored) return;
const parsed = parseDocumentSectionsForDisplay(stored.document);
if (parsed.length === 0) return;
setDocumentSections(parsed);
setHeaderTitle(stored.title);
const sum =
typeof stored.summary === "string" ? stored.summary.trim() : "";
setHeaderDescription(sum.length > 0 ? sum : undefined);
}, []);
const showDesktopLayout = !isMounted || isMdOrLarger;
if (showDesktopLayout) {
@@ -108,8 +129,8 @@ export default function CompletedPage() {
{/* Left column: community title + header, centered, does not scroll */}
<div className="flex min-w-0 flex-col justify-center overflow-hidden py-8">
<HeaderLockup
title={TITLE}
description={DESCRIPTION}
title={headerTitle}
description={headerDescription}
justification="left"
size="L"
palette="inverse"
@@ -124,7 +145,7 @@ export default function CompletedPage() {
/>
<div className="py-8 min-w-0">
<CommunityRuleDocument
sections={COMPLETED_RULE_SECTIONS}
sections={documentSections}
className="min-w-0"
/>
</div>
@@ -159,14 +180,14 @@ export default function CompletedPage() {
<div className="w-full flex flex-col items-center px-5 min-w-0 bg-[var(--color-teal-teal50,#c9fef9)] py-8">
<div className="flex flex-col gap-4 w-full max-w-[639px]">
<HeaderLockup
title={TITLE}
description={DESCRIPTION}
title={headerTitle}
description={headerDescription}
justification="left"
size="M"
palette="inverse"
/>
<CommunityRuleDocument
sections={COMPLETED_RULE_SECTIONS}
sections={documentSections}
useCardStyle
className="w-full p-4"
/>
+22 -10
View File
@@ -1,18 +1,19 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import HeaderLockup from "../../components/type/HeaderLockup";
import RuleCard from "../../components/cards/RuleCard";
import type { Category } from "../../components/cards/RuleCard/RuleCard.types";
import { useCreateFlow } from "../context/CreateFlowContext";
const TITLE = "Review your CommunityRule";
const DESCRIPTION =
"Here's what other people will see. Make sure everything looks good before you finalize everything. Once the rule is finalized, you must use one of your decision-making mechanisms to edit it again.";
const RULE_CARD_TITLE = "Mutual Aid Mondays";
const RULE_CARD_DESCRIPTION =
"Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.";
const RULE_CARD_TITLE_FALLBACK = "Your community";
const RULE_CARD_DESCRIPTION_FALLBACK =
"Add a short description of your community on earlier steps when that field is available. For now, this card shows your community name.";
/** Static categories for final review (read-only display). */
const FINAL_REVIEW_CATEGORIES: Category[] = [
@@ -55,9 +56,20 @@ const FINAL_REVIEW_CATEGORIES: Category[] = [
* Figma: 20907-212767 (full-size), 20976-220705 (small breakpoint).
*/
export default function FinalReviewPage() {
const { state } = useCreateFlow();
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
const ruleCardTitle = useMemo(() => {
const t = typeof state.title === "string" ? state.title.trim() : "";
return t.length > 0 ? t : RULE_CARD_TITLE_FALLBACK;
}, [state.title]);
const ruleCardDescription = useMemo(() => {
const s = typeof state.summary === "string" ? state.summary.trim() : "";
return s.length > 0 ? s : RULE_CARD_DESCRIPTION_FALLBACK;
}, [state.summary]);
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
@@ -80,13 +92,13 @@ export default function FinalReviewPage() {
</div>
<div className="min-w-0 w-full flex flex-col items-stretch">
<RuleCard
title={RULE_CARD_TITLE}
description={RULE_CARD_DESCRIPTION}
title={ruleCardTitle}
description={ruleCardDescription}
size="L"
expanded={true}
backgroundColor="bg-[#c9fef9]"
logoUrl="/assets/Vector_MutualAid.svg"
logoAlt={RULE_CARD_TITLE}
logoAlt={ruleCardTitle}
categories={FINAL_REVIEW_CATEGORIES}
className="rounded-[24px] !max-w-full !w-full min-w-0"
onClick={() => {}}
@@ -107,13 +119,13 @@ export default function FinalReviewPage() {
size="M"
/>
<RuleCard
title={RULE_CARD_TITLE}
description={RULE_CARD_DESCRIPTION}
title={ruleCardTitle}
description={ruleCardDescription}
size="L"
expanded={true}
backgroundColor="bg-[#c9fef9]"
logoUrl="/assets/Vector_MutualAid.svg"
logoAlt={RULE_CARD_TITLE}
logoAlt={ruleCardTitle}
categories={FINAL_REVIEW_CATEGORIES}
className="w-full rounded-[12px] p-4"
onClick={() => {}}
+4 -191
View File
@@ -1,193 +1,6 @@
"use client";
import type { ReactNode } from "react";
import CreateFlowLayoutGate from "./CreateFlowLayoutGate";
import { Suspense, useEffect, useState, type ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
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 CreateFlowFooter from "../components/utility/CreateFlowFooter";
import Button from "../components/buttons/Button";
import { fetchAuthSession } from "../../lib/create/api";
import messages from "../../messages/en/index";
import { useAuthModal } from "../contexts/AuthModalContext";
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
import { SignedInDraftHydration } from "./SignedInDraftHydration";
import Alert from "../components/modals/Alert";
import {
CreateFlowDraftSaveBannerProvider,
useCreateFlowDraftSaveBanner,
} from "./context/CreateFlowDraftSaveBannerContext";
/** First step where Save & Exit is offered (after informational + name / `text`). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select");
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const [sessionUser, setSessionUser] = useState<
{ id: string; email: string } | null | undefined
>(undefined);
useEffect(() => {
let cancelled = false;
void fetchAuthSession().then(({ user }) => {
if (!cancelled) setSessionUser(user);
});
return () => {
cancelled = true;
};
}, []);
const sessionResolved = sessionUser !== undefined;
const enableAnonymousPersistence = sessionResolved && sessionUser === null;
return (
<CreateFlowProvider enableAnonymousPersistence={enableAnonymousPersistence}>
<CreateFlowDraftSaveBannerProvider>
<CreateFlowLayoutContent
sessionUser={sessionUser}
sessionResolved={sessionResolved}
>
{children}
</CreateFlowLayoutContent>
</CreateFlowDraftSaveBannerProvider>
</CreateFlowProvider>
);
}
function CreateFlowLayoutContent({
children,
sessionUser,
sessionResolved,
}: {
children: ReactNode;
sessionUser: { id: string; email: string } | null | undefined;
sessionResolved: boolean;
}) {
const router = useRouter();
const pathname = usePathname();
const { openLogin } = useAuthModal();
const {
currentStep,
nextStep,
previousStep,
goToNextStep,
goToPreviousStep,
} = useCreateFlowNavigation();
const { state, clearState } = useCreateFlow();
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
useCreateFlowDraftSaveBanner();
const runAuthenticatedExit = useCreateFlowExit({
state,
currentStep,
clearState,
router,
user: sessionUser ?? null,
setDraftSaveBannerMessage,
});
const handleExit = async (opts?: { saveDraft?: boolean }) => {
const saveDraft = opts?.saveDraft ?? false;
if (!sessionResolved) return;
if (sessionUser === null) {
if (saveDraft) return;
openLogin({
variant: "saveProgress",
nextPath: `${pathname ?? "/create/informational"}?syncDraft=1`,
backdropVariant: "blurredYellow",
});
return;
}
if (!sessionUser) return;
await runAuthenticatedExit(opts);
};
const isCompletedStep = currentStep === "completed";
const isRightRailStep = currentStep === "right-rail";
const useFullHeightMain = isCompletedStep || isRightRailStep;
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
const saveDraftOnExit =
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
return (
<div
className={`bg-black flex flex-col ${useFullHeightMain ? "h-screen overflow-hidden" : "min-h-screen"}`}
>
{draftSaveBannerMessage ? (
<div className="w-full shrink-0 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)] z-[100]">
<Alert
type="banner"
status="danger"
title={messages.create.topNav.draftSaveBannerTitle}
description={draftSaveBannerMessage}
onClose={() => setDraftSaveBannerMessage(null)}
className="w-full max-w-[960px] mx-auto"
/>
</div>
) : null}
<Suspense fallback={null}>
<SignedInDraftHydration
sessionUser={sessionUser}
sessionResolved={sessionResolved}
/>
</Suspense>
<Suspense fallback={null}>
<PostLoginDraftTransfer sessionUser={sessionUser} />
</Suspense>
<CreateFlowTopNav
hasShare={isCompletedStep}
hasExport={isCompletedStep}
hasEdit={isCompletedStep}
saveDraftOnExit={saveDraftOnExit}
onEdit={
isCompletedStep
? () => router.push("/create/final-review")
: undefined
}
onExit={(opts) => void handleExit(opts)}
buttonPalette={isCompletedStep ? "inverse" : undefined}
className={
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : undefined
}
/>
<main
className={`flex-1 flex min-h-0 justify-center ${useFullHeightMain ? "items-stretch overflow-hidden" : "items-center overflow-auto"}`}
>
{children}
</main>
{!isCompletedStep && (
<CreateFlowFooter
secondButton={
nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
className="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)]"
onClick={goToNextStep}
>
{currentStep === "final-review"
? "Finalize CommunityRule"
: currentStep === "confirm-stakeholders"
? "Confirm Stakeholders"
: "Next"}
</Button>
) : null
}
onBackClick={previousStep ? goToPreviousStep : undefined}
/>
)}
</div>
);
}
export default function CreateFlowLayout({
children,
}: {
children: ReactNode;
}) {
return <CreateFlowSessionShell>{children}</CreateFlowSessionShell>;
export default function CreateFlowLayout({ children }: { children: ReactNode }) {
return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>;
}
+54 -18
View File
@@ -83,6 +83,21 @@ export async function fetchDraftFromServer(): Promise<CreateFlowState | null> {
const DRAFT_SAVE_NETWORK_ERROR =
"Something went wrong. Check your connection and try again.";
const PUBLISH_FAILED_FALLBACK =
"Something went wrong. Check your connection or try again.";
/** Parse JSON body; empty or invalid bodies return `null` (avoids `response.json()` throws). */
async function safeParseJsonResponse(res: Response): Promise<unknown> {
const text = await res.text();
const trimmed = text.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as unknown;
} catch {
return null;
}
}
export type SaveDraftResult =
| { ok: true }
| { ok: false; message: string; status?: number };
@@ -131,23 +146,44 @@ export async function publishRule(input: {
title: string;
summary?: string;
document: Record<string, unknown>;
}): Promise<{ ok: true; id: string; title: string } | { error: string }> {
const res = await fetch("/api/rules", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({
title: input.title,
summary: input.summary,
document: input.document,
}),
});
const data = await parseJson<{
error?: string;
rule?: { id: string; title: string };
}>(res);
if (!res.ok || !data.rule) {
return { error: readApiErrorMessage(data) };
}): Promise<
| { ok: true; id: string; title: string }
| { ok: false; error: string; status?: number }
> {
try {
const res = await fetch("/api/rules", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({
title: input.title,
summary: input.summary,
document: input.document,
}),
});
const data = (await safeParseJsonResponse(res)) as {
error?: string | { message?: string };
rule?: { id: string; title: string };
} | null;
const rule = data && typeof data === "object" ? data.rule : undefined;
if (!res.ok || !rule) {
const fromBody =
data && typeof data === "object" ? readApiErrorMessage(data) : null;
const msg =
fromBody && fromBody !== "Request failed"
? fromBody
: res.statusText?.trim() || PUBLISH_FAILED_FALLBACK;
return {
ok: false as const,
error: msg,
status: res.status,
};
}
return { ok: true, id: rule.id, title: rule.title };
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
};
}
return { ok: true, id: data.rule.id, title: data.rule.title };
}
+84
View File
@@ -0,0 +1,84 @@
import type { CreateFlowState } from "../../app/create/types";
import type { CommunityRuleDocumentSection } from "../../app/components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
function isDocumentEntry(x: unknown): x is { title: string; body: string } {
if (!x || typeof x !== "object") return false;
const o = x as Record<string, unknown>;
return typeof o.title === "string" && typeof o.body === "string";
}
function isDocumentSection(x: unknown): x is CommunityRuleDocumentSection {
if (!x || typeof x !== "object") return false;
const o = x as Record<string, unknown>;
if (typeof o.categoryName !== "string") return false;
if (!Array.isArray(o.entries)) return false;
return o.entries.every(isDocumentEntry);
}
/** Narrow `CreateFlowState.sections` into Community Rule document sections. */
export function parseSectionsFromCreateFlowState(
state: CreateFlowState,
): CommunityRuleDocumentSection[] {
const raw = state.sections;
if (!Array.isArray(raw)) return [];
const out: CommunityRuleDocumentSection[] = [];
for (const x of raw) {
if (isDocumentSection(x)) out.push(x);
}
return out;
}
export type BuildPublishPayloadResult =
| {
ok: true;
title: string;
summary?: string;
document: Record<string, unknown>;
}
| { ok: false; error: string };
const FALLBACK_CATEGORY = "Overview";
const DEFAULT_FALLBACK_BODY =
"This CommunityRule was created in the create flow. Add more detail in a future edit.";
export function buildPublishPayload(
state: CreateFlowState,
): BuildPublishPayloadResult {
const title = typeof state.title === "string" ? state.title.trim() : "";
if (!title) {
return { ok: false, error: "missingCommunityName" };
}
let summary: string | undefined;
if (typeof state.summary === "string") {
const t = state.summary.trim();
if (t.length > 0) summary = t;
}
let sections = parseSectionsFromCreateFlowState(state);
if (sections.length === 0) {
const body = summary ?? DEFAULT_FALLBACK_BODY;
sections = [
{
categoryName: FALLBACK_CATEGORY,
entries: [{ title: "Community", body }],
},
];
}
if (summary !== undefined) {
return { ok: true, title, summary, document: { sections } };
}
return { ok: true, title, document: { sections } };
}
/** Read `document.sections` from a stored published payload for display. */
export function parseDocumentSectionsForDisplay(
document: unknown,
): CommunityRuleDocumentSection[] {
if (!document || typeof document !== "object") return [];
const sections = (document as Record<string, unknown>).sections;
if (!Array.isArray(sections)) return [];
return sections.filter(isDocumentSection);
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Bridges final-review → completed without query strings.
* Replace with GET /api/rules/[id] (CR-81) when public rule fetch exists.
*/
export const CREATE_FLOW_LAST_PUBLISHED_KEY = "createFlow.lastPublished";
export type StoredLastPublishedRule = {
id: string;
title: string;
summary?: string | null;
document: Record<string, unknown>;
};
export function writeLastPublishedRule(data: StoredLastPublishedRule): void {
if (typeof sessionStorage === "undefined") return;
sessionStorage.setItem(CREATE_FLOW_LAST_PUBLISHED_KEY, JSON.stringify(data));
}
export function readLastPublishedRule(): StoredLastPublishedRule | null {
if (typeof sessionStorage === "undefined") return null;
const raw = sessionStorage.getItem(CREATE_FLOW_LAST_PUBLISHED_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const o = parsed as Record<string, unknown>;
if (typeof o.id !== "string" || typeof o.title !== "string") return null;
const doc = o.document;
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
return null;
}
const summaryVal = o.summary;
let summary: string | null | undefined;
if (typeof summaryVal === "string") {
summary = summaryVal;
} else if (summaryVal === null) {
summary = null;
} else {
summary = undefined;
}
return {
id: o.id,
title: o.title,
summary,
document: doc as Record<string, unknown>,
};
} catch {
return null;
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"finalizeBannerTitle": "Couldn't publish",
"missingCommunityName": "Add a community name before finalizing.",
"finalizeButtonPublishing": "Publishing…",
"genericPublishFailed": "Something went wrong. Try again."
}
+2
View File
@@ -20,6 +20,7 @@ import metadata from "./metadata.json";
import communication from "./create/communication.json";
import createTopNav from "./create/topNav.json";
import createDraftHydration from "./create/draftHydration.json";
import createPublish from "./create/publish.json";
export default {
common,
@@ -45,6 +46,7 @@ export default {
communication,
topNav: createTopNav,
draftHydration: createDraftHydration,
publish: createPublish,
},
navigation,
metadata,
+42 -8
View File
@@ -1,7 +1,31 @@
import { useLayoutEffect } from "react";
import { describe, it, expect } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import {
renderWithProviders as render,
screen,
waitFor,
} from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
import FinalReviewPage from "../../app/create/final-review/page";
import { useCreateFlow } from "../../app/create/context/CreateFlowContext";
const FALLBACK_CARD_TITLE = "Your community";
const FALLBACK_CARD_DESCRIPTION_SNIPPET =
"Add a short description of your community";
function FinalReviewWithFlowState({
title,
summary,
}: {
title: string;
summary?: string;
}) {
const { replaceState } = useCreateFlow();
useLayoutEffect(() => {
replaceState({ title, ...(summary !== undefined ? { summary } : {}) });
}, [replaceState, title, summary]);
return <FinalReviewPage />;
}
describe("FinalReviewPage", () => {
it("renders without crashing", () => {
@@ -27,17 +51,27 @@ describe("FinalReviewPage", () => {
).toBeInTheDocument();
});
it("renders RuleCard with title", () => {
it("renders RuleCard with fallback title when context has no name", () => {
render(<FinalReviewPage />);
expect(screen.getByText("Mutual Aid Mondays")).toBeInTheDocument();
expect(screen.getByText(FALLBACK_CARD_TITLE)).toBeInTheDocument();
});
it("renders RuleCard with description", () => {
it("renders RuleCard with fallback description when context has no summary", () => {
render(<FinalReviewPage />);
expect(
screen.getByText(
/Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness./i,
),
screen.getByText(new RegExp(FALLBACK_CARD_DESCRIPTION_SNIPPET, "i")),
).toBeInTheDocument();
});
it("renders RuleCard title from create flow state", async () => {
render(
<FinalReviewWithFlowState title="Oak Park Commons" summary="Local mutual aid." />,
);
await waitFor(() => {
expect(screen.getByText("Oak Park Commons")).toBeInTheDocument();
});
expect(
screen.getByText(/Local mutual aid\./i),
).toBeInTheDocument();
});
@@ -46,7 +80,7 @@ describe("FinalReviewPage", () => {
const buttons = screen.getAllByRole("button");
expect(buttons.length).toBeGreaterThanOrEqual(1);
expect(
buttons.some((el) => el.textContent?.includes("Mutual Aid Mondays")),
buttons.some((el) => el.textContent?.includes(FALLBACK_CARD_TITLE)),
).toBe(true);
});
+112
View File
@@ -0,0 +1,112 @@
import { describe, it, expect } from "vitest";
import {
buildPublishPayload,
parseDocumentSectionsForDisplay,
parseSectionsFromCreateFlowState,
} from "../../lib/create/buildPublishPayload";
import type { CreateFlowState } from "../../app/create/types";
describe("buildPublishPayload", () => {
it("returns error when title missing", () => {
expect(buildPublishPayload({})).toEqual({
ok: false,
error: "missingCommunityName",
});
});
it("returns error when title is whitespace only", () => {
expect(buildPublishPayload({ title: " \n\t " })).toEqual({
ok: false,
error: "missingCommunityName",
});
});
it("returns title and fallback Overview section when no sections", () => {
const r = buildPublishPayload({ title: "Oak Park Commons" });
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.title).toBe("Oak Park Commons");
expect(r.summary).toBeUndefined();
expect(r.document).toEqual({
sections: [
{
categoryName: "Overview",
entries: [
{
title: "Community",
body: "This CommunityRule was created in the create flow. Add more detail in a future edit.",
},
],
},
],
});
});
it("includes trimmed summary in payload and uses it as fallback section body", () => {
const r = buildPublishPayload({
title: " My Group ",
summary: " We organize locally. ",
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.title).toBe("My Group");
expect(r.summary).toBe("We organize locally.");
expect(r.document).toEqual({
sections: [
{
categoryName: "Overview",
entries: [{ title: "Community", body: "We organize locally." }],
},
],
});
});
it("uses valid state.sections when present", () => {
const sections: CreateFlowState["sections"] = [
{
categoryName: "Values",
entries: [{ title: "A", body: "B" }],
},
];
const r = buildPublishPayload({ title: "T", sections });
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.document).toEqual({ sections });
});
it("filters invalid section entries from state.sections", () => {
const r = buildPublishPayload({
title: "T",
sections: [
{ categoryName: "Values", entries: [{ title: "A", body: "B" }] },
{ bad: true } as unknown as Record<string, unknown>,
],
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.document).toEqual({
sections: [{ categoryName: "Values", entries: [{ title: "A", body: "B" }] }],
});
});
});
describe("parseDocumentSectionsForDisplay", () => {
it("returns empty for non-object", () => {
expect(parseDocumentSectionsForDisplay(null)).toEqual([]);
});
it("parses valid sections array", () => {
const doc = {
sections: [
{ categoryName: "X", entries: [{ title: "t", body: "b" }] },
],
};
expect(parseDocumentSectionsForDisplay(doc)).toEqual(doc.sections);
});
});
describe("parseSectionsFromCreateFlowState", () => {
it("returns empty when sections missing", () => {
expect(parseSectionsFromCreateFlowState({})).toEqual([]);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { publishRule } from "../../lib/create/api";
const input = {
title: "T",
document: { sections: [] as unknown[] },
};
describe("publishRule", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns ok on 200 with rule", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ rule: { id: "r1", title: "T" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const result = await publishRule(input);
expect(result).toEqual({ ok: true, id: "r1", title: "T" });
});
it("does not throw when body is empty (e.g. connection reset)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response("", {
status: 503,
statusText: "Service Unavailable",
}),
);
const result = await publishRule(input);
expect(result.ok).toBe(false);
if (result.ok === false) {
expect(result.status).toBe(503);
expect(result.error).toBe("Service Unavailable");
}
});
it("parses validation error when JSON present", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
error: { code: "validation_error", message: "title required" },
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
),
);
const result = await publishRule(input);
expect(result).toEqual({
ok: false,
error: "title required",
status: 400,
});
});
it("returns network message when fetch rejects", async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error("offline"));
const result = await publishRule(input);
expect(result).toEqual({
ok: false,
error: "Something went wrong. Check your connection and try again.",
});
});
});