diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6137ad..909ba75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 the create flow saves drafts to the server when a user is logged in. +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 diff --git a/app/components/modals/Login/Login.container.tsx b/app/components/modals/Login/Login.container.tsx index 9b477c0..184ed73 100644 --- a/app/components/modals/Login/Login.container.tsx +++ b/app/components/modals/Login/Login.container.tsx @@ -14,6 +14,7 @@ const LoginContainer = memo( ariaLabel, ariaLabelledBy, usePortal = true, + backdropVariant = "blurredYellow", }) => { const dialogRef = useRef(null); const backdropRef = useRef(null); @@ -126,6 +127,7 @@ const LoginContainer = memo( backdropRef={backdropRef} portalReady={portalReady} usePortal={usePortal} + backdropVariant={backdropVariant} > {children} diff --git a/app/components/modals/Login/Login.types.ts b/app/components/modals/Login/Login.types.ts index dad84b5..784ba64 100644 --- a/app/components/modals/Login/Login.types.ts +++ b/app/components/modals/Login/Login.types.ts @@ -1,3 +1,5 @@ +export type LoginBackdropVariant = "solid" | "blurredYellow"; + export interface LoginProps { isOpen: boolean; onClose: () => void; @@ -13,6 +15,8 @@ export interface LoginProps { * without waiting for a portal gate (more reliable across engines). */ usePortal?: boolean; + /** `solid` = full-page marketing yellow; `blurredYellow` = blur + translucent yellow over underlying UI */ + backdropVariant?: LoginBackdropVariant; } export interface LoginViewProps { @@ -28,4 +32,5 @@ export interface LoginViewProps { /** False until client mount — avoids SSR/client HTML mismatch for createPortal. */ portalReady: boolean; usePortal: boolean; + backdropVariant: LoginBackdropVariant; } diff --git a/app/components/modals/Login/Login.view.tsx b/app/components/modals/Login/Login.view.tsx index d8a38d3..934eec7 100644 --- a/app/components/modals/Login/Login.view.tsx +++ b/app/components/modals/Login/Login.view.tsx @@ -2,7 +2,13 @@ import { createPortal } from "react-dom"; import ModalHeader from "../../utility/ModalHeader"; -import type { LoginViewProps } from "./Login.types"; +import type { LoginBackdropVariant, LoginViewProps } from "./Login.types"; + +const backdropClasses: Record = { + solid: "bg-[var(--color-surface-inverse-brand-primary)]", + blurredYellow: + "bg-[var(--color-surface-inverse-brand-primary)]/85 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--color-surface-inverse-brand-primary)]/75", +}; export function LoginView({ isOpen, @@ -16,6 +22,7 @@ export function LoginView({ backdropRef, portalReady, usePortal, + backdropVariant, }: LoginViewProps) { if (!isOpen) return null; if (usePortal && !portalReady) return null; @@ -23,7 +30,7 @@ export function LoginView({ const content = (
diff --git a/app/components/modals/Login/LoginForm.tsx b/app/components/modals/Login/LoginForm.tsx index 4557ab0..250c437 100644 --- a/app/components/modals/Login/LoginForm.tsx +++ b/app/components/modals/Login/LoginForm.tsx @@ -9,6 +9,7 @@ import TextInput from "../../controls/TextInput"; import ContentLockup from "../../type/ContentLockup"; import { requestMagicLink } from "../../../../lib/create/api"; import { safeInternalPath } from "../../../../lib/safeInternalPath"; +import { setTransferPendingFlag } from "../../../create/anonymousDraftStorage"; /** Mail icon for login modal (inline SVG; same pattern as InfoMessageBox ExclamationIconInline). */ function MailIconInline() { @@ -37,7 +38,18 @@ function MailIconInline() { const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -export default function LoginForm() { +export type LoginFormVariant = "default" | "saveProgress"; + +export type LoginFormProps = { + variant?: LoginFormVariant; + /** Overrides URL `next` for `requestMagicLink` (e.g. create-flow exit modal). */ + magicLinkNextPath?: string; +}; + +export default function LoginForm({ + variant = "default", + magicLinkNextPath, +}: LoginFormProps) { const t = useTranslation("pages.login"); const tFooter = useTranslation("footer"); const router = useRouter(); @@ -55,6 +67,8 @@ export default function LoginForm() { const nextParam = searchParams.get("next"); const errorParam = searchParams.get("error"); + const isSaveProgress = variant === "saveProgress"; + /** Drop `error` from the URL so URL-driven messages don’t linger after a new attempt. */ const stripErrorQuery = useCallback(() => { if (!searchParams.get("error")) return; @@ -75,7 +89,8 @@ export default function LoginForm() { } setSubmitting(true); try { - const nextPath = safeInternalPath(nextParam); + const rawNext = magicLinkNextPath ?? nextParam; + const nextPath = safeInternalPath(rawNext); const result = await requestMagicLink(trimmed, nextPath); if (result.ok === false) { if (result.retryAfterMs != null && result.retryAfterMs > 0) { @@ -88,6 +103,9 @@ export default function LoginForm() { } return; } + if (isSaveProgress || nextPath.includes("syncDraft=1")) { + setTransferPendingFlag(); + } setEmail(trimmed); setSent(true); } catch { @@ -95,7 +113,14 @@ export default function LoginForm() { } finally { setSubmitting(false); } - }, [email, nextParam, stripErrorQuery, t]); + }, [ + email, + isSaveProgress, + magicLinkNextPath, + nextParam, + stripErrorQuery, + t, + ]); const urlErrorMessage = errorParam === "expired_link" @@ -106,16 +131,36 @@ export default function LoginForm() { : t("errors.invalidLink") : ""; + const titleId = "login-modal-heading"; + return (
-
+
diff --git a/app/components/navigation/ConditionalFooter.tsx b/app/components/navigation/ConditionalFooter.tsx index 570bcf4..4347e46 100644 --- a/app/components/navigation/ConditionalFooter.tsx +++ b/app/components/navigation/ConditionalFooter.tsx @@ -14,7 +14,7 @@ const Footer = dynamic(() => import("./Footer"), { /** * Conditionally renders Footer based on pathname. - * Hides footer for /create/* and /login (full-screen flows; login uses a body portal). + * Hides footer for /create/* and /login (full-screen flows; no site chrome). */ const ConditionalFooter = memo(() => { const pathname = usePathname(); diff --git a/app/components/navigation/MenuBarItem/MenuBarItem.container.tsx b/app/components/navigation/MenuBarItem/MenuBarItem.container.tsx index 7e87897..c2d6be7 100644 --- a/app/components/navigation/MenuBarItem/MenuBarItem.container.tsx +++ b/app/components/navigation/MenuBarItem/MenuBarItem.container.tsx @@ -12,6 +12,7 @@ import { const MenuBarItemContainer = memo( ({ href = "#", + buttonOnClick, children, state: stateProp, mode: modeProp, @@ -112,6 +113,7 @@ const MenuBarItemContainer = memo( return ( { href?: string; + /** When set, renders a ` + ); + } + return ( {children} diff --git a/app/components/navigation/TopNav/TopNav.container.tsx b/app/components/navigation/TopNav/TopNav.container.tsx index 31ec397..b2f7379 100644 --- a/app/components/navigation/TopNav/TopNav.container.tsx +++ b/app/components/navigation/TopNav/TopNav.container.tsx @@ -2,6 +2,7 @@ import { memo } from "react"; import { usePathname, useRouter } from "next/navigation"; +import { useAuthModal } from "../../../contexts/AuthModalContext"; import { useTranslation } from "../../../contexts/MessagesContext"; import MenuBarItem from "../MenuBarItem"; import Button from "../../buttons/Button"; @@ -21,6 +22,7 @@ const TopNavContainer = memo( ({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => { const pathname = usePathname(); const router = useRouter(); + const { openLogin } = useAuthModal(); const t = useTranslation("header"); // Schema markup for site navigation @@ -139,7 +141,6 @@ const TopNavContainer = memo( const isSmallBreakpoint = size === "xsmall" || size === "home"; const mode = folderTop && isSmallBreakpoint ? "inverse" : "default"; - const href = loggedIn ? "/profile" : "/login"; const label = loggedIn ? t("buttons.profile") : t("buttons.logIn"); const ariaLabel = loggedIn ? t("ariaLabels.goToProfile") @@ -148,9 +149,30 @@ const TopNavContainer = memo( (loggedIn && pathname === "/profile") || (!loggedIn && pathname === "/login"); + if (loggedIn) { + return ( + + {label} + + ); + } + return ( + openLogin({ + variant: "default", + backdropVariant: "blurredYellow", + nextPath: pathname || "/", + }) + } + href="/login" size={sizeMap[size] || "Small"} mode={mode} state={navSelected ? "selected" : "default"} diff --git a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.container.tsx b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.container.tsx index d732394..6e63804 100644 --- a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.container.tsx +++ b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.container.tsx @@ -10,7 +10,7 @@ const CreateFlowTopNavContainer = memo( hasShare = false, hasExport = false, hasEdit = false, - loggedIn = false, + saveDraftOnExit = false, onShare, onExport, onEdit, @@ -34,7 +34,7 @@ const CreateFlowTopNavContainer = memo( hasShare={hasShare} hasExport={hasExport} hasEdit={hasEdit} - loggedIn={loggedIn} + saveDraftOnExit={saveDraftOnExit} onShare={onShare} onExport={onExport} onEdit={onEdit} diff --git a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.types.ts b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.types.ts index 69f9afc..7cb0f96 100644 --- a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.types.ts +++ b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.types.ts @@ -22,10 +22,11 @@ export interface CreateFlowTopNavProps { */ hasEdit?: boolean; /** - * Whether the user is logged in + * When true, exit control is "Save & Exit" and `onExit` receives `{ saveDraft: true }`. + * When false, shows "Exit" and `{ saveDraft: false }` (caller may confirm data loss). * @default false */ - loggedIn?: boolean; + saveDraftOnExit?: boolean; /** * Callback when Share button is clicked */ @@ -40,7 +41,7 @@ export interface CreateFlowTopNavProps { onEdit?: () => void; /** * Callback when Exit/Save & Exit button is clicked. - * When user is logged in, called with { saveDraft: true } to stub "Save & Exit". + * When `saveDraftOnExit` is true, called with `{ saveDraft: true }`. */ onExit?: (options?: { saveDraft?: boolean }) => void; /** diff --git a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx index fd8cbb2..bf2420d 100644 --- a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx +++ b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx @@ -1,12 +1,18 @@ +"use client"; + import Logo from "../../asset/logo"; import Button from "../../buttons/Button"; +import { useTranslation } from "../../../contexts/MessagesContext"; import type { CreateFlowTopNavProps } from "./CreateFlowTopNav.types"; +const exitButtonFigmaClass = + "!rounded-[var(--radius-measures-radius-full,9999px)] !border-[1.25px] !px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!text-[12px] md:!leading-[14px]"; + export function CreateFlowTopNavView({ hasShare = false, hasExport = false, hasEdit = false, - loggedIn = false, + saveDraftOnExit = false, onShare, onExport, onEdit, @@ -14,7 +20,8 @@ export function CreateFlowTopNavView({ buttonPalette = "default", className = "", }: CreateFlowTopNavProps) { - const exitButtonText = loggedIn ? "Save & Exit" : "Exit"; + const t = useTranslation("create.topNav"); + const exitButtonText = saveDraftOnExit ? t("saveAndExit") : t("exit"); return (
- {/* Logo - Left */} - {/* Button Group - Right */} -
+
{hasShare && ( diff --git a/app/contexts/AuthModalContext.tsx b/app/contexts/AuthModalContext.tsx new file mode 100644 index 0000000..21eb7aa --- /dev/null +++ b/app/contexts/AuthModalContext.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; +import Link from "next/link"; +import Login from "../components/modals/Login"; +import LoginForm from "../components/modals/Login/LoginForm"; +import { useTranslation } from "./MessagesContext"; + +export type AuthModalLoginVariant = "default" | "saveProgress"; + +export type AuthModalBackdropVariant = "solid" | "blurredYellow"; + +export type OpenLoginOptions = { + variant?: AuthModalLoginVariant; + /** Passed to `requestMagicLink` as `next` (internal path). */ + nextPath?: string; + backdropVariant?: AuthModalBackdropVariant; +}; + +type AuthModalContextValue = { + openLogin: (_opts?: OpenLoginOptions) => void; + closeLogin: () => void; +}; + +const AuthModalContext = createContext(null); + +export function AuthModalProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const [opts, setOpts] = useState({}); + const t = useTranslation("pages.login"); + + const openLogin = useCallback((o?: OpenLoginOptions) => { + setOpts(o ?? {}); + setOpen(true); + }, []); + + const closeLogin = useCallback(() => { + setOpen(false); + setOpts({}); + }, []); + + const value = useMemo( + () => ({ openLogin, closeLogin }), + [openLogin, closeLogin], + ); + + const backdropVariant = opts.backdropVariant ?? "blurredYellow"; + + return ( + + {children} + closeLogin()} + > + {t("backToHome")} + + } + > + + + + ); +} + +export function useAuthModal(): AuthModalContextValue { + const ctx = useContext(AuthModalContext); + if (!ctx) { + throw new Error("useAuthModal must be used within AuthModalProvider"); + } + return ctx; +} diff --git a/app/create/CreateFlowLayoutClient.tsx b/app/create/CreateFlowLayoutClient.tsx new file mode 100644 index 0000000..290c433 --- /dev/null +++ b/app/create/CreateFlowLayoutClient.tsx @@ -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 ( + + + + {children} + + + + ); +} + +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 ( +
+ {hasErrorOverlays ? ( +
+ {draftSaveBannerMessage ? ( +
+ setDraftSaveBannerMessage(null)} + className="w-full" + /> +
+ ) : null} + {publishBannerMessage ? ( +
+ setPublishBannerMessage(null)} + className="w-full" + /> +
+ ) : null} +
+ ) : null} + + + + + + + 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()} + /> +
+ {children} +
+ {!isCompletedStep && ( + { + if (currentStep === "final-review") { + void handleFinalize(); + } else { + goToNextStep(); + } + }} + > + {currentStep === "final-review" + ? isPublishing + ? messages.create.publish.finalizeButtonPublishing + : "Finalize CommunityRule" + : currentStep === "confirm-stakeholders" + ? "Confirm Stakeholders" + : "Next"} + + ) : null + } + onBackClick={previousStep ? goToPreviousStep : undefined} + /> + )} +
+ ); +} + +export default function CreateFlowLayoutClient({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} diff --git a/app/create/CreateFlowLayoutGate.tsx b/app/create/CreateFlowLayoutGate.tsx new file mode 100644 index 0000000..8441c27 --- /dev/null +++ b/app/create/CreateFlowLayoutGate.tsx @@ -0,0 +1,26 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { ReactNode } from "react"; + +const CreateFlowLayoutClient = dynamic( + () => import("./CreateFlowLayoutClient"), + { + ssr: false, + loading: () => ( +
+ ), + }, +); + +export default function CreateFlowLayoutGate({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} diff --git a/app/create/PostLoginDraftTransfer.tsx b/app/create/PostLoginDraftTransfer.tsx new file mode 100644 index 0000000..154346a --- /dev/null +++ b/app/create/PostLoginDraftTransfer.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { + clearAnonymousCreateFlowStorage, + hasTransferPendingFlag, + readAnonymousCreateFlowState, +} from "./anonymousDraftStorage"; +import { useCreateFlow } from "./context/CreateFlowContext"; +import { isValidStep } from "./utils/flowSteps"; +import { saveDraftToServer } from "../../lib/create/api"; +import messages from "../../messages/en/index"; + +const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true"; + +/** + * After magic-link verify, redirects to `/create/...?syncDraft=1` with session cookie. + * 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, +}: { + sessionUser: { id: string; email: string } | null | undefined; +}) { + const { replaceState } = useCreateFlow(); + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const syncDraft = searchParams.get("syncDraft"); + const [transferError, setTransferError] = useState(null); + const attemptedRef = useRef(false); + + useEffect(() => { + if (sessionUser == null || sessionUser === undefined) return; + const wantsTransfer = syncDraft === "1" || hasTransferPendingFlag(); + if (!wantsTransfer) return; + if (attemptedRef.current) return; + + if (!SYNC_ENABLED) { + attemptedRef.current = true; + 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; + + 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 } : {}), + }; + + const saveResult = await saveDraftToServer(payload); + if (cancelled) return; + + if (saveResult.ok === false) { + setTransferError( + messages.create.topNav.postLoginSaveFailedWithReason.replace( + "{reason}", + saveResult.message, + ), + ); + attemptedRef.current = false; + return; + } + + clearAnonymousCreateFlowStorage(); + replaceState(payload); + + if (pathname) { + const params = new URLSearchParams(searchParams.toString()); + params.delete("syncDraft"); + const q = params.toString(); + router.replace(q ? `${pathname}?${q}` : pathname); + } + })(); + + return () => { + cancelled = true; + }; + }, [sessionUser, pathname, syncDraft, replaceState, router, searchParams]); + + if (!transferError) return null; + + return ( +
+ {transferError} +
+ ); +} diff --git a/app/create/SignedInDraftHydration.tsx b/app/create/SignedInDraftHydration.tsx new file mode 100644 index 0000000..229fb30 --- /dev/null +++ b/app/create/SignedInDraftHydration.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import type { CreateFlowState } from "./types"; +import { createFlowStateHasKeys } from "../../lib/create/draftHydrationUtils"; +import { + clearAnonymousCreateFlowStorage, + hasTransferPendingFlag, + readAnonymousCreateFlowState, +} from "./anonymousDraftStorage"; +import { useCreateFlow } from "./context/CreateFlowContext"; +import { fetchDraftFromServer } from "../../lib/create/api"; +import messages from "../../messages/en/index"; + +const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true"; + +/** + * When sync is on and the user is signed in, fetch `GET /api/drafts/me` once and merge into context. + * Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer} owns that path. + * + * **Conflict:** If both server draft and `create-flow-anonymous` are non-empty, `window.confirm` + * chooses account draft (OK) vs browser copy (Cancel); browser storage is cleared after resolution. + */ +export function SignedInDraftHydration({ + sessionUser, + sessionResolved, +}: { + sessionUser: { id: string; email: string } | null | undefined; + sessionResolved: boolean; +}) { + const searchParams = useSearchParams(); + const syncDraftParam = searchParams.get("syncDraft"); + const { replaceState, interactionTouched } = useCreateFlow(); + const touchedRef = useRef(interactionTouched); + touchedRef.current = interactionTouched; + + const [loadingHydration, setLoadingHydration] = useState(false); + const finishedUserIdRef = useRef(null); + + useEffect(() => { + if (!SYNC_ENABLED) return; + if (!sessionResolved) return; + if (sessionUser == null || sessionUser === undefined) { + finishedUserIdRef.current = null; + return; + } + + const userId = sessionUser.id; + if (finishedUserIdRef.current === userId) return; + + if (syncDraftParam === "1" || hasTransferPendingFlag()) { + finishedUserIdRef.current = userId; + return; + } + + let cancelled = false; + setLoadingHydration(true); + + void (async () => { + try { + const serverDraft = await fetchDraftFromServer(); + if (cancelled) return; + + const localDraft = readAnonymousCreateFlowState(); + const hasServer = + serverDraft != null && createFlowStateHasKeys(serverDraft); + const hasLocal = createFlowStateHasKeys(localDraft); + + if (touchedRef.current) { + finishedUserIdRef.current = userId; + return; + } + + if (hasServer && hasLocal) { + const useAccount = + typeof window !== "undefined" && + window.confirm(messages.create.draftHydration.conflictPrompt); + if (cancelled) return; + if (useAccount) { + replaceState(serverDraft as CreateFlowState); + } else { + replaceState(localDraft); + } + clearAnonymousCreateFlowStorage(); + finishedUserIdRef.current = userId; + return; + } + + if (hasServer) { + replaceState(serverDraft as CreateFlowState); + clearAnonymousCreateFlowStorage(); + finishedUserIdRef.current = userId; + return; + } + + if (hasLocal) { + replaceState(localDraft); + clearAnonymousCreateFlowStorage(); + } + + finishedUserIdRef.current = userId; + } finally { + if (!cancelled) setLoadingHydration(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [sessionResolved, sessionUser, syncDraftParam, replaceState]); + + if (!loadingHydration) return null; + + return ( +
+ {messages.create.draftHydration.loadingSavedProgress} +
+ ); +} diff --git a/app/create/anonymousDraftStorage.ts b/app/create/anonymousDraftStorage.ts new file mode 100644 index 0000000..9026bbb --- /dev/null +++ b/app/create/anonymousDraftStorage.ts @@ -0,0 +1,96 @@ +import type { CreateFlowState } from "./types"; + +/** Anonymous in-progress create flow (local only until magic-link transfer). */ +export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const; + +/** + * Set when the user submits magic link from “Save your progress?” so after verify we PUT to server. + * Value is arbitrary truthy string; cleared after successful transfer or abandon. + */ +export const CREATE_FLOW_TRANSFER_PENDING_KEY = + "create-flow-transfer-pending" as const; + +/** + * When signed-in + sync, {@link SignedInDraftHydration} resolves server vs this key via `window.confirm` + * if both are non-empty; see `messages/en/create/draftHydration.json`. + */ + +const LEGACY_LIVE_KEY = "create-flow-state"; +const LEGACY_DRAFT_KEY = "create-flow-draft"; + +export function readAnonymousCreateFlowState(): CreateFlowState { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as CreateFlowState; + return typeof parsed === "object" && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +export function writeAnonymousCreateFlowState(value: CreateFlowState): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + CREATE_FLOW_ANONYMOUS_KEY, + JSON.stringify(value), + ); + } catch { + // quota / private mode + } +} + +export function clearAnonymousCreateFlowStorage(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(CREATE_FLOW_ANONYMOUS_KEY); + window.localStorage.removeItem(CREATE_FLOW_TRANSFER_PENDING_KEY); + } catch { + // ignore + } +} + +export function setTransferPendingFlag(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(CREATE_FLOW_TRANSFER_PENDING_KEY, "1"); + } catch { + // ignore + } +} + +export function hasTransferPendingFlag(): boolean { + if (typeof window === "undefined") return false; + try { + return Boolean( + window.localStorage.getItem(CREATE_FLOW_TRANSFER_PENDING_KEY), + ); + } catch { + return false; + } +} + +export function clearTransferPendingFlag(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(CREATE_FLOW_TRANSFER_PENDING_KEY); + } catch { + // ignore + } +} + +/** One-time cleanup of pre–anonymous-draft keys. */ +export function clearLegacyCreateFlowKeysOnce(): void { + if (typeof window === "undefined") return; + try { + const done = window.sessionStorage.getItem("create-flow-legacy-cleared"); + if (done) return; + window.localStorage.removeItem(LEGACY_LIVE_KEY); + window.localStorage.removeItem(LEGACY_DRAFT_KEY); + window.sessionStorage.setItem("create-flow-legacy-cleared", "1"); + } catch { + // ignore + } +} diff --git a/app/create/cards/page.tsx b/app/create/cards/page.tsx index 184ee80..ef87873 100644 --- a/app/create/cards/page.tsx +++ b/app/create/cards/page.tsx @@ -2,6 +2,7 @@ import { useState, useCallback } from "react"; import HeaderLockup from "../../components/type/HeaderLockup"; +import { useCreateFlow } from "../context/CreateFlowContext"; import CardStack from "../../components/utility/CardStack"; import Create from "../../components/modals/Create"; import TextArea from "../../components/controls/TextArea"; @@ -130,6 +131,7 @@ function AddPlatformModalContent({ }: { platformCardId: string; }) { + const { markCreateFlowInteraction } = useCreateFlow(); const defaults = ADD_PLATFORM_SECTION_DEFAULTS[platformCardId]; const [sectionValues, setSectionValues] = useState< Record @@ -141,9 +143,13 @@ function AddPlatformModalContent({ }, ); - const updateSection = useCallback((key: SectionKey, value: string) => { - setSectionValues((prev) => ({ ...prev, [key]: value })); - }, []); + const updateSection = useCallback( + (key: SectionKey, value: string) => { + markCreateFlowInteraction(); + setSectionValues((prev) => ({ ...prev, [key]: value })); + }, + [markCreateFlowInteraction], + ); if (!defaults) return null; @@ -230,6 +236,7 @@ function getCreateModalConfig(pendingCardId: string | null) { /** Create flow card stack step: compact grid with optional expand to full list. */ export default function CardsPage() { + const { markCreateFlowInteraction } = useCreateFlow(); const [expanded, setExpanded] = useState(false); const [selectedIds, setSelectedIds] = useState([]); const [createModalOpen, setCreateModalOpen] = useState(false); @@ -239,10 +246,14 @@ export default function CardsPage() { const description = expanded ? EXPANDED_DESCRIPTION : COMPACT_DESCRIPTION; const modalConfig = getCreateModalConfig(pendingCardId); - const handleCardClick = useCallback((id: string) => { - setPendingCardId(id); - setCreateModalOpen(true); - }, []); + const handleCardClick = useCallback( + (id: string) => { + markCreateFlowInteraction(); + setPendingCardId(id); + setCreateModalOpen(true); + }, + [markCreateFlowInteraction], + ); const handleCreateModalClose = useCallback(() => { setCreateModalOpen(false); @@ -250,6 +261,7 @@ export default function CardsPage() { }, []); const handleCreateModalConfirm = useCallback(() => { + markCreateFlowInteraction(); if (pendingCardId) { setSelectedIds((prev) => prev.includes(pendingCardId) ? prev : [...prev, pendingCardId], @@ -257,7 +269,7 @@ export default function CardsPage() { } setCreateModalOpen(false); setPendingCardId(null); - }, [pendingCardId]); + }, [markCreateFlowInteraction, pendingCardId]); return (
@@ -276,7 +288,10 @@ export default function CardsPage() { selectedIds={selectedIds} onCardSelect={handleCardClick} expanded={expanded} - onToggleExpand={() => setExpanded((prev) => !prev)} + onToggleExpand={() => { + markCreateFlowInteraction(); + setExpanded((prev) => !prev); + }} hasMore={true} />
diff --git a/app/create/completed/page.tsx b/app/create/completed/page.tsx index 80cc5ba..5b86299 100644 --- a/app/create/completed/page.tsx +++ b/app/create/completed/page.tsx @@ -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(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 */}
@@ -159,14 +180,14 @@ export default function CompletedPage() {
diff --git a/app/create/confirm-stakeholders/page.tsx b/app/create/confirm-stakeholders/page.tsx index 23785f6..92eb7e7 100644 --- a/app/create/confirm-stakeholders/page.tsx +++ b/app/create/confirm-stakeholders/page.tsx @@ -6,6 +6,7 @@ import HeaderLockup from "../../components/type/HeaderLockup"; import MultiSelect from "../../components/controls/MultiSelect"; import Alert from "../../components/modals/Alert"; import type { ChipOption } from "../../components/controls/MultiSelect/MultiSelect.types"; +import { useCreateFlow } from "../context/CreateFlowContext"; const TITLE = "Do other stakeholders need to be involved in creating your community?"; @@ -20,6 +21,7 @@ const DRAFT_TOAST_TITLE = "Congratulations! You've drafted your CommunityRule!"; * Figma: 21104-46594. */ export default function ConfirmStakeholdersPage() { + const { markCreateFlowInteraction } = useCreateFlow(); const [isMounted, setIsMounted] = useState(false); const [toastDismissed, setToastDismissed] = useState(false); const [stakeholderOptions, setStakeholderOptions] = useState( @@ -35,6 +37,7 @@ export default function ConfirmStakeholdersPage() { const effectiveMdOrLarger = !isMounted || isMdOrLarger; const handleAddStakeholder = () => { + markCreateFlowInteraction(); setStakeholderOptions((prev) => [ ...prev, { id: crypto.randomUUID(), label: "", state: "Custom" }, @@ -42,6 +45,7 @@ export default function ConfirmStakeholdersPage() { }; const handleCustomChipConfirm = (chipId: string, value: string) => { + markCreateFlowInteraction(); setStakeholderOptions((prev) => prev.map((opt) => opt.id === chipId ? { ...opt, label: value, state: "Selected" } : opt, @@ -50,10 +54,12 @@ export default function ConfirmStakeholdersPage() { }; const handleCustomChipClose = (chipId: string) => { + markCreateFlowInteraction(); setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId)); }; const handleChipClick = (chipId: string) => { + markCreateFlowInteraction(); setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId)); }; diff --git a/app/create/context/CreateFlowBackendSync.tsx b/app/create/context/CreateFlowBackendSync.tsx deleted file mode 100644 index 2988048..0000000 --- a/app/create/context/CreateFlowBackendSync.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { - fetchAuthSession, - fetchDraftFromServer, - saveDraftToServer, -} from "../../../lib/create/api"; -import { useCreateFlow } from "./CreateFlowContext"; - -const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true"; - -const DEBOUNCE_MS = 1000; - -/** - * When NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true, loads the signed-in user's draft - * from the server and debounces saves. Anonymous users keep localStorage-only behavior. - */ -export function CreateFlowBackendSync() { - const { state, replaceState } = useCreateFlow(); - const [hydrated, setHydrated] = useState(!SYNC_ENABLED); - const saveTimer = useRef | null>(null); - - useEffect(() => { - if (!SYNC_ENABLED) return; - - let cancelled = false; - - (async () => { - try { - const { user } = await fetchAuthSession(); - if (cancelled || !user) { - setHydrated(true); - return; - } - const serverDraft = await fetchDraftFromServer(); - if (cancelled) return; - if (serverDraft && Object.keys(serverDraft).length > 0) { - replaceState(serverDraft); - } - } finally { - if (!cancelled) setHydrated(true); - } - })(); - - return () => { - cancelled = true; - }; - }, [replaceState]); - - useEffect(() => { - if (!SYNC_ENABLED || !hydrated) return; - - if (saveTimer.current) clearTimeout(saveTimer.current); - - saveTimer.current = setTimeout(() => { - saveTimer.current = null; - void (async () => { - const { user } = await fetchAuthSession(); - if (!user) return; - await saveDraftToServer(state); - })(); - }, DEBOUNCE_MS); - - return () => { - if (saveTimer.current) clearTimeout(saveTimer.current); - }; - }, [state, hydrated]); - - return null; -} diff --git a/app/create/context/CreateFlowContext.tsx b/app/create/context/CreateFlowContext.tsx index aa836c8..0a0c5ce 100644 --- a/app/create/context/CreateFlowContext.tsx +++ b/app/create/context/CreateFlowContext.tsx @@ -5,6 +5,7 @@ import { useCallback, useContext, useEffect, + useRef, useState, type ReactNode, } from "react"; @@ -13,64 +14,67 @@ import type { CreateFlowContextValue, CreateFlowStep, } from "../types"; +import { + clearAnonymousCreateFlowStorage, + clearLegacyCreateFlowKeysOnce, + readAnonymousCreateFlowState, + writeAnonymousCreateFlowState, +} from "../anonymousDraftStorage"; const CreateFlowContext = createContext(null); -const STORAGE_KEY = "create-flow-state"; -const DRAFT_STORAGE_KEY = "create-flow-draft"; - -function readStateFromStorage(key: string): CreateFlowState { - if (typeof window === "undefined") return {}; - try { - const raw = window.localStorage.getItem(key); - if (!raw) return {}; - const parsed = JSON.parse(raw) as CreateFlowState; - return typeof parsed === "object" && parsed !== null ? parsed : {}; - } catch { - return {}; - } -} - -function writeStateToStorage(key: string, value: CreateFlowState): void { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(key, JSON.stringify(value)); - } catch { - // Ignore storage errors (e.g. quota, private mode) - } -} - -function removeFromStorage(key: string): void { - if (typeof window === "undefined") return; - try { - window.localStorage.removeItem(key); - } catch { - // Ignore - } -} - interface CreateFlowProviderProps { children: ReactNode; initialStep?: CreateFlowStep | null; + /** + * When true (signed-out, session resolved), load/sync `create-flow-anonymous` in localStorage. + * When false, in-memory only (authenticated fresh create). + */ + enableAnonymousPersistence?: boolean; } /** - * Provider component for Create Flow state management - * - * Manages flow state with optional localStorage persistence and draft support. + * Create flow state. Anonymous users mirror state to localStorage; authenticated users stay in memory. */ export function CreateFlowProvider({ children, initialStep = null, + enableAnonymousPersistence = false, }: CreateFlowProviderProps) { const [state, setState] = useState(() => - readStateFromStorage(STORAGE_KEY), + enableAnonymousPersistence ? readAnonymousCreateFlowState() : {}, ); + const [interactionTouched, setInteractionTouched] = useState(false); const [currentStep] = useState(initialStep); + const prevPersistRef = useRef(enableAnonymousPersistence); useEffect(() => { - writeStateToStorage(STORAGE_KEY, state); - }, [state]); + clearLegacyCreateFlowKeysOnce(); + }, []); + + // Session resolved as guest after initial paint: hydrate from localStorage if still empty. + useEffect(() => { + if (!enableAnonymousPersistence) { + prevPersistRef.current = false; + return; + } + const wasOff = !prevPersistRef.current; + prevPersistRef.current = true; + if (!wasOff) return; + const from = readAnonymousCreateFlowState(); + if (Object.keys(from).length === 0) return; + // eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate anonymous draft when guest persistence turns on + setState((prev) => (Object.keys(prev).length > 0 ? prev : { ...from })); + }, [enableAnonymousPersistence]); + + useEffect(() => { + if (!enableAnonymousPersistence) return; + writeAnonymousCreateFlowState(state); + }, [state, enableAnonymousPersistence]); + + const markCreateFlowInteraction = useCallback(() => { + setInteractionTouched(true); + }, []); const updateState = useCallback((updates: Partial) => { setState((prevState) => ({ @@ -81,13 +85,12 @@ export function CreateFlowProvider({ const replaceState = useCallback((next: CreateFlowState) => { setState(next); - writeStateToStorage(STORAGE_KEY, next); }, []); const clearState = useCallback(() => { setState({}); - removeFromStorage(STORAGE_KEY); - removeFromStorage(DRAFT_STORAGE_KEY); + setInteractionTouched(false); + clearAnonymousCreateFlowStorage(); }, []); const contextValue: CreateFlowContextValue = { @@ -96,6 +99,8 @@ export function CreateFlowProvider({ updateState, replaceState, clearState, + interactionTouched, + markCreateFlowInteraction, }; return ( @@ -105,22 +110,6 @@ export function CreateFlowProvider({ ); } -/** Save current state as draft (e.g. on "Save & Exit"). Stub for CR-57. */ -export function saveCreateFlowDraft(state: CreateFlowState): void { - writeStateToStorage(DRAFT_STORAGE_KEY, state); -} - -/** Load draft state if present. Caller can merge into initial state when entering flow. */ -export function loadCreateFlowDraft(): CreateFlowState { - return readStateFromStorage(DRAFT_STORAGE_KEY); -} - -/** - * Hook to access Create Flow context - * - * @throws Error if used outside CreateFlowProvider - * @returns CreateFlowContextValue - */ export function useCreateFlow(): CreateFlowContextValue { const context = useContext(CreateFlowContext); if (!context) { diff --git a/app/create/context/CreateFlowDraftSaveBannerContext.tsx b/app/create/context/CreateFlowDraftSaveBannerContext.tsx new file mode 100644 index 0000000..7d41ad6 --- /dev/null +++ b/app/create/context/CreateFlowDraftSaveBannerContext.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { + createContext, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; + +type CreateFlowDraftSaveBannerContextValue = { + draftSaveBannerMessage: string | null; + setDraftSaveBannerMessage: (_message: string | null) => void; +}; + +const CreateFlowDraftSaveBannerContext = + createContext(null); + +export function CreateFlowDraftSaveBannerProvider({ + children, +}: { + children: ReactNode; +}) { + const [draftSaveBannerMessage, setDraftSaveBannerMessage] = useState< + string | null + >(null); + + const value = useMemo( + () => ({ + draftSaveBannerMessage, + setDraftSaveBannerMessage, + }), + [draftSaveBannerMessage], + ); + + return ( + + {children} + + ); +} + +export function useCreateFlowDraftSaveBanner(): CreateFlowDraftSaveBannerContextValue { + const ctx = useContext(CreateFlowDraftSaveBannerContext); + if (!ctx) { + throw new Error( + "useCreateFlowDraftSaveBanner must be used within CreateFlowDraftSaveBannerProvider", + ); + } + return ctx; +} diff --git a/app/create/final-review/page.tsx b/app/create/final-review/page.tsx index 26d5ddb..f00fe84 100644 --- a/app/create/final-review/page.tsx +++ b/app/create/final-review/page.tsx @@ -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() {
{}} @@ -107,13 +119,13 @@ export default function FinalReviewPage() { size="M" /> {}} diff --git a/app/create/hasCreateFlowUserInput.ts b/app/create/hasCreateFlowUserInput.ts new file mode 100644 index 0000000..0632665 --- /dev/null +++ b/app/create/hasCreateFlowUserInput.ts @@ -0,0 +1,27 @@ +import type { CreateFlowState } from "./types"; + +const IGNORED_KEYS = new Set(["currentStep"]); + +function valueIndicatesUserInput(value: unknown): boolean { + if (value === undefined || value === null) return false; + if (typeof value === "string") return value.trim().length > 0; + if (typeof value === "boolean") return value; + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") { + return Object.keys(value as object).length > 0; + } + return false; +} + +/** + * True once the user has entered meaningful create-flow data (not only navigation metadata). + * Used to show "Save & Exit" vs a plain "Exit" that confirms data loss. + */ +export function hasCreateFlowUserInput(state: CreateFlowState): boolean { + for (const key of Object.keys(state)) { + if (IGNORED_KEYS.has(key)) continue; + if (valueIndicatesUserInput(state[key])) return true; + } + return false; +} diff --git a/app/create/hooks/useCreateFlowExit.ts b/app/create/hooks/useCreateFlowExit.ts new file mode 100644 index 0000000..d7b5315 --- /dev/null +++ b/app/create/hooks/useCreateFlowExit.ts @@ -0,0 +1,65 @@ +"use client"; + +import { useCallback } from "react"; +import type { CreateFlowState, CreateFlowStep } from "../types"; +import { saveDraftToServer } from "../../../lib/create/api"; +import messages from "../../../messages/en/index"; + +const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true"; + +export type CreateFlowExitClearState = () => void; + +type AppRouterLike = { push: (_href: string) => void }; + +/** + * Leave the create flow for a **signed-in** user. Caller must not invoke for anonymous users. + */ +export function useCreateFlowExit({ + state, + currentStep, + clearState, + router, + user, + setDraftSaveBannerMessage, +}: { + state: CreateFlowState; + currentStep: CreateFlowStep | null; + clearState: CreateFlowExitClearState; + router: AppRouterLike; + user: { id: string; email: string } | null; + /** When save fails, surface the server message in the create shell banner (no leave confirm). */ + setDraftSaveBannerMessage?: (_message: string | null) => void; +}): (_options?: { saveDraft?: boolean }) => Promise { + return useCallback( + async (options?: { saveDraft?: boolean }) => { + if (!user) return; + + const saveDraft = options?.saveDraft ?? false; + + if (!saveDraft && typeof window !== "undefined") { + const confirmed = window.confirm( + messages.create.topNav.leaveConfirmLoss, + ); + if (!confirmed) return; + } + + if (saveDraft && SYNC_ENABLED) { + const payload: CreateFlowState = { + ...state, + ...(currentStep ? { currentStep } : {}), + }; + const result = await saveDraftToServer(payload); + if (result.ok === true) { + setDraftSaveBannerMessage?.(null); + } else { + setDraftSaveBannerMessage?.(result.message); + return; + } + } + + clearState(); + router.push("/"); + }, + [state, currentStep, clearState, router, user, setDraftSaveBannerMessage], + ); +} diff --git a/app/create/layout.tsx b/app/create/layout.tsx index c94e070..bca407c 100644 --- a/app/create/layout.tsx +++ b/app/create/layout.tsx @@ -1,115 +1,6 @@ -"use client"; - import type { ReactNode } from "react"; -import { useRouter } from "next/navigation"; -import { CreateFlowBackendSync } from "./context/CreateFlowBackendSync"; -import { - CreateFlowProvider, - useCreateFlow, - saveCreateFlowDraft, -} from "./context/CreateFlowContext"; -import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation"; -import CreateFlowTopNav from "../components/utility/CreateFlowTopNav"; -import CreateFlowFooter from "../components/utility/CreateFlowFooter"; -import Button from "../components/buttons/Button"; +import CreateFlowLayoutGate from "./CreateFlowLayoutGate"; -/** - * Layout for the Create Rule Flow - * - * Provides a full-screen layout without the root layout's TopNav/Footer. - * This layout wraps all create flow pages and provides the CreateFlowContext. - * Includes the create flow-specific TopNav and Footer components. - */ -function CreateFlowLayoutContent({ children }: { children: ReactNode }) { - const router = useRouter(); - const { - currentStep, - nextStep, - previousStep, - goToNextStep, - goToPreviousStep, - } = useCreateFlowNavigation(); - const { state, clearState } = useCreateFlow(); - - const handleExit = (options?: { saveDraft?: boolean }) => { - const saveDraft = options?.saveDraft ?? false; - if (!saveDraft && typeof window !== "undefined") { - const confirmed = window.confirm( - "Leave create flow? Your progress will be lost.", - ); - if (!confirmed) return; - } - if (saveDraft) { - saveCreateFlowDraft(state); - } - clearState(); - router.push("/"); - }; - - const isCompletedStep = currentStep === "completed"; - const isRightRailStep = currentStep === "right-rail"; - const useFullHeightMain = isCompletedStep || isRightRailStep; - - return ( -
- router.push("/create/final-review") - : undefined - } - onExit={handleExit} - buttonPalette={isCompletedStep ? "inverse" : undefined} - className={ - isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : undefined - } - /> -
- {children} -
- {!isCompletedStep && ( - - {currentStep === "final-review" - ? "Finalize CommunityRule" - : currentStep === "confirm-stakeholders" - ? "Confirm Stakeholders" - : "Next"} - - ) : null - } - onBackClick={previousStep ? goToPreviousStep : undefined} - /> - )} -
- ); -} - -export default function CreateFlowLayout({ - children, -}: { - children: ReactNode; -}) { - return ( - - - {children} - - ); +export default function CreateFlowLayout({ children }: { children: ReactNode }) { + return {children}; } diff --git a/app/create/right-rail/page.tsx b/app/create/right-rail/page.tsx index 548b372..8c9b95e 100644 --- a/app/create/right-rail/page.tsx +++ b/app/create/right-rail/page.tsx @@ -6,6 +6,7 @@ import DecisionMakingSidebar from "../../components/utility/DecisionMakingSideba import CardStack from "../../components/utility/CardStack"; import type { InfoMessageBoxItem } from "../../components/utility/InfoMessageBox/InfoMessageBox.types"; import type { CardStackItem } from "../../components/utility/CardStack/CardStack.types"; +import { useCreateFlow } from "../context/CreateFlowContext"; const SIDEBAR_TITLE = "How should conflicts be resolved?"; @@ -78,6 +79,7 @@ const SAMPLE_CARDS: CardStackItem[] = [ * Two-column layout (sidebar + card stack) at 640+, single column at 320-639. */ export default function RightRailPage() { + const { markCreateFlowInteraction } = useCreateFlow(); const [isMounted, setIsMounted] = useState(false); const isMdOrLarger = useMediaQuery("(min-width: 640px)"); const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState( @@ -96,22 +98,28 @@ export default function RightRailPage() { const handleMessageBoxCheckboxChange = useCallback( (id: string, checked: boolean) => { + markCreateFlowInteraction(); setMessageBoxCheckedIds((prev) => checked ? [...prev, id] : prev.filter((x) => x !== id), ); }, - [], + [markCreateFlowInteraction], ); - const handleCardSelect = useCallback((id: string) => { - setSelectedIds((prev) => - prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], - ); - }, []); + const handleCardSelect = useCallback( + (id: string) => { + markCreateFlowInteraction(); + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + }, + [markCreateFlowInteraction], + ); const handleToggleExpand = useCallback(() => { + markCreateFlowInteraction(); setExpanded((prev) => !prev); - }, []); + }, [markCreateFlowInteraction]); if (showDesktopLayout) { return ( diff --git a/app/create/select/page.tsx b/app/create/select/page.tsx index e74d611..3a230a5 100644 --- a/app/create/select/page.tsx +++ b/app/create/select/page.tsx @@ -11,27 +11,36 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; import HeaderLockup from "../../components/type/HeaderLockup"; import MultiSelect from "../../components/controls/MultiSelect"; import type { ChipOption } from "../../components/controls/MultiSelect/MultiSelect.types"; +import { useCreateFlow } from "../context/CreateFlowContext"; function createListCustomHandlers( setList: Dispatch>, confirmState: "Unselected" | "Selected", + onInteraction?: () => void, ) { + const touch = () => onInteraction?.(); return { - onAddClick: () => + onAddClick: () => { + touch(); setList((prev) => [ ...prev, { id: crypto.randomUUID(), label: "", state: "Custom" }, - ]), - onCustomChipConfirm: (chipId: string, value: string) => + ]); + }, + onCustomChipConfirm: (chipId: string, value: string) => { + touch(); setList((prev) => prev.map((opt) => opt.id === chipId ? { ...opt, label: value, state: confirmState } : opt, ), - ), - onCustomChipClose: (chipId: string) => - setList((prev) => prev.filter((o) => o.id !== chipId)), + ); + }, + onCustomChipClose: (chipId: string) => { + touch(); + setList((prev) => prev.filter((o) => o.id !== chipId)); + }, }; } @@ -43,6 +52,7 @@ function createListCustomHandlers( * Responsive sizing: uses L/M for HeaderLockup and S for MultiSelect based on 640px breakpoint. */ export default function SelectPage() { + const { markCreateFlowInteraction } = useCreateFlow(); const [isMounted, setIsMounted] = useState(false); const isMdOrLarger = useMediaQuery("(min-width: 640px)"); @@ -85,19 +95,35 @@ export default function SelectPage() { ]); const communityCustomHandlers = useMemo( - () => createListCustomHandlers(setCommunitySizeOptions, "Unselected"), - [], + () => + createListCustomHandlers( + setCommunitySizeOptions, + "Unselected", + markCreateFlowInteraction, + ), + [markCreateFlowInteraction], ); const organizationCustomHandlers = useMemo( - () => createListCustomHandlers(setOrganizationTypeOptions, "Unselected"), - [], + () => + createListCustomHandlers( + setOrganizationTypeOptions, + "Unselected", + markCreateFlowInteraction, + ), + [markCreateFlowInteraction], ); const governanceCustomHandlers = useMemo( - () => createListCustomHandlers(setGovernanceStyleOptions, "Unselected"), - [], + () => + createListCustomHandlers( + setGovernanceStyleOptions, + "Unselected", + markCreateFlowInteraction, + ), + [markCreateFlowInteraction], ); const handleCommunitySizeClick = (chipId: string) => { + markCreateFlowInteraction(); setCommunitySizeOptions((prev) => prev.map((opt) => opt.id === chipId @@ -111,6 +137,7 @@ export default function SelectPage() { }; const handleOrganizationTypeClick = (chipId: string) => { + markCreateFlowInteraction(); setOrganizationTypeOptions((prev) => prev.map((opt) => opt.id === chipId @@ -124,6 +151,7 @@ export default function SelectPage() { }; const handleGovernanceStyleClick = (chipId: string) => { + markCreateFlowInteraction(); setGovernanceStyleOptions((prev) => prev.map((opt) => opt.id === chipId diff --git a/app/create/text/page.tsx b/app/create/text/page.tsx index 462566b..945a1d1 100644 --- a/app/create/text/page.tsx +++ b/app/create/text/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import HeaderLockup from "../../components/type/HeaderLockup"; import TextInput from "../../components/controls/TextInput"; +import { useCreateFlow } from "../context/CreateFlowContext"; /** * Text page for the create flow @@ -12,9 +13,19 @@ import TextInput from "../../components/controls/TextInput"; * Responsive sizing: uses L/M for HeaderLockup and medium/small for TextInput based on 640px breakpoint. */ export default function TextPage() { + const { markCreateFlowInteraction, updateState, state } = useCreateFlow(); const [isMounted, setIsMounted] = useState(false); const isMdOrLarger = useMediaQuery("(min-width: 640px)"); - const [value, setValue] = useState(""); + const [value, setValue] = useState(() => + typeof state.title === "string" ? state.title : "", + ); + + useEffect(() => { + const incoming = state.title; + if (typeof incoming !== "string" || incoming.length === 0) return; + // eslint-disable-next-line react-hooks/set-state-in-effect -- sync controlled field when context hydrates from server/local + setValue((prev) => (prev === "" ? incoming : prev)); + }, [state.title]); // Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop). useEffect(() => { @@ -43,7 +54,12 @@ export default function TextPage() { setValue(e.target.value)} + onChange={(e) => { + const v = e.target.value; + setValue(v); + markCreateFlowInteraction(); + updateState({ title: v }); + }} inputSize={effectiveMdOrLarger ? "medium" : "small"} formHeader={false} textHint={`${characterCount}/${maxLength}`} diff --git a/app/create/types.ts b/app/create/types.ts index 5803f42..ff91f13 100644 --- a/app/create/types.ts +++ b/app/create/types.ts @@ -47,8 +47,14 @@ export interface CreateFlowContextValue { updateState: (_updates: Partial) => void; /** Replace entire flow state (e.g. hydrate from server draft). */ replaceState: (_next: CreateFlowState) => void; - /** Clear all flow state (e.g. on exit). Also clears persisted draft. */ + /** Reset flow state and clear anonymous localStorage draft keys when present. */ clearState: () => void; + /** + * True after the user edits any template control (pages use local state until wired to `state`). + * Drives Save & Exit visibility together with `hasCreateFlowUserInput(state)`. + */ + interactionTouched: boolean; + markCreateFlowInteraction: () => void; } /** diff --git a/app/create/upload/page.tsx b/app/create/upload/page.tsx index b425569..981f46e 100644 --- a/app/create/upload/page.tsx +++ b/app/create/upload/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import HeaderLockup from "../../components/type/HeaderLockup"; import Upload from "../../components/controls/Upload"; +import { useCreateFlow } from "../context/CreateFlowContext"; /** * Upload page for the create flow @@ -13,6 +14,7 @@ import Upload from "../../components/controls/Upload"; * Responsive sizing: uses L/M for HeaderLockup based on 640px breakpoint. */ export default function UploadPage() { + const { markCreateFlowInteraction } = useCreateFlow(); const [isMounted, setIsMounted] = useState(false); const isMdOrLarger = useMediaQuery("(min-width: 640px)"); @@ -25,6 +27,7 @@ export default function UploadPage() { const effectiveMdOrLarger = !isMounted || isMdOrLarger; const handleUploadClick = () => { + markCreateFlowInteraction(); // TODO: Handle upload button click (e.g. open file picker) }; diff --git a/app/layout.tsx b/app/layout.tsx index 92a9d13..8d778f5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google"; import type { Metadata } from "next"; import type { ReactNode } from "react"; +import { AuthModalProvider } from "./contexts/AuthModalContext"; import { MessagesProvider } from "./contexts/MessagesContext"; import messages from "../messages/en/index"; import "./globals.css"; @@ -101,11 +102,13 @@ export default function RootLayout({ children }: { children: ReactNode }) { className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable}`} > -
- -
{children}
- -
+ +
+ +
{children}
+ +
+
diff --git a/app/login/LoginPageClient.tsx b/app/login/LoginPageClient.tsx deleted file mode 100644 index 7a191f1..0000000 --- a/app/login/LoginPageClient.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useTranslation } from "../contexts/MessagesContext"; -import Login from "../components/modals/Login"; -import LoginForm from "../components/modals/Login/LoginForm"; - -export default function LoginPageClient() { - const router = useRouter(); - const t = useTranslation("pages.login"); - - return ( -
- { - router.push("/"); - }} - ariaLabelledBy="login-modal-heading" - belowCard={ - - {t("backToHome")} - - } - > - - -
- ); -} diff --git a/app/login/layout.tsx b/app/login/layout.tsx new file mode 100644 index 0000000..311edbb --- /dev/null +++ b/app/login/layout.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Log in · CommunityRule", + robots: { index: false, follow: false }, +}; + +export default function LoginLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/app/login/page.tsx b/app/login/page.tsx index d3ad9d7..7f3e70e 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,15 +1,18 @@ -import type { Metadata } from "next"; +"use client"; + import { Suspense } from "react"; -import LoginPageClient from "./LoginPageClient"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useTranslation } from "../contexts/MessagesContext"; +import Login from "../components/modals/Login"; +import LoginForm from "../components/modals/Login/LoginForm"; -export const metadata: Metadata = { - title: "Log in · CommunityRule", - robots: { index: false, follow: false }, -}; +const loginPageBgClass = + "min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]"; -function LoginFallback() { +function LoginLoadingFallback() { return ( -
+

Loading…

@@ -17,10 +20,43 @@ function LoginFallback() { ); } +/** + * Full-page login shell for magic-link **error redirects** (`?error=*`) and direct `/login` visits. + * Header **Log in** uses `AuthModalProvider` instead; this route stays for verify failures and bookmarks. + */ +function LoginWithSearchParams() { + const router = useRouter(); + const t = useTranslation("pages.login"); + + return ( +
+ { + router.push("/"); + }} + ariaLabelledBy="login-modal-heading" + belowCard={ + + {t("backToHome")} + + } + > + + +
+ ); +} + export default function LoginPage() { return ( - }> - + }> + ); } diff --git a/app/profile/ProfilePageClient.tsx b/app/profile/ProfilePageClient.tsx index 3ea1aa8..f2a6f4f 100644 --- a/app/profile/ProfilePageClient.tsx +++ b/app/profile/ProfilePageClient.tsx @@ -1,9 +1,32 @@ "use client"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "../contexts/MessagesContext"; +import Button from "../components/buttons/Button"; +import { fetchAuthSession, logout } from "../../lib/create/api"; export default function ProfilePageClient() { const t = useTranslation("pages.profile"); + const [user, setUser] = useState<{ id: string; email: string } | null>(null); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + let cancelled = false; + void fetchAuthSession().then(({ user: u }) => { + if (!cancelled) { + setUser(u); + setLoaded(true); + } + }); + return () => { + cancelled = true; + }; + }, []); + + const handleSignOut = useCallback(async () => { + await logout(); + setUser(null); + }, []); return (
@@ -13,6 +36,20 @@ export default function ProfilePageClient() {

{t("placeholderBody")}

+ {loaded && user ? ( +
+ +
+ ) : null}
); } diff --git a/docs/backend-linear-tickets.md b/docs/backend-linear-tickets.md index aefa860..d322773 100644 --- a/docs/backend-linear-tickets.md +++ b/docs/backend-linear-tickets.md @@ -2,11 +2,11 @@ Copy each block into Linear (or your tracker) as a separate issue, **in order**. Earlier tickets are prerequisites for later ones. -**Foundation already in the repo (no ticket needed unless you are onboarding a greenfield clone):** Prisma schema ([prisma/schema.prisma](prisma/schema.prisma)), migrations, `lib/server/*`, Route Handlers under `app/api/*`, [docker-compose.yml](docker-compose.yml), [Dockerfile](Dockerfile), [CONTRIBUTING.md](CONTRIBUTING.md), [`.env.example`](.env.example), [lib/create/api.ts](lib/create/api.ts), [CreateFlowBackendSync](app/create/context/CreateFlowBackendSync.tsx) behind `NEXT_PUBLIC_ENABLE_BACKEND_SYNC`. +**Foundation already in the repo (no ticket needed unless you are onboarding a greenfield clone):** Prisma schema ([prisma/schema.prisma](prisma/schema.prisma)), migrations, `lib/server/*`, Route Handlers under `app/api/*`, [docker-compose.yml](docker-compose.yml), [Dockerfile](Dockerfile), [CONTRIBUTING.md](CONTRIBUTING.md), [`.env.example`](.env.example), [lib/create/api.ts](lib/create/api.ts), create-flow draft **PUT** via `useCreateFlowExit` / `PostLoginDraftTransfer` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC`. ### Review sync (relevant feedback only) -A backend review was merged into **[docs/backend-roadmap.md](backend-roadmap.md)** after checking the repo. **Incorporated:** custom session lifecycle follow-ups (not a mandate to adopt Auth.js/Lucia), **passwordless email (magic-link request)** rate limits in-memory until multi-instance + shared store, `RuleDraft` already has `updatedAt` (no migration to add it), **prefer external web vitals** over product Postgres by default, API error shape + request-id observability targets, **authorization v1** aligned with `app/api/rules`, Prisma **never edit applied migrations**, **profile / my rules / account** scope from Figma profile (`22143:900069`) as **Ticket 15** (change email deferred). **Excluded:** requiring NextAuth/Lucia; “add `updatedAt` on drafts”; hard ban on DB for vitals (softened to default external). **Parallel Linear issues:** **CR-84** (API errors, blocked by CR-73), **CR-85** (session lifecycle, blocked by CR-75)—see **Linear** table at the end of this doc. +A backend review was merged into **[docs/backend-roadmap.md](backend-roadmap.md)** after checking the repo. **Incorporated:** custom session lifecycle follow-ups (not a mandate to adopt Auth.js/Lucia), **passwordless email (magic-link request)** rate limits in-memory until multi-instance + shared store, `RuleDraft` already has `updatedAt` (no migration to add it), **prefer external web vitals** over product Postgres by default, API error shape + request-id observability targets, **authorization v1** aligned with `app/api/rules`, Prisma **never edit applied migrations**, **profile / my rules / account** scope from Figma profile (`22143:900069`) as **Ticket 15** (change email deferred). **Excluded:** requiring NextAuth/Lucia; “add `updatedAt` on drafts”; hard ban on DB for vitals (softened to default external). **Parallel Linear issues:** **CR-84** (API errors — **unblocked** now that **CR-73** is Done), **CR-85** (session lifecycle — **unblocked** now that **CR-75** is Done)—see **Linear** table at the end of this doc. --- @@ -67,8 +67,8 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi **Implementation:** -1. Rewrite **§1 Where we are** to list: Prisma + Postgres, existing `app/api/*` routes, `localStorage` + optional server draft sync, web-vitals still file-based. -2. In **§9 Build order** (build steps were renumbered from old §5), mark what is **operator/manual**, what is **already shipped in the repo**, and what is **still product/frontend** (sign-in UI, publish wiring, etc.). +1. Rewrite **§1 Where we are** to list: Prisma + Postgres, existing `app/api/*` routes, create-flow persistence (anonymous `localStorage` + optional server draft PUT when sync is on), web-vitals still file-based. +2. In **§9 Build order** (build steps were renumbered from old §5), mark what is **operator/manual**, what is **already shipped in the repo**, and what is **still product/frontend** (publish wiring, templates in UI, etc.). 3. Add **HTTP API (implemented in repo)** — table mirroring [CONTRIBUTING.md](CONTRIBUTING.md), plus note for `/api/web-vitals`. **Acceptance criteria:** @@ -125,7 +125,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi **Implementation (shipped):** -1. **`/login`** route and/or **modal** from the header (designer-approved)—[app/login/page.tsx](app/login/page.tsx), [app/login/LoginPageClient.tsx](app/login/LoginPageClient.tsx), [app/components/modals/Login/](app/components/modals/Login/) (`LoginForm.tsx`, container/view). +1. **`/login`** route **and** **header modal** — primary **Log in** entry is [`AuthModalProvider`](app/contexts/AuthModalContext.tsx) + [app/components/modals/Login/](app/components/modals/Login/); [app/login/page.tsx](app/login/page.tsx) (solid shell, `usePortal={false}`) remains for verify **error** redirects and bookmarks. 2. Flow: email → “Send link” → user opens link (email, Mailhog, or dev log) → `GET /api/auth/magic-link/verify?token=...` sets session and redirects; optional `next` for post-login path. 3. Surface API errors: invalid email, 429 `retryAfterMs`, expired/invalid token, network failure (accessible copy). 4. Ensure `fetch` calls use `credentials: "include"` where needed (see [lib/create/api.ts](lib/create/api.ts)). @@ -137,9 +137,9 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi - [x] Happy path: user completes magic-link verify and `GET /api/auth/session` returns `user` in the same browser session. - [x] Keyboard + screen-reader friendly forms (labels, errors associated with fields). - [x] No secrets in client bundle. -- [x] Header shows **Profile** → placeholder `/profile` when session present; **Log in** when anonymous. +- [x] Header shows **Profile** → placeholder `/profile` when session present; **Log in** when anonymous (opens modal, not only `/login`). -**Status:** [CR-74](https://linear.app/community-rule/issue/CR-74/backend-email-otp-sign-in-ui-existing-apis) **Done** for shipped UI/APIs. **Residual checklist** below: repo doc items are **done**; use Linear (CR-74 or child issue) to track **per-environment** staging URL checks. +**Status:** [CR-74](https://linear.app/community-rule/issue/CR-74/backend-magic-link-sign-in-ui-apis-ticket-3-cr-75-done) **Done** for shipped UI/APIs. **Residual checklist** below: repo doc items are **done**; use Linear (CR-74 or child issue) to track **per-environment** staging URL checks. **Files:** [app/login/](app/login/), [app/profile/](app/profile/) (placeholder), [app/components/modals/Login/](app/components/modals/Login/), [messages/en/pages/login.json](messages/en/pages/login.json), [messages/en/pages/profile.json](messages/en/pages/profile.json), [messages/en/components/header.json](messages/en/components/header.json), [app/components/navigation/TopNav/TopNav.container.tsx](app/components/navigation/TopNav/TopNav.container.tsx), [app/components/navigation/TopNav/TopNavWithPathname.tsx](app/components/navigation/TopNav/TopNavWithPathname.tsx), [lib/create/api.ts](lib/create/api.ts), [app/api/auth/magic-link/request/route.ts](app/api/auth/magic-link/request/route.ts), [app/api/auth/magic-link/verify/route.ts](app/api/auth/magic-link/verify/route.ts), [prisma/schema.prisma](prisma/schema.prisma) (`MagicLinkToken`), [lib/server/mail.ts](lib/server/mail.ts). Onboarding: [CONTRIBUTING.md](CONTRIBUTING.md), [`.env.example`](.env.example). @@ -158,23 +158,24 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi **Depends on:** Ticket 3. -**Goal:** While in `/create/*`, users see whether they are signed in and can sign out without leaving the flow awkwardly. +**Goal:** In `/create/*`, **Exit** / **Save & Exit** (from `select` onward for signed-in users) is the only top-nav chrome—no email or Sign out in the create shell. **Anonymous:** progress in **`create-flow-anonymous`** localStorage; **Exit** opens the global **Save your progress?** auth modal (magic link + `?syncDraft=1` return); after verify, [`PostLoginDraftTransfer`](app/create/PostLoginDraftTransfer.tsx) **PUT**s to `/api/drafts/me` when sync is on. **Signed-in:** **Save & Exit** **PUT**s via [`useCreateFlowExit`](app/create/hooks/useCreateFlowExit.ts) when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC`**. **Sign out** for QA lives on **[ProfilePageClient](app/profile/ProfilePageClient.tsx)**. Site **Log in** opens the same modal overlay ([`AuthModalProvider`](app/contexts/AuthModalContext.tsx)), not only `/login`. -**Context:** [CreateFlowTopNav](app/components/utility/CreateFlowTopNav/) has props like `loggedIn` currently tied to step UI in [app/create/layout.tsx](app/create/layout.tsx) (`isCompletedStep`). Decouple **auth session** from **step**. +**Context:** **`saveDraftOnExit`** is gated on **session + step ≥ select**. Layout **`fetchAuthSession`** drives anonymous vs authenticated persistence and exit behavior. **Save & Exit** styling: Figma [20907:212637](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=20907-212637). Save-progress exit modal: Figma `22398:23743`. -**Implementation:** +**Implementation (repo):** -1. On create layout mount (or a small wrapper provider), call `fetchAuthSession()` and store `{ user }` in React state or a tiny `AuthSessionContext`. -2. Pass **real** `loggedIn={Boolean(user)}` (or rename prop to `isAuthenticated` if clearer) and show **email** (truncated) per design. -3. Wire **Sign out** to `logout()` from [lib/create/api.ts](lib/create/api.ts), clear client state as needed, refresh session. -4. Optionally: if `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` and user is anonymous, show one-line CTA “Sign in to save progress to your account” linking to login. +1. [app/create/layout.tsx](app/create/layout.tsx): session + `enableAnonymousPersistence`; anonymous exit → `openLogin({ variant: 'saveProgress', nextPath })`; signed-in exit → `useCreateFlowExit`. +2. [CreateFlowTopNav](app/components/utility/CreateFlowTopNav/): i18n [`messages/en/create/topNav.json`](messages/en/create/topNav.json); logo + Share/Export/Edit (completed) + Exit/Save & Exit only. +3. [useCreateFlowExit](app/create/hooks/useCreateFlowExit.ts): `saveDraftToServer` when sync + signed in; `clearState` + home. +4. [CreateFlowContext](app/create/context/CreateFlowContext.tsx): optional anonymous localStorage mirror via `enableAnonymousPersistence`. +5. **QA:** [ProfilePageClient](app/profile/ProfilePageClient.tsx) Sign out when session present. **Acceptance criteria:** -- [ ] Completed step still works; auth state is independent of `completed` step. -- [ ] Sign out clears httpOnly session server-side and UI updates. +- [x] Completed step still works; **Save & Exit** gating uses session + step (not conflated with `completed` only). +- [x] Signed in + sync: Save & Exit persists server-side; anonymous: localStorage + exit modal + transfer after magic link. Sign out on profile clears session. _(Re-verify on staging/prod as needed.)_ -**Files:** [app/create/layout.tsx](app/create/layout.tsx), [app/components/utility/CreateFlowTopNav/](app/components/utility/CreateFlowTopNav/), optional new `app/create/context/AuthSessionContext.tsx`. +**Files:** [app/create/layout.tsx](app/create/layout.tsx), [app/create/hooks/useCreateFlowExit.ts](app/create/hooks/useCreateFlowExit.ts), [app/components/utility/CreateFlowTopNav/](app/components/utility/CreateFlowTopNav/), [app/create/context/CreateFlowContext.tsx](app/create/context/CreateFlowContext.tsx), [messages/en/create/topNav.json](messages/en/create/topNav.json), [app/profile/ProfilePageClient.tsx](app/profile/ProfilePageClient.tsx). --- @@ -182,24 +183,24 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi **Depends on:** Tickets 2–4. -**Goal:** `CreateFlowBackendSync` is production-grade when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`. +**Goal:** Server draft **PUT** path is production-grade when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` (Save & Exit, post-login transfer from anonymous draft). -**Context:** [app/create/context/CreateFlowBackendSync.tsx](app/create/context/CreateFlowBackendSync.tsx) hydrates from server and debounces saves; today it can race with localStorage-first paint and silently fail saves. +**Context:** Auto-hydrate / debounced autosave component was removed; signed-in resume uses `GET /api/drafts/me` in the create layout. **Implementation:** -1. **Hydration:** Show a non-blocking “Loading your saved progress…” until first session + draft fetch completes (only when sync enabled). -2. **Conflict:** If `localStorage` has non-empty state and server returns non-empty draft, pick a policy: prefer server with confirm modal, or prefer newer `updatedAt` (requires storing timestamp client-side). Document choice in code comment. -3. **Save failures (API surface):** Change [saveDraftToServer](lib/create/api.ts) from `Promise` to a result type such as `{ ok: true } | { ok: false; message: string; status?: number }`, parsing the response body with [readApiErrorMessage](lib/create/api.ts) so both legacy `{ error: string }` and CR-73 validation `{ error: { message } }` (and 413 `payload_too_large`) produce a useful `message`. Update [CreateFlowBackendSync](app/create/context/CreateFlowBackendSync.tsx) to branch on that result. -4. **Save failures (UX):** On `ok: false`, show toast/banner (include `message`); optionally retry with backoff. -5. **Tests:** Component test or Playwright scenario with sync flag on (may require test DB or route mocks). +1. **Hydration:** **Done:** [SignedInDraftHydration](app/create/SignedInDraftHydration.tsx) + [messages/en/create/draftHydration.json](messages/en/create/draftHydration.json); skips `?syncDraft=1` / transfer-pending (PostLogin owns that). Wired in [layout](app/create/layout.tsx). +2. **Conflict:** **Done:** If `create-flow-anonymous` and server draft are both non-empty, `window.confirm` (OK = account draft, Cancel = browser copy); documented on [anonymousDraftStorage](app/create/anonymousDraftStorage.ts). Newer-`updatedAt` client compare remains optional. +3. **Save failures (API surface):** **Done (CR-76):** [saveDraftToServer](lib/create/api.ts) returns `SaveDraftResult` with parsed API `message`; wired in [useCreateFlowExit](app/create/hooks/useCreateFlowExit.ts) and [PostLoginDraftTransfer](app/create/PostLoginDraftTransfer.tsx). +4. **Save failures (UX):** **Done (CR-76):** Dismissible banner with server `message` (no second confirm to leave); post-login transfer shows reason; unit tests in `tests/unit/saveDraftToServer.test.ts`. Retry/backoff remains optional. +5. **Tests:** `saveDraftToServer` unit tests; [draftHydrationUtils](lib/create/draftHydrationUtils.ts) unit tests. Playwright against Next standalone + route mocks for `/api/auth/session` was flaky here; cover hydration with **manual QA** (signed in + sync on + server draft) or add a future E2E with a dedicated auth fixture. **Acceptance criteria:** -- [ ] No silent data loss when server save fails. -- [ ] User understands when server draft replaced local state (if applicable). +- [x] No silent data loss when server save fails (user sees reason in banner; stays in flow to retry Save & Exit or leave via e.g. logo). +- [x] User understands when server draft replaced local state (if applicable) — conflict `window.confirm` when both browser anonymous draft and account draft exist; otherwise silent apply of single source. -**Files:** [lib/create/api.ts](lib/create/api.ts), [app/create/context/CreateFlowBackendSync.tsx](app/create/context/CreateFlowBackendSync.tsx), possibly [CreateFlowContext](app/create/context/CreateFlowContext.tsx), tests under `tests/`. +**Files:** [lib/create/api.ts](lib/create/api.ts), [app/create/hooks/useCreateFlowExit.ts](app/create/hooks/useCreateFlowExit.ts), [app/create/PostLoginDraftTransfer.tsx](app/create/PostLoginDraftTransfer.tsx), [app/create/SignedInDraftHydration.tsx](app/create/SignedInDraftHydration.tsx), [app/create/layout.tsx](app/create/layout.tsx), [CreateFlowContext](app/create/context/CreateFlowContext.tsx), tests under `tests/`. --- @@ -233,6 +234,8 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi **Goal:** Curated templates exist in DB for recommendations (v1 = static curated list, no ML). +**Not in v1 (this ticket):** **Spreadsheet-authored matrices**, multi-axis **facet filtering**, or **ranked** recommendations from user answers — that is **Ticket 16 / [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion)** after the flat list ships. + **Implementation:** 1. Add [Prisma seed](https://www.prisma.io/docs/guides/migrate/seed-database): `prisma/seed.ts` with `upsert` on `slug` for idempotent runs. @@ -270,6 +273,36 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi **Files:** [app/components/sections/RuleStack/](app/components/sections/RuleStack/), [app/create/[step]/page.tsx](app/create/[step]/page.tsx) or related, possibly new `lib/templates/fetchTemplates.ts`. +**Follow-up:** **Ticket 16** — dynamic recommendations from authoring spreadsheets and create-flow answers. + +--- + +## Ticket 16 — Template recommendation matrix + spreadsheet ingestion + +**Depends on:** Tickets 7–8 (templates exist in DB and UI can fetch them). Can overlap **Ticket 6** (create flow) for wizard steps that POST answers. + +**Goal:** Support **dynamic** template selection driven by **authoring spreadsheets** (e.g. Excel / Google Sheets exported to `.xlsx`): each **row** is a template variant with long-form copy (title, description, principles, steps, objections); **columns** encode **matching dimensions** (group size bands, organization type, location, maturity, etc.) with symbols or weights (✓/✗, 0–1 scores). The create flow (or home) should **narrow or rank** options from **user-supplied facets** or a short questionnaire. + +**Context:** The current [`RuleTemplate`](prisma/schema.prisma) model is a **flat** list (`slug`, `title`, `category`, `description`, `body` JSON). It does **not** model dimension columns, matrix versioning, or import from sheets. Example product shape: a “Decision-making” workbook → many governance patterns, each row tied to applicability across org context. + +**Implementation (phased — product can stop after any phase):** + +1. **Authoring contract:** Document required columns / sheet tabs (per domain: decision-making, meetings, etc.), validation rules, and how ✓/✗ or numeric cells map to API filters or scores. +2. **Storage:** Either extend `RuleTemplate` / `body` with a structured `recommendationMatrix` blob **or** add normalized tables (`TemplateDimension`, `TemplateFacetValue`, `TemplateApplicability`) — pick based on query needs and reporting. +3. **Import:** Script or internal admin path: `.xlsx` → parse (e.g. `xlsx` / SheetJS) → validate → upsert DB rows or generate seed JSON checked into repo. **Default:** batch job on export, **not** live Sheets API in prod unless explicitly required. +4. **API:** Extend `GET /api/templates` with optional query params (`?facet.orgType=nonprofit&facet.size=6-12`) **or** add `POST /api/templates/recommend` with a JSON body of answers; return ranked `templates` + optional `scores` / `reasons` for UI. +5. **UI:** Create-flow step(s) collect facets; call API; prefill `CreateFlowState` or document JSON from chosen row’s `body`. + +**Acceptance criteria:** + +- [ ] Importing an updated workbook (or running the importer) changes recommendations without hand-editing Prisma rows in Studio. +- [ ] API behavior is documented (params or POST body) and covered by tests for at least one reference matrix. +- [ ] Invalid / partial facet combinations degrade gracefully (empty list vs fallback featured templates). + +**Files (expected):** `prisma/schema.prisma`, `lib/templates/*` or `scripts/import-templates-xlsx.ts`, `app/api/templates/*`, create-flow pages, tests. + +**Linear:** [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion) (**Backlog**). **Parallel** to much of the core chain; **blocked** only by having **CR-78**/**CR-79** far enough along that a template list exists (or stub rows). + --- ## Ticket 9 — Persist web vitals outside `.next` (prefer external RUM) @@ -392,7 +425,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi **Files:** `lib/server/` (new helper), selected `app/api/**/route.ts`, optional tests. -**Linear:** [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) (blocked by **CR-73**). +**Linear:** [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) (**CR-73** Done — ready to pick up). --- @@ -418,7 +451,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi **Files:** [lib/server/session.ts](lib/server/session.ts), [app/api/auth/magic-link/verify/route.ts](app/api/auth/magic-link/verify/route.ts), optional `prisma` migration if new columns (unlikely). -**Linear:** [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) (blocked by **CR-75**). +**Linear:** [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) (**unblocked** — **CR-75** Done). --- @@ -452,7 +485,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi **Files:** new `app/` routes and components, `app/api/rules/...` (or new segment handlers), [lib/create/api.ts](lib/create/api.ts) as needed, [prisma/schema.prisma](prisma/schema.prisma) only if account-delete policy requires schema tweaks, [messages/en/](messages/en/) for copy. -**Linear:** [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) (**Backlog**). **Blocked by** **CR-75** + **CR-77**. **Related:** [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional) (public rule detail for deep links from profile cards). **Not** part of the sequential **CR-72 → CR-83** chain—parallel after publish + session, similar to CR-84/CR-85. +**Linear:** [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) (**Backlog**). **Blocked by** **CR-77** (publish) only — **CR-75** Done. **Related:** [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional) (public rule detail for deep links from profile cards). **Not** part of the sequential **CR-72 → CR-83** chain—parallel after publish + session, similar to CR-84/CR-85. --- @@ -475,42 +508,39 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi | 13 | 13 | API errors + request-id logging | | 14 | 14 | Session lifecycle + cleanup | | 15 | 15 | Profile + account (Figma profile) | +| 16 | 16 | Template matrix + xlsx ingestion | -Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Tickets 13–14** are parallel to that chain (blocked by **CR-73** and **CR-75** respectively), not sequential after CR-83. **Ticket 15** is also **parallel** (blocked by auth + session + publish—not by the ops runbook); Linear: **CR-86**. +Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 16** is also **deferrable** until after **7–8** (flat template list + UI); it adds **spreadsheet-driven** recommendations and facet APIs. **Tickets 13–14** are parallel to that chain (**CR-73** / **CR-75** prerequisites are **Done** — **CR-84** / **CR-85** are unblocked), not sequential after CR-83. **Ticket 15** is also **parallel** (blocked by **publish (CR-77)** once session/auth are shipped); Linear: **CR-86**. --- ## Linear (Community-rule team) -**Main chain:** **CR-72 → CR-83** (each blocks the next). **Parallel:** **CR-84** (blocked by CR-73), **CR-85** (blocked by CR-75), **CR-86** / Ticket 15 (blocked by CR-75 + CR-77, not in the CR-72–83 sequence). +**Main chain:** **CR-72 → CR-83** (each blocks the next). **Parallel:** **CR-84** (**CR-73** Done — ready to pick up), **CR-85** (**CR-75** Done — ready to pick up), **CR-86** / Ticket 15 (blocked by **CR-77** publish only; **CR-75** Done), **CR-88** / Ticket 16 (template matrix + `.xlsx` ingestion — after **CR-78**/**CR-79**), not in the CR-72–83 sequence. -| Doc ticket | Linear | Title (short) | -| ---------: | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | -| 1 | [CR-72](https://linear.app/community-rule/issue/CR-72/backend-align-docsbackend-roadmapmd-with-current-codebase) | Align backend-roadmap | -| 2 | [CR-73](https://linear.app/community-rule/issue/CR-73/backend-formalize-createflowstate-validate-draftpublish-api-payloads) | CreateFlowState + API validation | -| 3 | [CR-74](https://linear.app/community-rule/issue/CR-74/backend-email-otp-sign-in-ui-existing-apis) | Magic-link sign-in UI + CR-75 prep | -| 4 | [CR-75](https://linear.app/community-rule/issue/CR-75/backend-create-flow-session-ui-sign-out) | Create flow session UI | -| 5 | [CR-76](https://linear.app/community-rule/issue/CR-76/backend-harden-server-draft-sync-createflowbackendsync) | Draft sync hardening | -| 6 | [CR-77](https://linear.app/community-rule/issue/CR-77/backend-wire-publish-rule-from-create-flow-post-apirules) | Publish wiring | -| 7 | [CR-78](https://linear.app/community-rule/issue/CR-78/backend-prisma-seed-ruletemplate-document) | Template seed | -| 8 | [CR-79](https://linear.app/community-rule/issue/CR-79/backend-load-rule-templates-from-get-apitemplates-in-ui) | Templates in UI | -| 9 | [CR-80](https://linear.app/community-rule/issue/CR-80/backend-persist-web-vitals-outside-next-db-or-external-rum) | Web vitals (prefer external) | -| 10 | [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional) | Public rule detail (optional) | -| 11 | [CR-82](https://linear.app/community-rule/issue/CR-82/backend-ci-postgres-migration-smoke-optional) | CI migrate smoke (optional) | -| 12 | [CR-83](https://linear.app/community-rule/issue/CR-83/backend-stagingproduction-runbook-admin-handoff-docsops-backend) | Ops runbook / admin handoff | -| 13 | [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) | API errors + request-id logging | -| 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup | -| 15 | [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) | Profile + account (Figma 22143:900069) | +| Doc ticket | Linear | Title (short) | +| ---------: | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| 1 | [CR-72](https://linear.app/community-rule/issue/CR-72/backend-align-docsbackend-roadmapmd-with-current-codebase) | Align backend-roadmap | +| 2 | [CR-73](https://linear.app/community-rule/issue/CR-73/backend-formalize-createflowstate-validate-draftpublish-api-payloads) | CreateFlowState + API validation | +| 3 | [CR-74](https://linear.app/community-rule/issue/CR-74/backend-magic-link-sign-in-ui-apis-ticket-3-cr-75-done) | Magic-link sign-in UI (Ticket 3; Done) | +| 4 | [CR-75](https://linear.app/community-rule/issue/CR-75/backend-create-flow-session-ui-sign-out-ticket-4-done) | Create flow session UI (Ticket 4; Done) | +| 5 | [CR-76](https://linear.app/community-rule/issue/CR-76/backend-harden-server-draft-sync-save-and-exit-post-login-transfer) | Draft sync hardening (PUT UX / errors) | +| 6 | [CR-77](https://linear.app/community-rule/issue/CR-77/backend-wire-publish-rule-from-create-flow-post-apirules) | Publish wiring | +| 7 | [CR-78](https://linear.app/community-rule/issue/CR-78/backend-prisma-seed-ruletemplate-document) | Template seed | +| 8 | [CR-79](https://linear.app/community-rule/issue/CR-79/backend-load-rule-templates-from-get-apitemplates-in-ui) | Templates in UI | +| 9 | [CR-80](https://linear.app/community-rule/issue/CR-80/backend-persist-web-vitals-outside-next-db-or-external-rum) | Web vitals (prefer external) | +| 10 | [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional) | Public rule detail (optional) | +| 11 | [CR-82](https://linear.app/community-rule/issue/CR-82/backend-ci-postgres-migration-smoke-optional) | CI migrate smoke (optional) | +| 12 | [CR-83](https://linear.app/community-rule/issue/CR-83/backend-stagingproduction-runbook-admin-handoff-docsops-backend) | Ops runbook / admin handoff | +| 13 | [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) | API errors + request-id logging | +| 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup | +| 15 | [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) | Profile + account (Figma 22143:900069) | +| 16 | [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion) | Template matrix + xlsx ingestion | --- -## Updating Linear issue CR-74 (manual) +## Linear sync notes (CR-74 / CR-75) -Keep **[CR-74](https://linear.app/community-rule/issue/CR-74/backend-email-otp-sign-in-ui-existing-apis)** aligned with **Ticket 3** (Linear UI or MCP). If Linear still describes an old sign-in approach, update it so it matches **Ticket 3** above (magic link only): +**[CR-74](https://linear.app/community-rule/issue/CR-74/backend-magic-link-sign-in-ui-apis-ticket-3-cr-75-done)** and **[CR-75](https://linear.app/community-rule/issue/CR-75/backend-create-flow-session-ui-sign-out-ticket-4-done)** are kept in sync with **Ticket 3** / **Ticket 4** above (magic link, `AuthModalProvider`, anonymous draft + transfer, etc.). **Residual:** staging/prod `Host` / magic-link URL verification (per-environment). -- **Title (examples):** `Magic-link sign-in UI + APIs; prep for CR-75` or `Email magic-link sign-in (UI + routes) — residuals for create-flow auth`. -- **Description — Shipped:** Magic link: `POST /api/auth/magic-link/request`, `GET /api/auth/magic-link/verify`, `MagicLinkToken`, `/login` + modal UI, `requestMagicLink`, session cookie. -- **Description — Residual / before CR-75:** Use the checklist under **Residual / before CR-75** (Ticket 3 above). Mark **done** for items 1, 2, and 4 (repo docs). Keep **open** until verified: **(3)** staging/prod `Host` / link URLs on your real hosts. -- **Comment (optional):** Start **CR-75** only after residuals are done **or** the team defers specific lines (e.g. CONTRIBUTING in a separate PR). - -**Status:** CR-74 can stay **Done** with a **child issue** (e.g. “CR-74 follow-ups: auth docs + smoke”) if you prefer not to reopen the parent. +To refresh other issues from this doc, use Linear MCP `save_issue` or paste the matching **Ticket N** section into the issue body. diff --git a/docs/backend-roadmap.md b/docs/backend-roadmap.md index 0065de8..b49afba 100644 --- a/docs/backend-roadmap.md +++ b/docs/backend-roadmap.md @@ -9,7 +9,7 @@ Temporary working notes for building the backend. Safe to delete once the stack - **Next.js 16** single repo ([`package.json`](package.json)). - **PostgreSQL + Prisma**: schema and migrations under `prisma/`; product APIs under `app/api/*` (health, auth/magic-link, session, drafts, rules, templates, web-vitals). - **Server modules** in `lib/server/` (db, session, mail, rate limiting, etc.). -- **Create flow** persists in the browser (`localStorage`); optional **server draft sync** when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` and the user is signed in ([`app/create/context/CreateFlowBackendSync.tsx`](app/create/context/CreateFlowBackendSync.tsx)). +- **Create flow:** **Anonymous** users mirror in-progress state to **`create-flow-anonymous`** in `localStorage`; **Exit** opens the save-progress magic-link modal; after verify, [`PostLoginDraftTransfer`](app/create/PostLoginDraftTransfer.tsx) can **PUT** `/api/drafts/me` when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`**. **Signed-in** users start a **fresh** in-memory session per “Create rule”; **Save & Exit** (from `select` onward) **PUT**s when sync is on. **Log in** from the marketing header uses the global modal ([`AuthModalProvider`](app/contexts/AuthModalContext.tsx)); **`/login`** remains for verify errors and deep links. - **Web vitals** [`app/api/web-vitals/route.ts`](app/api/web-vitals/route.ts) still use **file-based** storage under `.next` (not suitable for multi-instance production). - **CI:** [`.gitea/workflows/ci.yaml`](.gitea/workflows/ci.yaml) (build, test, lint, `prisma validate`); no in-repo production deploy definition. @@ -80,7 +80,9 @@ Plain-English entities (names can evolve): | **MagicLinkToken** | Short-lived **hashed** token for email sign-in links; optional `nextPath` for post-login redirect. | | **RuleDraft** | **One** JSON blob per user (create-flow state). Schema already has **`updatedAt`**; no draft **versioning** or **multiple named drafts** in v1. | | **PublishedRule** | Saved rule after publish (title, summary, document JSON). Profile UI badges such as **IN PROGRESS** may be **derived from `document` JSON**, a future `status` column, or UI-only—product decision when implementing Ticket 15. | -| **RuleTemplate** | Curated templates (slug, category, ordering). | +| **RuleTemplate** | Curated templates (slug, category, ordering, `body` JSON). **v1 API** lists rows for cards / create entry; **not** yet a recommendation engine (see below). | + +**RuleTemplate — recommendation matrix (after v1 list):** Product may author templates in **spreadsheets** (e.g. one row per governance pattern, columns for **matching dimensions** such as group size, organization type, location, maturity, plus long-form fields for create-flow prefill). That implies: **normalized schema or versioned JSON** for dimensions × template fit (✓/✗, weights, or scores), an **import path** (export `.xlsx` / Sheets → validate → DB or build-time artifact), and **`GET /api/templates` (or a sibling route)** that accepts **user- or wizard-selected facets** and returns a **ranked or filtered** set. **Out of scope for first ship** of Tickets 7–8 (seed + display list); tracked as **Ticket 16** in [docs/backend-linear-tickets.md](backend-linear-tickets.md) and Linear **[CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion)**. Prefer **batch import** over live Google Sheets API in production unless ops explicitly wants sync. **Session follow-ups to implement or decide:** token **rotation** on sensitive events, whether **new login invalidates other sessions**, and **cleanup** of expired `Session` rows (job or lazy delete). Revisit a small auth library (e.g. Auth.js, Lucia) only if maintaining custom code becomes costly. @@ -136,7 +138,7 @@ Match the current API behavior; tighten as product evolves: **Backend behavior already in the repo:** Steps **5–10** match implemented Route Handlers and middleware (`lib/server/*`). **Step 11** (web vitals) is **not** production-ready (files under `.next`); treat as follow-up work aligned with §7. -**Product / frontend still open (not only “backend exists”):** Sign-in UI, wiring publish from the create flow, template seed + UI consumption, **profile / my rules dashboard** (Ticket 15)—see §12 and [docs/backend-linear-tickets.md](backend-linear-tickets.md). +**Product / frontend still open (not only “backend exists”):** Sign-in UI, wiring publish from the create flow, template seed + UI consumption (flat list first), **spreadsheet-driven template recommendations** (Ticket 16 / [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion) — after v1 templates), **profile / my rules dashboard** (Ticket 15)—see §12 and [docs/backend-linear-tickets.md](backend-linear-tickets.md). --- @@ -178,7 +180,7 @@ npm run dev **Step 9.** **Templates** (when ready): seed `RuleTemplate` rows; `GET /api/templates` is implemented. -**Step 10.** **Frontend sync**: Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` for server drafts when logged in; `localStorage` remains fallback when off or anonymous. +**Step 10.** **Frontend draft sync:** Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so **Save & Exit** and **post-login anonymous → account transfer** can **PUT** `/api/drafts/me`. Without sync, drafts are **not** written to the server (anonymous progress still lives in `localStorage` only). **Step 11.** **Web vitals:** Move off `.next` files—**prefer an external analytics or logging pipeline** (see §7). Use Postgres for vitals only as a deliberate ops choice. @@ -216,20 +218,23 @@ npm run dev ## 12. Frontend hook-up -**Step 1.** Keep default behavior: **no env flag** → create flow uses **only** `localStorage` (current behavior). +**Step 1.** **Anonymous** create flow: in-progress state is stored in **`create-flow-anonymous`** (`localStorage`). **Signed-in** “Create rule” does **not** auto-load server drafts yet (profile “open draft” is future). -**Step 2.** Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` to opt in to server drafts when logged in. +**Step 2.** Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` to enable **PUT** on **Save & Exit** and after **magic-link transfer** from the save-progress exit modal. -**Step 3.** Sign-in UI: **`/login`** (and **Log in** in the site header) uses **magic link** (modal / page flow: request link → open verify URL); after verify, rely on the browser cookie for `/api/drafts/me`. +**Step 3.** Sign-in: **Log in** in the header opens the **modal** ([`AuthModalProvider`](app/contexts/AuthModalContext.tsx)); **`/login`** is still used for verify **error** redirects and bookmarks. Flow: request magic link → open verify URL → session cookie → `GET /api/auth/session` / `/api/drafts/me` as needed. **Step 4.** On publish, call `POST /api/rules` from the completed step when the backend is required (wire when the final review UI is ready). **Step 5.** **Profile / dashboard** (`/profile` or agreed path): signed-in hub for **my rules** (after Ticket 15 APIs exist), **duplicate** / **delete** rule actions, **logout**, **delete account**—aligned with [Figma profile](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22143-900069). **Change email** in design is **deferred** (hide, “coming soon,” or backlog) until a future account ticket; greeting copy can stay **static** or use **email local-part in UI only**—no `displayName` field required for MVP. +**Step 6.** **Templates:** **Tickets 7–8** — seed `RuleTemplate` and load **`GET /api/templates`** in home / create surfaces (flat list, optional `featured`). **Ticket 16 / [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion)** — add **facet-based recommendations** and **spreadsheet ingestion** when product is ready (matrix rows + dimension columns like the decision-making workbook). + --- ## 13. Optional later +- **Template recommendation matrix** + `.xlsx` / Sheets import pipeline — see **Ticket 16** / **[CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion)** (also §4 `RuleTemplate` note); not bundled into v1 template list work. - **Session library** spike (Auth.js, Lucia) if custom lifecycle cost grows. - **Redis** (or similar) for **shared magic-link rate limits** and horizontal scale. - **RuleDraft** versioning or multiple drafts per user. diff --git a/lib/create/api.ts b/lib/create/api.ts index a73159b..ca2acc6 100644 --- a/lib/create/api.ts +++ b/lib/create/api.ts @@ -80,39 +80,110 @@ export async function fetchDraftFromServer(): Promise { return data.draft.payload as CreateFlowState; } +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 { + 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 }; + +async function errorBodyMessage(res: Response): Promise { + try { + const data: unknown = await res.json(); + const msg = readApiErrorMessage(data); + if (msg !== "Request failed") return msg; + } catch { + /* non-JSON body */ + } + const statusText = res.statusText?.trim(); + if (statusText) return statusText; + return "Save failed"; +} + export async function saveDraftToServer( state: CreateFlowState, -): Promise { - const res = await fetch("/api/drafts/me", { - method: "PUT", - credentials: "include", - headers: jsonHeaders, - body: JSON.stringify({ payload: state }), - }); - return res.ok; +): Promise { + try { + const res = await fetch("/api/drafts/me", { + method: "PUT", + credentials: "include", + headers: jsonHeaders, + body: JSON.stringify({ payload: state }), + }); + if (res.ok) { + return { ok: true as const }; + } + const message = await errorBodyMessage(res); + return { + ok: false as const, + message, + status: res.status, + }; + } catch { + return { + ok: false as const, + message: DRAFT_SAVE_NETWORK_ERROR, + }; + } } export async function publishRule(input: { title: string; summary?: string; document: Record; -}): 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 }; } diff --git a/lib/create/buildPublishPayload.ts b/lib/create/buildPublishPayload.ts new file mode 100644 index 0000000..f4a7868 --- /dev/null +++ b/lib/create/buildPublishPayload.ts @@ -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; + 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; + 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; + } + | { 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).sections; + if (!Array.isArray(sections)) return []; + return sections.filter(isDocumentSection); +} diff --git a/lib/create/draftHydrationUtils.ts b/lib/create/draftHydrationUtils.ts new file mode 100644 index 0000000..6842881 --- /dev/null +++ b/lib/create/draftHydrationUtils.ts @@ -0,0 +1,6 @@ +import type { CreateFlowState } from "../../app/create/types"; + +/** True when the client should treat a draft payload as non-empty for hydration / conflict checks. */ +export function createFlowStateHasKeys(state: CreateFlowState): boolean { + return Object.keys(state).length > 0; +} diff --git a/lib/create/lastPublishedRule.ts b/lib/create/lastPublishedRule.ts new file mode 100644 index 0000000..5093116 --- /dev/null +++ b/lib/create/lastPublishedRule.ts @@ -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; +}; + +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; + 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, + }; + } catch { + return null; + } +} diff --git a/messages/en/create/draftHydration.json b/messages/en/create/draftHydration.json new file mode 100644 index 0000000..d92cdbc --- /dev/null +++ b/messages/en/create/draftHydration.json @@ -0,0 +1,4 @@ +{ + "loadingSavedProgress": "Loading your saved progress…", + "conflictPrompt": "You have progress saved in this browser and a draft on your account.\n\nOK — load the account draft (discard the browser copy).\nCancel — keep this browser copy." +} diff --git a/messages/en/create/publish.json b/messages/en/create/publish.json new file mode 100644 index 0000000..f428743 --- /dev/null +++ b/messages/en/create/publish.json @@ -0,0 +1,6 @@ +{ + "finalizeBannerTitle": "Couldn't publish", + "missingCommunityName": "Add a community name before finalizing.", + "finalizeButtonPublishing": "Publishing…", + "genericPublishFailed": "Something went wrong. Try again." +} diff --git a/messages/en/create/topNav.json b/messages/en/create/topNav.json new file mode 100644 index 0000000..16f61c5 --- /dev/null +++ b/messages/en/create/topNav.json @@ -0,0 +1,7 @@ +{ + "saveAndExit": "Save & Exit", + "exit": "Exit", + "leaveConfirmLoss": "Leave create flow? Your progress will be lost.", + "draftSaveBannerTitle": "Couldn't save draft", + "postLoginSaveFailedWithReason": "Could not save your draft to your account. Your progress is still stored on this device.\n\n{reason}" +} diff --git a/messages/en/index.ts b/messages/en/index.ts index 01cbafc..b4dca81 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -18,6 +18,9 @@ import profile from "./pages/profile.json"; import navigation from "./navigation.json"; 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, @@ -41,6 +44,9 @@ export default { }, create: { communication, + topNav: createTopNav, + draftHydration: createDraftHydration, + publish: createPublish, }, navigation, metadata, diff --git a/messages/en/pages/login.json b/messages/en/pages/login.json index b33ff8a..b924a90 100644 --- a/messages/en/pages/login.json +++ b/messages/en/pages/login.json @@ -1,6 +1,8 @@ { "title": "Log in to CommunityRule", "subtitle": "Enter your email and we'll send you a magic link to sign in. No password needed!", + "saveProgressTitle": "Save your progress?", + "saveProgressSubtitle": "We need your email to save, and we'll send you a magic link to sign in. If you don't save now you could lose your progress.", "emailLabel": "Email address", "emailPlaceholder": "you@example.com", "sendMagicLink": "Send me a magic link", diff --git a/messages/en/pages/profile.json b/messages/en/pages/profile.json index 1be5de4..b34e62c 100644 --- a/messages/en/pages/profile.json +++ b/messages/en/pages/profile.json @@ -1,4 +1,5 @@ { "placeholderTitle": "Your profile", - "placeholderBody": "We’re building this space for your CommunityRules and account options. Check back soon." + "placeholderBody": "We’re building this space for your CommunityRules and account options. Check back soon.", + "signOut": "Sign out" } diff --git a/stories/modals/Login.stories.tsx b/stories/modals/Login.stories.tsx index 8e21783..b449be4 100644 --- a/stories/modals/Login.stories.tsx +++ b/stories/modals/Login.stories.tsx @@ -34,6 +34,29 @@ function MagicLinkFetchMock({ children }: { children: React.ReactNode }) { return <>{children}; } +/** Fake marketing page behind the overlay (header “Log in” opens `AuthModalProvider` with this look). */ +function FakeMarketingPageBehindOverlay({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+
+

+ Placeholder page content — the login overlay portals above this and + uses backdrop blur (`blurredYellow`). +

+
+ {children} +
+ ); +} + export default { title: "Components/Modals/Login", component: Login, @@ -42,61 +65,89 @@ export default { nextjs: { appDirectory: true, navigation: { - pathname: "/login", + pathname: "/", }, }, docs: { description: { component: - "Full-page style login shell (yellow backdrop) with modal card. Uses magic-link `LoginForm` inside. Matches `/login` and header modal usage.", + '**Primary UX:** `AuthModalProvider` opens this as a **popup overlay** on top of the current page — `backdropVariant="blurredYellow"`, `usePortal` (default). **`/login`** is a thin full-page shell: yellow **solid** backdrop, `usePortal={false}`, same `LoginForm` inside.', + }, + }, + }, + decorators: [ + (Story: () => React.ReactNode) => ( + + + + ), + ], + tags: ["autodocs"], +}; + +/** Matches `AuthModalProvider`: blurred backdrop + portal over whatever route the user was on. */ +export const HeaderOverlayBlurred = { + name: "Header overlay (blurred — default)", + parameters: { + docs: { + description: { + story: + 'Same as **Log in** from the site header: `backdropVariant="blurredYellow"`, `usePortal`, card + “Back to home” below.', + }, + }, + }, + render: () => ( + + {}} + backdropVariant="blurredYellow" + usePortal + ariaLabelledBy="login-modal-heading" + belowCard={ + + ← Back to home + + } + > + Loading…

}> + +
+
+
+ ), +}; + +/** Matches `app/login/page.tsx`: dedicated route, solid yellow, no portal. */ +export const FullPageRouteSolid = { + name: "Full-page route (/login — solid)", + parameters: { + nextjs: { + navigation: { pathname: "/login" }, + }, + docs: { + description: { + story: + "Verify-email **error** links and bookmarks use `/login` with a **solid** backdrop and `usePortal={false}`.", }, }, }, decorators: [ (Story: () => React.ReactNode) => (
- - - +
), ], - tags: ["autodocs"], -}; - -export const ModalChromeOnly = { - name: "Modal (placeholder content)", - render: () => ( - {}} - ariaLabelledBy="login-modal-heading" - belowCard={ -
- ← Back to home - - } - > -

- Placeholder body — use "With magic link form" for the real - flow. -

- - ), -}; - -export const WithMagicLinkForm = { - name: "With magic link form", render: () => ( {}} + backdropVariant="solid" + usePortal={false} ariaLabelledBy="login-modal-heading" belowCard={ ( +
+ {}} + backdropVariant="solid" + usePortal={false} + ariaLabelledBy="login-modal-heading" + belowCard={ + + ← Back to home + + } + > +

+ Placeholder body — use "Header overlay" or "Full-page + route" for the real flow. +

+ +
+ ), +}; + export const FormOnly = { name: "Login form (card inset)", parameters: { docs: { description: { story: - "Form only, for inspecting copy and layout without the modal chrome. Wrap in `Login` in the app.", + "Form only, for inspecting copy and layout without overlay chrome. In the app it is always wrapped by `Login` (modal) or the `/login` page shell.", }, }, }, + decorators: [ + (Story: () => React.ReactNode) => ( +
+ +
+ ), + ], render: () => (
Loading…

}> diff --git a/stories/utility/CreateFlowTopNav.stories.js b/stories/utility/CreateFlowTopNav.stories.js index 918876d..2b511f4 100644 --- a/stories/utility/CreateFlowTopNav.stories.js +++ b/stories/utility/CreateFlowTopNav.stories.js @@ -25,9 +25,10 @@ export default { control: "boolean", description: "Whether to show the Edit button", }, - loggedIn: { + saveDraftOnExit: { control: "boolean", - description: "Whether the user is logged in (affects Exit button text)", + description: + "After user input (or completed step), use Save & Exit and pass saveDraft: true to onExit", }, onShare: { action: "share clicked" }, onExport: { action: "export clicked" }, @@ -42,7 +43,7 @@ export const Default = { hasShare: false, hasExport: false, hasEdit: false, - loggedIn: false, + saveDraftOnExit: false, }, }; @@ -51,15 +52,15 @@ export const AllButtons = { hasShare: true, hasExport: true, hasEdit: true, - loggedIn: false, + saveDraftOnExit: false, }, }; -export const LoggedIn = { +export const SaveDraftOnExit = { args: { hasShare: true, hasExport: true, hasEdit: true, - loggedIn: true, + saveDraftOnExit: true, }, }; diff --git a/tests/components/AuthModalContext.test.tsx b/tests/components/AuthModalContext.test.tsx new file mode 100644 index 0000000..73bb798 --- /dev/null +++ b/tests/components/AuthModalContext.test.tsx @@ -0,0 +1,151 @@ +import { Suspense } from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom/vitest"; +import { renderWithProviders } from "../utils/test-utils"; +import { useAuthModal } from "../../app/contexts/AuthModalContext"; + +const { navMock } = vi.hoisted(() => ({ + navMock: { + pathname: "/", + searchParams: new URLSearchParams(), + replace: vi.fn(), + }, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + replace: navMock.replace, + push: vi.fn(), + prefetch: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + }), + usePathname: () => navMock.pathname, + useSearchParams: () => navMock.searchParams, +})); + +vi.mock("../../lib/create/api", () => ({ + requestMagicLink: vi.fn(), +})); + +vi.mock("../../app/create/anonymousDraftStorage", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../app/create/anonymousDraftStorage") + >(); + return { + ...actual, + setTransferPendingFlag: vi.fn(), + }; +}); + +import { requestMagicLink } from "../../lib/create/api"; +import { setTransferPendingFlag } from "../../app/create/anonymousDraftStorage"; + +function LoginTrigger() { + const { openLogin, closeLogin } = useAuthModal(); + return ( +
+ + + +
+ ); +} + +describe("AuthModalProvider (header overlay)", () => { + beforeEach(() => { + vi.mocked(requestMagicLink).mockReset(); + vi.mocked(setTransferPendingFlag).mockReset(); + navMock.replace.mockReset(); + navMock.pathname = "/"; + navMock.searchParams = new URLSearchParams(); + }); + + it("opens blurred overlay with LoginForm when openLogin is called", async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + await user.click(screen.getByRole("button", { name: /open login modal/i })); + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + const backdrop = screen.getByRole("dialog").parentElement; + expect(backdrop).toHaveClass("backdrop-blur-md"); + expect( + screen.getByRole("heading", { name: /log in to communityrule/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /back to home/i }), + ).toBeInTheDocument(); + }); + + it("closes overlay when closeLogin is called", async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + await user.click(screen.getByRole("button", { name: /open login modal/i })); + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + await user.click( + screen.getByRole("button", { name: /close from outside/i }), + ); + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + it("saveProgress openLogin wires magicLinkNextPath and transfer flag on success", async () => { + const user = userEvent.setup(); + vi.mocked(requestMagicLink).mockResolvedValue({ ok: true }); + renderWithProviders( + + + , + ); + await user.click( + screen.getByRole("button", { name: /open save progress/i }), + ); + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + await user.type( + screen.getByRole("textbox", { name: /email address/i }), + "guest@example.com", + ); + await user.click( + screen.getByRole("button", { name: /send me a magic link/i }), + ); + await waitFor(() => { + expect(requestMagicLink).toHaveBeenCalledWith( + "guest@example.com", + "/create/select?syncDraft=1", + ); + }); + expect(setTransferPendingFlag).toHaveBeenCalled(); + }); +}); diff --git a/tests/components/CreateFlowTopNav.test.tsx b/tests/components/CreateFlowTopNav.test.tsx index 616a6c2..403521e 100644 --- a/tests/components/CreateFlowTopNav.test.tsx +++ b/tests/components/CreateFlowTopNav.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { renderWithProviders as render, screen } from "../utils/test-utils"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom/vitest"; import CreateFlowTopNav from "../../app/components/utility/CreateFlowTopNav"; @@ -34,7 +34,7 @@ const config: ComponentTestSuiteConfig = { hasShare: true, hasExport: true, hasEdit: true, - loggedIn: true, + saveDraftOnExit: true, onShare: vi.fn(), onExport: vi.fn(), onEdit: vi.fn(), @@ -60,14 +60,14 @@ describe("CreateFlowTopNav (behavioral tests)", () => { expect(exitButton).toBeInTheDocument(); }); - it("shows Save & Exit when loggedIn is true", () => { - render(); + it("shows Save & Exit when saveDraftOnExit is true", () => { + render(); const exitButton = screen.getByRole("button", { name: "Save & Exit" }); expect(exitButton).toBeInTheDocument(); }); - it("shows Exit when loggedIn is false", () => { - render(); + it("shows Exit when saveDraftOnExit is false", () => { + render(); const exitButton = screen.getByRole("button", { name: "Exit" }); expect(exitButton).toBeInTheDocument(); }); diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx index 52ace8d..4906f9c 100644 --- a/tests/components/FinalReviewPage.test.tsx +++ b/tests/components/FinalReviewPage.test.tsx @@ -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 ; +} 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(); - 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(); 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( + , + ); + 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); }); diff --git a/tests/components/Login.test.tsx b/tests/components/Login.test.tsx index 793af4d..c8608b7 100644 --- a/tests/components/Login.test.tsx +++ b/tests/components/Login.test.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen, fireEvent, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom/vitest"; @@ -10,6 +9,37 @@ describe("Login", () => { vi.clearAllMocks(); }); + it("uses blurredYellow backdrop by default (header overlay)", async () => { + renderWithProviders( + +

Login content

+
, + ); + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + const backdrop = screen.getByRole("dialog").parentElement; + expect(backdrop).toHaveClass("backdrop-blur-md"); + }); + + it("uses solid backdrop when backdropVariant is solid (/login page)", async () => { + renderWithProviders( + +

Login content

+
, + ); + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + const backdrop = screen.getByRole("dialog").parentElement; + expect(backdrop).not.toHaveClass("backdrop-blur-md"); + }); + it("renders dialog when open and portal is ready", async () => { renderWithProviders( @@ -35,6 +65,23 @@ describe("Login", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); + it("portals overlay outside the rendered subtree by default", async () => { + const { container } = renderWithProviders( +
+ +

Portaled

+
+
, + ); + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + const dialog = screen.getByRole("dialog"); + expect( + container.querySelector('[data-testid="inline-root"]')?.contains(dialog), + ).toBe(false); + }); + it("calls onClose when close button is clicked", async () => { const onClose = vi.fn(); renderWithProviders( diff --git a/tests/components/LoginForm.test.tsx b/tests/components/LoginForm.test.tsx index ce912b9..759c12e 100644 --- a/tests/components/LoginForm.test.tsx +++ b/tests/components/LoginForm.test.tsx @@ -1,4 +1,4 @@ -import React, { Suspense } from "react"; +import { Suspense } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -8,6 +8,8 @@ import LoginForm from "../../app/components/modals/Login/LoginForm"; const { navMock } = vi.hoisted(() => ({ navMock: { + /** Default: marketing route — header modal is the primary entry (not `/login`). */ + pathname: "/", searchParams: new URLSearchParams(), replace: vi.fn(), }, @@ -22,7 +24,7 @@ vi.mock("next/navigation", () => ({ back: vi.fn(), forward: vi.fn(), }), - usePathname: () => "/login", + usePathname: () => navMock.pathname, useSearchParams: () => navMock.searchParams, })); @@ -30,7 +32,19 @@ vi.mock("../../lib/create/api", () => ({ requestMagicLink: vi.fn(), })); +vi.mock("../../app/create/anonymousDraftStorage", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../app/create/anonymousDraftStorage") + >(); + return { + ...actual, + setTransferPendingFlag: vi.fn(), + }; +}); + import { requestMagicLink } from "../../lib/create/api"; +import { setTransferPendingFlag } from "../../app/create/anonymousDraftStorage"; function renderLoginForm() { return renderWithProviders( @@ -43,7 +57,9 @@ function renderLoginForm() { describe("LoginForm", () => { beforeEach(() => { vi.mocked(requestMagicLink).mockReset(); + vi.mocked(setTransferPendingFlag).mockReset(); navMock.replace.mockReset(); + navMock.pathname = "/"; navMock.searchParams = new URLSearchParams(); }); @@ -96,6 +112,33 @@ describe("LoginForm", () => { expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument(); }); + it("saveProgress variant uses magicLinkNextPath and sets transfer pending on success", async () => { + const user = userEvent.setup(); + vi.mocked(requestMagicLink).mockResolvedValue({ ok: true }); + renderWithProviders( + + + , + ); + await user.type( + screen.getByRole("textbox", { name: /email address/i }), + "save@example.com", + ); + await user.click( + screen.getByRole("button", { name: /send me a magic link/i }), + ); + await waitFor(() => { + expect(requestMagicLink).toHaveBeenCalledWith( + "save@example.com", + "/create/select?syncDraft=1", + ); + }); + expect(setTransferPendingFlag).toHaveBeenCalled(); + }); + it("passes safe next path when next query param is set", async () => { const user = userEvent.setup(); navMock.searchParams = new URLSearchParams("next=/learn"); @@ -158,8 +201,9 @@ describe("LoginForm", () => { ).toBeInTheDocument(); }); - it("calls router.replace to clear error query when user types", async () => { + it("calls router.replace to clear error query when user types (full-page /login)", async () => { const user = userEvent.setup(); + navMock.pathname = "/login"; navMock.searchParams = new URLSearchParams("error=expired_link"); renderLoginForm(); await user.type( @@ -169,6 +213,18 @@ describe("LoginForm", () => { expect(navMock.replace).toHaveBeenCalledWith("/login", { scroll: false }); }); + it("clears error query using current pathname when not on /login", async () => { + const user = userEvent.setup(); + navMock.pathname = "/learn"; + navMock.searchParams = new URLSearchParams("error=expired_link"); + renderLoginForm(); + await user.type( + screen.getByRole("textbox", { name: /email address/i }), + "x", + ); + expect(navMock.replace).toHaveBeenCalledWith("/learn", { scroll: false }); + }); + it("shows network error when request throws", async () => { const user = userEvent.setup(); vi.mocked(requestMagicLink).mockRejectedValue(new Error("network")); diff --git a/tests/unit/buildPublishPayload.test.ts b/tests/unit/buildPublishPayload.test.ts new file mode 100644 index 0000000..34d349b --- /dev/null +++ b/tests/unit/buildPublishPayload.test.ts @@ -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, + ], + }); + 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([]); + }); +}); diff --git a/tests/unit/draftHydrationUtils.test.ts b/tests/unit/draftHydrationUtils.test.ts new file mode 100644 index 0000000..de45a83 --- /dev/null +++ b/tests/unit/draftHydrationUtils.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from "vitest"; +import { createFlowStateHasKeys } from "../../lib/create/draftHydrationUtils"; + +describe("createFlowStateHasKeys", () => { + it("returns false for empty object", () => { + expect(createFlowStateHasKeys({})).toBe(false); + }); + + it("returns true when any key is present", () => { + expect(createFlowStateHasKeys({ title: "x" })).toBe(true); + expect(createFlowStateHasKeys({ currentStep: "text" })).toBe(true); + }); +}); diff --git a/tests/unit/hasCreateFlowUserInput.test.ts b/tests/unit/hasCreateFlowUserInput.test.ts new file mode 100644 index 0000000..3ccaee8 --- /dev/null +++ b/tests/unit/hasCreateFlowUserInput.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { hasCreateFlowUserInput } from "../../app/create/hasCreateFlowUserInput"; + +describe("hasCreateFlowUserInput", () => { + it("returns false for empty state", () => { + expect(hasCreateFlowUserInput({})).toBe(false); + }); + + it("ignores currentStep alone", () => { + expect(hasCreateFlowUserInput({ currentStep: "text" })).toBe(false); + }); + + it("returns true for non-empty title", () => { + expect(hasCreateFlowUserInput({ title: "My rule" })).toBe(true); + }); + + it("returns false for whitespace-only title", () => { + expect(hasCreateFlowUserInput({ title: " " })).toBe(false); + }); + + it("returns true for non-empty sections array", () => { + expect(hasCreateFlowUserInput({ sections: [{}] })).toBe(true); + }); + + it("returns false for empty sections array", () => { + expect(hasCreateFlowUserInput({ sections: [] })).toBe(false); + }); + + it("returns true for extra step-specific keys with content", () => { + expect(hasCreateFlowUserInput({ cards: ["a"] })).toBe(true); + }); + + it("returns false for extra keys with empty object", () => { + expect(hasCreateFlowUserInput({ foo: {} })).toBe(false); + }); +}); diff --git a/tests/unit/publishRule.test.ts b/tests/unit/publishRule.test.ts new file mode 100644 index 0000000..1663b41 --- /dev/null +++ b/tests/unit/publishRule.test.ts @@ -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.", + }); + }); +}); diff --git a/tests/unit/saveDraftToServer.test.ts b/tests/unit/saveDraftToServer.test.ts new file mode 100644 index 0000000..bf4af65 --- /dev/null +++ b/tests/unit/saveDraftToServer.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { saveDraftToServer } from "../../lib/create/api"; +import type { CreateFlowState } from "../../app/create/types"; + +const minimalState: CreateFlowState = {}; + +describe("saveDraftToServer", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns ok true on 200", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ draft: { payload: {}, updatedAt: "" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const result = await saveDraftToServer(minimalState); + expect(result).toEqual({ ok: true }); + }); + + it("returns message from validation error body", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { code: "validation_error", message: "Payload invalid" }, + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ), + ); + const result = await saveDraftToServer(minimalState); + expect(result).toEqual({ + ok: false, + message: "Payload invalid", + status: 400, + }); + }); + + it("returns message from 413 payload_too_large", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: "payload_too_large", + message: "Request body must be at most 524288 bytes", + }, + }), + { status: 413, headers: { "Content-Type": "application/json" } }, + ), + ); + const result = await saveDraftToServer(minimalState); + expect(result.ok).toBe(false); + if (result.ok === false) { + expect(result.message).toContain("524288"); + expect(result.status).toBe(413); + } + }); + + it("returns Unauthorized string from 401 legacy shape", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ); + const result = await saveDraftToServer(minimalState); + expect(result).toEqual({ + ok: false, + message: "Unauthorized", + status: 401, + }); + }); + + it("falls back when error body is not JSON", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response("not json", { + status: 500, + statusText: "Internal Server Error", + }), + ); + const result = await saveDraftToServer(minimalState); + expect(result).toEqual({ + ok: false, + message: "Internal Server Error", + status: 500, + }); + }); + + it("returns network message when fetch rejects", async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error("offline")); + const result = await saveDraftToServer(minimalState); + expect(result).toEqual({ + ok: false, + message: "Something went wrong. Check your connection and try again.", + }); + }); +}); diff --git a/tests/utils/test-utils.tsx b/tests/utils/test-utils.tsx index 83c1d35..ae71a46 100644 --- a/tests/utils/test-utils.tsx +++ b/tests/utils/test-utils.tsx @@ -1,19 +1,25 @@ import React, { type ReactElement } from "react"; import { render, type RenderOptions } from "@testing-library/react"; +import { AuthModalProvider } from "../../app/contexts/AuthModalContext"; import { MessagesProvider } from "../../app/contexts/MessagesContext"; +import { CreateFlowProvider } from "../../app/create/context/CreateFlowContext"; import messages from "../../messages/en/index"; /** - * Custom render function that wraps components with MessagesProvider - * Use this instead of the default render from @testing-library/react - * for components that use useTranslation hook + * Custom render function: MessagesProvider, AuthModalProvider (TopNav login), CreateFlowProvider. */ export function renderWithProviders( ui: ReactElement, options?: Omit, ) { function Wrapper({ children }: { children: React.ReactNode }) { - return {children}; + return ( + + + {children} + + + ); } return render(ui, { wrapper: Wrapper, ...options });