Wire Publish rule from create flow
This commit is contained in:
+2
-2
@@ -25,7 +25,7 @@ Use `npx prisma studio` to inspect the database.
|
|||||||
| GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect |
|
| GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect |
|
||||||
| POST | `/api/auth/logout` | Clear session |
|
| POST | `/api/auth/logout` | Clear session |
|
||||||
| GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) |
|
| 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 |
|
| GET | `/api/templates` | List curated templates |
|
||||||
|
|
||||||
### Email magic link (sign-in)
|
### Email magic link (sign-in)
|
||||||
@@ -39,7 +39,7 @@ Use `npx prisma studio` to inspect the database.
|
|||||||
|
|
||||||
### Optional draft sync
|
### 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
|
## Frontend / tests
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export default function LoginForm({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isSaveProgress) {
|
if (isSaveProgress || nextPath.includes("syncDraft=1")) {
|
||||||
setTransferPendingFlag();
|
setTransferPendingFlag();
|
||||||
}
|
}
|
||||||
setEmail(trimmed);
|
setEmail(trimmed);
|
||||||
@@ -113,7 +113,14 @@ export default function LoginForm({
|
|||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [email, isSaveProgress, magicLinkNextPath, nextParam, stripErrorQuery, t]);
|
}, [
|
||||||
|
email,
|
||||||
|
isSaveProgress,
|
||||||
|
magicLinkNextPath,
|
||||||
|
nextParam,
|
||||||
|
stripErrorQuery,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
const urlErrorMessage =
|
const urlErrorMessage =
|
||||||
errorParam === "expired_link"
|
errorParam === "expired_link"
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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.
|
* 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({
|
export function PostLoginDraftTransfer({
|
||||||
sessionUser,
|
sessionUser,
|
||||||
@@ -38,19 +39,46 @@ export function PostLoginDraftTransfer({
|
|||||||
if (attemptedRef.current) return;
|
if (attemptedRef.current) return;
|
||||||
|
|
||||||
if (!SYNC_ENABLED) {
|
if (!SYNC_ENABLED) {
|
||||||
if (attemptedRef.current) return;
|
|
||||||
attemptedRef.current = true;
|
attemptedRef.current = true;
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync-off path: show one-shot error then strip query
|
let cancelled = false;
|
||||||
setTransferError(
|
void (async () => {
|
||||||
"Saving to your account is not available (server sync is disabled). Your progress stays on this device.",
|
const local = readAnonymousCreateFlowState();
|
||||||
);
|
const pending = hasTransferPendingFlag();
|
||||||
if (pathname) {
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
if (Object.keys(local).length === 0 && !pending) {
|
||||||
params.delete("syncDraft");
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
const q = params.toString();
|
params.delete("syncDraft");
|
||||||
router.replace(q ? `${pathname}?${q}` : pathname);
|
const q = params.toString();
|
||||||
}
|
if (pathname) {
|
||||||
return;
|
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;
|
attemptedRef.current = true;
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import HeaderLockup from "../../components/type/HeaderLockup";
|
|||||||
import CommunityRuleDocument from "../../components/sections/CommunityRuleDocument";
|
import CommunityRuleDocument from "../../components/sections/CommunityRuleDocument";
|
||||||
import type { CommunityRuleDocumentSection } from "../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
|
import type { CommunityRuleDocumentSection } from "../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
|
||||||
import Alert from "../../components/modals/Alert";
|
import Alert from "../../components/modals/Alert";
|
||||||
|
import { parseDocumentSectionsForDisplay } from "../../../lib/create/buildPublishPayload";
|
||||||
|
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||||
|
|
||||||
const TITLE = "Mutual Aid Mondays";
|
/** Demo copy when `/create/completed` is opened without a prior publish in this tab. */
|
||||||
const DESCRIPTION =
|
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.";
|
"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";
|
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() {
|
export default function CompletedPage() {
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const [toastDismissed, setToastDismissed] = 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)");
|
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,6 +107,18 @@ export default function CompletedPage() {
|
|||||||
setIsMounted(true);
|
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;
|
const showDesktopLayout = !isMounted || isMdOrLarger;
|
||||||
|
|
||||||
if (showDesktopLayout) {
|
if (showDesktopLayout) {
|
||||||
@@ -108,8 +129,8 @@ export default function CompletedPage() {
|
|||||||
{/* Left column: community title + header, centered, does not scroll */}
|
{/* Left column: community title + header, centered, does not scroll */}
|
||||||
<div className="flex min-w-0 flex-col justify-center overflow-hidden py-8">
|
<div className="flex min-w-0 flex-col justify-center overflow-hidden py-8">
|
||||||
<HeaderLockup
|
<HeaderLockup
|
||||||
title={TITLE}
|
title={headerTitle}
|
||||||
description={DESCRIPTION}
|
description={headerDescription}
|
||||||
justification="left"
|
justification="left"
|
||||||
size="L"
|
size="L"
|
||||||
palette="inverse"
|
palette="inverse"
|
||||||
@@ -124,7 +145,7 @@ export default function CompletedPage() {
|
|||||||
/>
|
/>
|
||||||
<div className="py-8 min-w-0">
|
<div className="py-8 min-w-0">
|
||||||
<CommunityRuleDocument
|
<CommunityRuleDocument
|
||||||
sections={COMPLETED_RULE_SECTIONS}
|
sections={documentSections}
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="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]">
|
<div className="flex flex-col gap-4 w-full max-w-[639px]">
|
||||||
<HeaderLockup
|
<HeaderLockup
|
||||||
title={TITLE}
|
title={headerTitle}
|
||||||
description={DESCRIPTION}
|
description={headerDescription}
|
||||||
justification="left"
|
justification="left"
|
||||||
size="M"
|
size="M"
|
||||||
palette="inverse"
|
palette="inverse"
|
||||||
/>
|
/>
|
||||||
<CommunityRuleDocument
|
<CommunityRuleDocument
|
||||||
sections={COMPLETED_RULE_SECTIONS}
|
sections={documentSections}
|
||||||
useCardStyle
|
useCardStyle
|
||||||
className="w-full p-4"
|
className="w-full p-4"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||||
import RuleCard from "../../components/cards/RuleCard";
|
import RuleCard from "../../components/cards/RuleCard";
|
||||||
import type { Category } from "../../components/cards/RuleCard/RuleCard.types";
|
import type { Category } from "../../components/cards/RuleCard/RuleCard.types";
|
||||||
|
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||||
|
|
||||||
const TITLE = "Review your CommunityRule";
|
const TITLE = "Review your CommunityRule";
|
||||||
const DESCRIPTION =
|
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.";
|
"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_TITLE_FALLBACK = "Your community";
|
||||||
const RULE_CARD_DESCRIPTION =
|
const RULE_CARD_DESCRIPTION_FALLBACK =
|
||||||
"Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.";
|
"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). */
|
/** Static categories for final review (read-only display). */
|
||||||
const FINAL_REVIEW_CATEGORIES: Category[] = [
|
const FINAL_REVIEW_CATEGORIES: Category[] = [
|
||||||
@@ -55,9 +56,20 @@ const FINAL_REVIEW_CATEGORIES: Category[] = [
|
|||||||
* Figma: 20907-212767 (full-size), 20976-220705 (small breakpoint).
|
* Figma: 20907-212767 (full-size), 20976-220705 (small breakpoint).
|
||||||
*/
|
*/
|
||||||
export default function FinalReviewPage() {
|
export default function FinalReviewPage() {
|
||||||
|
const { state } = useCreateFlow();
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
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).
|
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
// 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>
|
||||||
<div className="min-w-0 w-full flex flex-col items-stretch">
|
<div className="min-w-0 w-full flex flex-col items-stretch">
|
||||||
<RuleCard
|
<RuleCard
|
||||||
title={RULE_CARD_TITLE}
|
title={ruleCardTitle}
|
||||||
description={RULE_CARD_DESCRIPTION}
|
description={ruleCardDescription}
|
||||||
size="L"
|
size="L"
|
||||||
expanded={true}
|
expanded={true}
|
||||||
backgroundColor="bg-[#c9fef9]"
|
backgroundColor="bg-[#c9fef9]"
|
||||||
logoUrl="/assets/Vector_MutualAid.svg"
|
logoUrl="/assets/Vector_MutualAid.svg"
|
||||||
logoAlt={RULE_CARD_TITLE}
|
logoAlt={ruleCardTitle}
|
||||||
categories={FINAL_REVIEW_CATEGORIES}
|
categories={FINAL_REVIEW_CATEGORIES}
|
||||||
className="rounded-[24px] !max-w-full !w-full min-w-0"
|
className="rounded-[24px] !max-w-full !w-full min-w-0"
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
@@ -107,13 +119,13 @@ export default function FinalReviewPage() {
|
|||||||
size="M"
|
size="M"
|
||||||
/>
|
/>
|
||||||
<RuleCard
|
<RuleCard
|
||||||
title={RULE_CARD_TITLE}
|
title={ruleCardTitle}
|
||||||
description={RULE_CARD_DESCRIPTION}
|
description={ruleCardDescription}
|
||||||
size="L"
|
size="L"
|
||||||
expanded={true}
|
expanded={true}
|
||||||
backgroundColor="bg-[#c9fef9]"
|
backgroundColor="bg-[#c9fef9]"
|
||||||
logoUrl="/assets/Vector_MutualAid.svg"
|
logoUrl="/assets/Vector_MutualAid.svg"
|
||||||
logoAlt={RULE_CARD_TITLE}
|
logoAlt={ruleCardTitle}
|
||||||
categories={FINAL_REVIEW_CATEGORIES}
|
categories={FINAL_REVIEW_CATEGORIES}
|
||||||
className="w-full rounded-[12px] p-4"
|
className="w-full rounded-[12px] p-4"
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
|
|||||||
+4
-191
@@ -1,193 +1,6 @@
|
|||||||
"use client";
|
import type { ReactNode } from "react";
|
||||||
|
import CreateFlowLayoutGate from "./CreateFlowLayoutGate";
|
||||||
|
|
||||||
import { Suspense, useEffect, useState, type ReactNode } from "react";
|
export default function CreateFlowLayout({ children }: { children: ReactNode }) {
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>;
|
||||||
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>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-18
@@ -83,6 +83,21 @@ export async function fetchDraftFromServer(): Promise<CreateFlowState | null> {
|
|||||||
const DRAFT_SAVE_NETWORK_ERROR =
|
const DRAFT_SAVE_NETWORK_ERROR =
|
||||||
"Something went wrong. Check your connection and try again.";
|
"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 =
|
export type SaveDraftResult =
|
||||||
| { ok: true }
|
| { ok: true }
|
||||||
| { ok: false; message: string; status?: number };
|
| { ok: false; message: string; status?: number };
|
||||||
@@ -131,23 +146,44 @@ export async function publishRule(input: {
|
|||||||
title: string;
|
title: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
document: Record<string, unknown>;
|
document: Record<string, unknown>;
|
||||||
}): Promise<{ ok: true; id: string; title: string } | { error: string }> {
|
}): Promise<
|
||||||
const res = await fetch("/api/rules", {
|
| { ok: true; id: string; title: string }
|
||||||
method: "POST",
|
| { ok: false; error: string; status?: number }
|
||||||
credentials: "include",
|
> {
|
||||||
headers: jsonHeaders,
|
try {
|
||||||
body: JSON.stringify({
|
const res = await fetch("/api/rules", {
|
||||||
title: input.title,
|
method: "POST",
|
||||||
summary: input.summary,
|
credentials: "include",
|
||||||
document: input.document,
|
headers: jsonHeaders,
|
||||||
}),
|
body: JSON.stringify({
|
||||||
});
|
title: input.title,
|
||||||
const data = await parseJson<{
|
summary: input.summary,
|
||||||
error?: string;
|
document: input.document,
|
||||||
rule?: { id: string; title: string };
|
}),
|
||||||
}>(res);
|
});
|
||||||
if (!res.ok || !data.rule) {
|
const data = (await safeParseJsonResponse(res)) as {
|
||||||
return { error: readApiErrorMessage(data) };
|
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 };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"finalizeBannerTitle": "Couldn't publish",
|
||||||
|
"missingCommunityName": "Add a community name before finalizing.",
|
||||||
|
"finalizeButtonPublishing": "Publishing…",
|
||||||
|
"genericPublishFailed": "Something went wrong. Try again."
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import metadata from "./metadata.json";
|
|||||||
import communication from "./create/communication.json";
|
import communication from "./create/communication.json";
|
||||||
import createTopNav from "./create/topNav.json";
|
import createTopNav from "./create/topNav.json";
|
||||||
import createDraftHydration from "./create/draftHydration.json";
|
import createDraftHydration from "./create/draftHydration.json";
|
||||||
|
import createPublish from "./create/publish.json";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
common,
|
common,
|
||||||
@@ -45,6 +46,7 @@ export default {
|
|||||||
communication,
|
communication,
|
||||||
topNav: createTopNav,
|
topNav: createTopNav,
|
||||||
draftHydration: createDraftHydration,
|
draftHydration: createDraftHydration,
|
||||||
|
publish: createPublish,
|
||||||
},
|
},
|
||||||
navigation,
|
navigation,
|
||||||
metadata,
|
metadata,
|
||||||
|
|||||||
@@ -1,7 +1,31 @@
|
|||||||
|
import { useLayoutEffect } from "react";
|
||||||
import { describe, it, expect } from "vitest";
|
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 "@testing-library/jest-dom/vitest";
|
||||||
import FinalReviewPage from "../../app/create/final-review/page";
|
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", () => {
|
describe("FinalReviewPage", () => {
|
||||||
it("renders without crashing", () => {
|
it("renders without crashing", () => {
|
||||||
@@ -27,17 +51,27 @@ describe("FinalReviewPage", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders RuleCard with title", () => {
|
it("renders RuleCard with fallback title when context has no name", () => {
|
||||||
render(<FinalReviewPage />);
|
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 />);
|
render(<FinalReviewPage />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.getByText(new RegExp(FALLBACK_CARD_DESCRIPTION_SNIPPET, "i")),
|
||||||
/Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness./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();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,7 +80,7 @@ describe("FinalReviewPage", () => {
|
|||||||
const buttons = screen.getAllByRole("button");
|
const buttons = screen.getAllByRole("button");
|
||||||
expect(buttons.length).toBeGreaterThanOrEqual(1);
|
expect(buttons.length).toBeGreaterThanOrEqual(1);
|
||||||
expect(
|
expect(
|
||||||
buttons.some((el) => el.textContent?.includes("Mutual Aid Mondays")),
|
buttons.some((el) => el.textContent?.includes(FALLBACK_CARD_TITLE)),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user