Merge pull request 'Create flow: session UI, draft sync, publish' (#44) from adilallo/feature/BackendImplementation2 into main
Reviewed-on: #44
This commit was merged in pull request #44.
This commit is contained in:
+2
-2
@@ -25,7 +25,7 @@ Use `npx prisma studio` to inspect the database.
|
||||
| GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const LoginContainer = memo<LoginProps>(
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
usePortal = true,
|
||||
backdropVariant = "blurredYellow",
|
||||
}) => {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
@@ -126,6 +127,7 @@ const LoginContainer = memo<LoginProps>(
|
||||
backdropRef={backdropRef}
|
||||
portalReady={portalReady}
|
||||
usePortal={usePortal}
|
||||
backdropVariant={backdropVariant}
|
||||
>
|
||||
{children}
|
||||
</LoginView>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<LoginBackdropVariant, string> = {
|
||||
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 = (
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className="fixed inset-0 z-[9998] flex flex-col items-center justify-center gap-6 overflow-y-auto bg-[var(--color-surface-inverse-brand-primary)] px-4 py-8"
|
||||
className={`fixed inset-0 z-[9998] flex flex-col items-center justify-center gap-6 overflow-y-auto px-4 py-8 ${backdropClasses[backdropVariant]}`}
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-6 pt-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="relative flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-inverse-brand-primary)]">
|
||||
<div
|
||||
className={`relative flex h-12 w-12 shrink-0 items-center justify-center rounded-full ${
|
||||
isSaveProgress
|
||||
? "bg-[#fefcc9]"
|
||||
: "bg-[var(--color-surface-inverse-brand-primary)]"
|
||||
}`}
|
||||
>
|
||||
<MailIconInline />
|
||||
</div>
|
||||
<ContentLockup
|
||||
titleId="login-modal-heading"
|
||||
title={sent ? t("successTitle") : t("title")}
|
||||
description={sent ? t("successBody") : t("subtitle")}
|
||||
titleId={titleId}
|
||||
title={
|
||||
sent
|
||||
? t("successTitle")
|
||||
: isSaveProgress
|
||||
? t("saveProgressTitle")
|
||||
: t("title")
|
||||
}
|
||||
description={
|
||||
sent
|
||||
? t("successBody")
|
||||
: isSaveProgress
|
||||
? t("saveProgressSubtitle")
|
||||
: t("subtitle")
|
||||
}
|
||||
variant="login"
|
||||
alignment="left"
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
const MenuBarItemContainer = memo<MenuBarItemProps>(
|
||||
({
|
||||
href = "#",
|
||||
buttonOnClick,
|
||||
children,
|
||||
state: stateProp,
|
||||
mode: modeProp,
|
||||
@@ -112,6 +113,7 @@ const MenuBarItemContainer = memo<MenuBarItemProps>(
|
||||
return (
|
||||
<MenuBarItemView
|
||||
href={href}
|
||||
buttonOnClick={buttonOnClick}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
combinedStyles={combinedStyles}
|
||||
|
||||
@@ -11,6 +11,8 @@ export type MenuBarItemModeValue = "default" | "inverse";
|
||||
|
||||
export interface MenuBarItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
href?: string;
|
||||
/** When set, renders a `<button type="button">` instead of a link (e.g. open login modal). */
|
||||
buttonOnClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* Menu bar item state: "default", "hover", or "selected".
|
||||
@@ -45,9 +47,12 @@ export interface MenuBarItemProps extends React.AnchorHTMLAttributes<HTMLAnchorE
|
||||
|
||||
export interface MenuBarItemViewProps {
|
||||
href: string;
|
||||
buttonOnClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
disabled: boolean;
|
||||
className: string;
|
||||
combinedStyles: string;
|
||||
accessibilityProps: React.HTMLAttributes<HTMLAnchorElement | HTMLSpanElement>;
|
||||
accessibilityProps: React.HTMLAttributes<
|
||||
HTMLAnchorElement | HTMLSpanElement | HTMLButtonElement
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MenuBarItemViewProps } from "./MenuBarItem.types";
|
||||
|
||||
function MenuBarItemView({
|
||||
href,
|
||||
buttonOnClick,
|
||||
children,
|
||||
disabled,
|
||||
combinedStyles,
|
||||
@@ -16,6 +17,19 @@ function MenuBarItemView({
|
||||
);
|
||||
}
|
||||
|
||||
if (buttonOnClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={combinedStyles}
|
||||
onClick={buttonOnClick}
|
||||
{...accessibilityProps}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={href} className={combinedStyles} {...accessibilityProps}>
|
||||
{children}
|
||||
|
||||
@@ -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<TopNavProps>(
|
||||
({ 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<TopNavProps>(
|
||||
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<TopNavProps>(
|
||||
(loggedIn && pathname === "/profile") ||
|
||||
(!loggedIn && pathname === "/login");
|
||||
|
||||
if (loggedIn) {
|
||||
return (
|
||||
<MenuBarItem
|
||||
href="/profile"
|
||||
size={sizeMap[size] || "Small"}
|
||||
mode={mode}
|
||||
state={navSelected ? "selected" : "default"}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{label}
|
||||
</MenuBarItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuBarItem
|
||||
href={href}
|
||||
buttonOnClick={() =>
|
||||
openLogin({
|
||||
variant: "default",
|
||||
backdropVariant: "blurredYellow",
|
||||
nextPath: pathname || "/",
|
||||
})
|
||||
}
|
||||
href="/login"
|
||||
size={sizeMap[size] || "Small"}
|
||||
mode={mode}
|
||||
state={navSelected ? "selected" : "default"}
|
||||
|
||||
@@ -10,7 +10,7 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
||||
hasShare = false,
|
||||
hasExport = false,
|
||||
hasEdit = false,
|
||||
loggedIn = false,
|
||||
saveDraftOnExit = false,
|
||||
onShare,
|
||||
onExport,
|
||||
onEdit,
|
||||
@@ -34,7 +34,7 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
||||
hasShare={hasShare}
|
||||
hasExport={hasExport}
|
||||
hasEdit={hasEdit}
|
||||
loggedIn={loggedIn}
|
||||
saveDraftOnExit={saveDraftOnExit}
|
||||
onShare={onShare}
|
||||
onExport={onExport}
|
||||
onEdit={onEdit}
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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 (
|
||||
<header
|
||||
@@ -27,11 +34,9 @@ export function CreateFlowTopNavView({
|
||||
role="navigation"
|
||||
aria-label="Create Flow Navigation"
|
||||
>
|
||||
{/* Logo - Left */}
|
||||
<Logo size="createFlow" wordmark palette={buttonPalette} />
|
||||
|
||||
{/* Button Group - Right */}
|
||||
<div className="flex items-center gap-[var(--spacing-scale-012,12px)]">
|
||||
<div className="flex flex-wrap items-center justify-end gap-[var(--spacing-scale-012,12px)]">
|
||||
{hasShare && (
|
||||
<Button
|
||||
buttonType="outline"
|
||||
@@ -89,9 +94,10 @@ export function CreateFlowTopNavView({
|
||||
buttonType="outline"
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
onClick={() => onExit?.({ saveDraft: loggedIn })}
|
||||
type="button"
|
||||
onClick={() => void onExit?.({ saveDraft: saveDraftOnExit })}
|
||||
ariaLabel={exitButtonText}
|
||||
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
|
||||
className={`md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !py-[6px] md:!py-[8px] shrink-0 ${exitButtonFigmaClass}`}
|
||||
>
|
||||
{exitButtonText}
|
||||
</Button>
|
||||
|
||||
@@ -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<AuthModalContextValue | null>(null);
|
||||
|
||||
export function AuthModalProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [opts, setOpts] = useState<OpenLoginOptions>({});
|
||||
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 (
|
||||
<AuthModalContext.Provider value={value}>
|
||||
{children}
|
||||
<Login
|
||||
isOpen={open}
|
||||
onClose={closeLogin}
|
||||
backdropVariant={backdropVariant}
|
||||
usePortal
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
belowCard={
|
||||
<Link
|
||||
href="/"
|
||||
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
||||
onClick={() => closeLogin()}
|
||||
>
|
||||
{t("backToHome")}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<LoginForm
|
||||
variant={opts.variant ?? "default"}
|
||||
magicLinkNextPath={opts.nextPath}
|
||||
/>
|
||||
</Login>
|
||||
</AuthModalContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuthModal(): AuthModalContextValue {
|
||||
const ctx = useContext(AuthModalContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuthModal must be used within AuthModalProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
||||
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
||||
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
|
||||
import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
|
||||
import { getStepIndex } from "./utils/flowSteps";
|
||||
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
|
||||
import Button from "../components/buttons/Button";
|
||||
import { buildPublishPayload } from "../../lib/create/buildPublishPayload";
|
||||
import { fetchAuthSession, publishRule } from "../../lib/create/api";
|
||||
import { writeLastPublishedRule } from "../../lib/create/lastPublishedRule";
|
||||
import messages from "../../messages/en/index";
|
||||
import { useAuthModal } from "../contexts/AuthModalContext";
|
||||
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
|
||||
import { SignedInDraftHydration } from "./SignedInDraftHydration";
|
||||
import Alert from "../components/modals/Alert";
|
||||
import {
|
||||
CreateFlowDraftSaveBannerProvider,
|
||||
useCreateFlowDraftSaveBanner,
|
||||
} from "./context/CreateFlowDraftSaveBannerContext";
|
||||
|
||||
/** First step where Save & Exit is offered (after informational + name / `text`). */
|
||||
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select");
|
||||
|
||||
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
|
||||
const [sessionUser, setSessionUser] = useState<
|
||||
{ id: string; email: string } | null | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchAuthSession().then(({ user }) => {
|
||||
if (!cancelled) setSessionUser(user);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sessionResolved = sessionUser !== undefined;
|
||||
const enableAnonymousPersistence = sessionResolved && sessionUser === null;
|
||||
|
||||
return (
|
||||
<CreateFlowProvider enableAnonymousPersistence={enableAnonymousPersistence}>
|
||||
<CreateFlowDraftSaveBannerProvider>
|
||||
<CreateFlowLayoutContent
|
||||
sessionUser={sessionUser}
|
||||
sessionResolved={sessionResolved}
|
||||
>
|
||||
{children}
|
||||
</CreateFlowLayoutContent>
|
||||
</CreateFlowDraftSaveBannerProvider>
|
||||
</CreateFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateFlowLayoutContent({
|
||||
children,
|
||||
sessionUser,
|
||||
sessionResolved,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
sessionUser: { id: string; email: string } | null | undefined;
|
||||
sessionResolved: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { openLogin } = useAuthModal();
|
||||
const {
|
||||
currentStep,
|
||||
nextStep,
|
||||
previousStep,
|
||||
goToNextStep,
|
||||
goToPreviousStep,
|
||||
} = useCreateFlowNavigation();
|
||||
const { state, clearState } = useCreateFlow();
|
||||
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
||||
useCreateFlowDraftSaveBanner();
|
||||
const [publishBannerMessage, setPublishBannerMessage] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
|
||||
const handleFinalize = useCallback(async () => {
|
||||
setPublishBannerMessage(null);
|
||||
const payloadResult = buildPublishPayload(state);
|
||||
if (payloadResult.ok === false) {
|
||||
setPublishBannerMessage(
|
||||
payloadResult.error === "missingCommunityName"
|
||||
? messages.create.publish.missingCommunityName
|
||||
: payloadResult.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { title, summary, document: ruleDocument } = payloadResult;
|
||||
setIsPublishing(true);
|
||||
const publishResult = await publishRule({
|
||||
title,
|
||||
summary,
|
||||
document: ruleDocument,
|
||||
});
|
||||
setIsPublishing(false);
|
||||
if (publishResult.ok === true) {
|
||||
writeLastPublishedRule({
|
||||
id: publishResult.id,
|
||||
title,
|
||||
summary: summary ?? null,
|
||||
document: ruleDocument,
|
||||
});
|
||||
router.push("/create/completed");
|
||||
return;
|
||||
}
|
||||
if (publishResult.status === 401) {
|
||||
openLogin({
|
||||
variant: "default",
|
||||
nextPath: "/create/final-review?syncDraft=1",
|
||||
backdropVariant: "blurredYellow",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPublishBannerMessage(
|
||||
publishResult.error.trim() !== ""
|
||||
? publishResult.error
|
||||
: messages.create.publish.genericPublishFailed,
|
||||
);
|
||||
}, [state, router, openLogin]);
|
||||
|
||||
const runAuthenticatedExit = useCreateFlowExit({
|
||||
state,
|
||||
currentStep,
|
||||
clearState,
|
||||
router,
|
||||
user: sessionUser ?? null,
|
||||
setDraftSaveBannerMessage,
|
||||
});
|
||||
|
||||
const handleExit = async (opts?: { saveDraft?: boolean }) => {
|
||||
const saveDraft = opts?.saveDraft ?? false;
|
||||
if (!sessionResolved) return;
|
||||
|
||||
if (sessionUser === null) {
|
||||
if (saveDraft) return;
|
||||
openLogin({
|
||||
variant: "saveProgress",
|
||||
nextPath: `${pathname ?? "/create/informational"}?syncDraft=1`,
|
||||
backdropVariant: "blurredYellow",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionUser) return;
|
||||
await runAuthenticatedExit(opts);
|
||||
};
|
||||
|
||||
const isCompletedStep = currentStep === "completed";
|
||||
const isRightRailStep = currentStep === "right-rail";
|
||||
const useFullHeightMain = isCompletedStep || isRightRailStep;
|
||||
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
|
||||
const saveDraftOnExit =
|
||||
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
|
||||
|
||||
const hasErrorOverlays =
|
||||
Boolean(draftSaveBannerMessage) || Boolean(publishBannerMessage);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
|
||||
{hasErrorOverlays ? (
|
||||
<div
|
||||
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]"
|
||||
aria-live="polite"
|
||||
>
|
||||
{draftSaveBannerMessage ? (
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
title={messages.create.topNav.draftSaveBannerTitle}
|
||||
description={draftSaveBannerMessage}
|
||||
onClose={() => setDraftSaveBannerMessage(null)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{publishBannerMessage ? (
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
title={messages.create.publish.finalizeBannerTitle}
|
||||
description={publishBannerMessage}
|
||||
onClose={() => setPublishBannerMessage(null)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Suspense fallback={null}>
|
||||
<SignedInDraftHydration
|
||||
sessionUser={sessionUser}
|
||||
sessionResolved={sessionResolved}
|
||||
/>
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<PostLoginDraftTransfer sessionUser={sessionUser} />
|
||||
</Suspense>
|
||||
<CreateFlowTopNav
|
||||
hasShare={isCompletedStep}
|
||||
hasExport={isCompletedStep}
|
||||
hasEdit={isCompletedStep}
|
||||
saveDraftOnExit={saveDraftOnExit}
|
||||
onEdit={
|
||||
isCompletedStep
|
||||
? () => router.push("/create/final-review")
|
||||
: undefined
|
||||
}
|
||||
onExit={(opts) => void handleExit(opts)}
|
||||
buttonPalette={isCompletedStep ? "inverse" : undefined}
|
||||
className={`shrink-0 ${
|
||||
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
|
||||
}`.trim()}
|
||||
/>
|
||||
<main
|
||||
className={`flex min-h-0 flex-1 justify-center ${
|
||||
useFullHeightMain
|
||||
? isCompletedStep
|
||||
? "items-stretch overflow-y-auto sm:overflow-hidden"
|
||||
: "items-stretch overflow-hidden"
|
||||
: "flex-row items-center justify-center overflow-y-auto"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
{!isCompletedStep && (
|
||||
<CreateFlowFooter
|
||||
className="shrink-0"
|
||||
secondButton={
|
||||
nextStep ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"
|
||||
onClick={() => {
|
||||
if (currentStep === "final-review") {
|
||||
void handleFinalize();
|
||||
} else {
|
||||
goToNextStep();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{currentStep === "final-review"
|
||||
? isPublishing
|
||||
? messages.create.publish.finalizeButtonPublishing
|
||||
: "Finalize CommunityRule"
|
||||
: currentStep === "confirm-stakeholders"
|
||||
? "Confirm Stakeholders"
|
||||
: "Next"}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
onBackClick={previousStep ? goToPreviousStep : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CreateFlowLayoutClient({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <CreateFlowSessionShell>{children}</CreateFlowSessionShell>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const CreateFlowLayoutClient = dynamic(
|
||||
() => import("./CreateFlowLayoutClient"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className="flex h-screen min-h-0 flex-col overflow-hidden bg-black"
|
||||
aria-busy="true"
|
||||
aria-label="Loading create flow"
|
||||
/>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export default function CreateFlowLayoutGate({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <CreateFlowLayoutClient>{children}</CreateFlowLayoutClient>;
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<div
|
||||
role="alert"
|
||||
className="mx-auto max-w-[640px] px-5 py-3 text-center font-inter text-sm text-[var(--color-border-default-utility-negative)]"
|
||||
>
|
||||
{transferError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="w-full shrink-0 px-[var(--spacing-measures-spacing-500,20px)] py-[var(--spacing-measures-spacing-200,8px)] md:px-[var(--measures-spacing-1800,64px)] text-center font-inter text-sm text-[var(--color-text-default-secondary,#a3a3a3)]"
|
||||
>
|
||||
{messages.create.draftHydration.loadingSavedProgress}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<SectionKey, string>
|
||||
@@ -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<string[]>([]);
|
||||
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 (
|
||||
<div className="w-full max-w-[1280px] shrink-0 px-5 md:px-16">
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,12 @@ import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import CommunityRuleDocument from "../../components/sections/CommunityRuleDocument";
|
||||
import type { CommunityRuleDocumentSection } from "../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
|
||||
import Alert from "../../components/modals/Alert";
|
||||
import { parseDocumentSectionsForDisplay } from "../../../lib/create/buildPublishPayload";
|
||||
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||
|
||||
const TITLE = "Mutual Aid Mondays";
|
||||
const DESCRIPTION =
|
||||
/** Demo copy when `/create/completed` is opened without a prior publish in this tab. */
|
||||
const FALLBACK_TITLE = "Mutual Aid Mondays";
|
||||
const FALLBACK_DESCRIPTION =
|
||||
"Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.";
|
||||
|
||||
const TOAST_TITLE = "This is what folks see when you share your CommunityRule";
|
||||
@@ -91,6 +94,12 @@ const COMPLETED_RULE_SECTIONS: CommunityRuleDocumentSection[] = [
|
||||
export default function CompletedPage() {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [toastDismissed, setToastDismissed] = useState(false);
|
||||
const [headerTitle, setHeaderTitle] = useState(FALLBACK_TITLE);
|
||||
const [headerDescription, setHeaderDescription] = useState<
|
||||
string | undefined
|
||||
>(FALLBACK_DESCRIPTION);
|
||||
const [documentSections, setDocumentSections] =
|
||||
useState<CommunityRuleDocumentSection[]>(COMPLETED_RULE_SECTIONS);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,6 +107,18 @@ export default function CompletedPage() {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readLastPublishedRule();
|
||||
if (!stored) return;
|
||||
const parsed = parseDocumentSectionsForDisplay(stored.document);
|
||||
if (parsed.length === 0) return;
|
||||
setDocumentSections(parsed);
|
||||
setHeaderTitle(stored.title);
|
||||
const sum =
|
||||
typeof stored.summary === "string" ? stored.summary.trim() : "";
|
||||
setHeaderDescription(sum.length > 0 ? sum : undefined);
|
||||
}, []);
|
||||
|
||||
const showDesktopLayout = !isMounted || isMdOrLarger;
|
||||
|
||||
if (showDesktopLayout) {
|
||||
@@ -108,8 +129,8 @@ export default function CompletedPage() {
|
||||
{/* Left column: community title + header, centered, does not scroll */}
|
||||
<div className="flex min-w-0 flex-col justify-center overflow-hidden py-8">
|
||||
<HeaderLockup
|
||||
title={TITLE}
|
||||
description={DESCRIPTION}
|
||||
title={headerTitle}
|
||||
description={headerDescription}
|
||||
justification="left"
|
||||
size="L"
|
||||
palette="inverse"
|
||||
@@ -124,7 +145,7 @@ export default function CompletedPage() {
|
||||
/>
|
||||
<div className="py-8 min-w-0">
|
||||
<CommunityRuleDocument
|
||||
sections={COMPLETED_RULE_SECTIONS}
|
||||
sections={documentSections}
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
@@ -159,14 +180,14 @@ export default function CompletedPage() {
|
||||
<div className="w-full flex flex-col items-center px-5 min-w-0 bg-[var(--color-teal-teal50,#c9fef9)] py-8">
|
||||
<div className="flex flex-col gap-4 w-full max-w-[639px]">
|
||||
<HeaderLockup
|
||||
title={TITLE}
|
||||
description={DESCRIPTION}
|
||||
title={headerTitle}
|
||||
description={headerDescription}
|
||||
justification="left"
|
||||
size="M"
|
||||
palette="inverse"
|
||||
/>
|
||||
<CommunityRuleDocument
|
||||
sections={COMPLETED_RULE_SECTIONS}
|
||||
sections={documentSections}
|
||||
useCardStyle
|
||||
className="w-full p-4"
|
||||
/>
|
||||
|
||||
@@ -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<ChipOption[]>(
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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;
|
||||
}
|
||||
@@ -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<CreateFlowContextValue | null>(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<CreateFlowState>(() =>
|
||||
readStateFromStorage(STORAGE_KEY),
|
||||
enableAnonymousPersistence ? readAnonymousCreateFlowState() : {},
|
||||
);
|
||||
const [interactionTouched, setInteractionTouched] = useState(false);
|
||||
const [currentStep] = useState<CreateFlowStep | null>(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<CreateFlowState>) => {
|
||||
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) {
|
||||
|
||||
@@ -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<CreateFlowDraftSaveBannerContextValue | null>(null);
|
||||
|
||||
export function CreateFlowDraftSaveBannerProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [draftSaveBannerMessage, setDraftSaveBannerMessage] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
draftSaveBannerMessage,
|
||||
setDraftSaveBannerMessage,
|
||||
}),
|
||||
[draftSaveBannerMessage],
|
||||
);
|
||||
|
||||
return (
|
||||
<CreateFlowDraftSaveBannerContext.Provider value={value}>
|
||||
{children}
|
||||
</CreateFlowDraftSaveBannerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCreateFlowDraftSaveBanner(): CreateFlowDraftSaveBannerContextValue {
|
||||
const ctx = useContext(CreateFlowDraftSaveBannerContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useCreateFlowDraftSaveBanner must be used within CreateFlowDraftSaveBannerProvider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import RuleCard from "../../components/cards/RuleCard";
|
||||
import type { Category } from "../../components/cards/RuleCard/RuleCard.types";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
const TITLE = "Review your CommunityRule";
|
||||
const DESCRIPTION =
|
||||
"Here's what other people will see. Make sure everything looks good before you finalize everything. Once the rule is finalized, you must use one of your decision-making mechanisms to edit it again.";
|
||||
|
||||
const RULE_CARD_TITLE = "Mutual Aid Mondays";
|
||||
const RULE_CARD_DESCRIPTION =
|
||||
"Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.";
|
||||
const RULE_CARD_TITLE_FALLBACK = "Your community";
|
||||
const RULE_CARD_DESCRIPTION_FALLBACK =
|
||||
"Add a short description of your community on earlier steps when that field is available. For now, this card shows your community name.";
|
||||
|
||||
/** Static categories for final review (read-only display). */
|
||||
const FINAL_REVIEW_CATEGORIES: Category[] = [
|
||||
@@ -55,9 +56,20 @@ const FINAL_REVIEW_CATEGORIES: Category[] = [
|
||||
* Figma: 20907-212767 (full-size), 20976-220705 (small breakpoint).
|
||||
*/
|
||||
export default function FinalReviewPage() {
|
||||
const { state } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
const ruleCardTitle = useMemo(() => {
|
||||
const t = typeof state.title === "string" ? state.title.trim() : "";
|
||||
return t.length > 0 ? t : RULE_CARD_TITLE_FALLBACK;
|
||||
}, [state.title]);
|
||||
|
||||
const ruleCardDescription = useMemo(() => {
|
||||
const s = typeof state.summary === "string" ? state.summary.trim() : "";
|
||||
return s.length > 0 ? s : RULE_CARD_DESCRIPTION_FALLBACK;
|
||||
}, [state.summary]);
|
||||
|
||||
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
@@ -80,13 +92,13 @@ export default function FinalReviewPage() {
|
||||
</div>
|
||||
<div className="min-w-0 w-full flex flex-col items-stretch">
|
||||
<RuleCard
|
||||
title={RULE_CARD_TITLE}
|
||||
description={RULE_CARD_DESCRIPTION}
|
||||
title={ruleCardTitle}
|
||||
description={ruleCardDescription}
|
||||
size="L"
|
||||
expanded={true}
|
||||
backgroundColor="bg-[#c9fef9]"
|
||||
logoUrl="/assets/Vector_MutualAid.svg"
|
||||
logoAlt={RULE_CARD_TITLE}
|
||||
logoAlt={ruleCardTitle}
|
||||
categories={FINAL_REVIEW_CATEGORIES}
|
||||
className="rounded-[24px] !max-w-full !w-full min-w-0"
|
||||
onClick={() => {}}
|
||||
@@ -107,13 +119,13 @@ export default function FinalReviewPage() {
|
||||
size="M"
|
||||
/>
|
||||
<RuleCard
|
||||
title={RULE_CARD_TITLE}
|
||||
description={RULE_CARD_DESCRIPTION}
|
||||
title={ruleCardTitle}
|
||||
description={ruleCardDescription}
|
||||
size="L"
|
||||
expanded={true}
|
||||
backgroundColor="bg-[#c9fef9]"
|
||||
logoUrl="/assets/Vector_MutualAid.svg"
|
||||
logoAlt={RULE_CARD_TITLE}
|
||||
logoAlt={ruleCardTitle}
|
||||
categories={FINAL_REVIEW_CATEGORIES}
|
||||
className="w-full rounded-[12px] p-4"
|
||||
onClick={() => {}}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { CreateFlowState } from "./types";
|
||||
|
||||
const IGNORED_KEYS = new Set<string>(["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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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],
|
||||
);
|
||||
}
|
||||
+3
-112
@@ -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 (
|
||||
<div
|
||||
className={`bg-black flex flex-col ${useFullHeightMain ? "h-screen overflow-hidden" : "min-h-screen"}`}
|
||||
>
|
||||
<CreateFlowTopNav
|
||||
hasShare={isCompletedStep}
|
||||
hasExport={isCompletedStep}
|
||||
hasEdit={isCompletedStep}
|
||||
loggedIn={isCompletedStep}
|
||||
onEdit={
|
||||
isCompletedStep
|
||||
? () => router.push("/create/final-review")
|
||||
: undefined
|
||||
}
|
||||
onExit={handleExit}
|
||||
buttonPalette={isCompletedStep ? "inverse" : undefined}
|
||||
className={
|
||||
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : undefined
|
||||
}
|
||||
/>
|
||||
<main
|
||||
className={`flex-1 flex min-h-0 justify-center ${useFullHeightMain ? "items-stretch overflow-hidden" : "items-center overflow-auto"}`}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
{!isCompletedStep && (
|
||||
<CreateFlowFooter
|
||||
secondButton={
|
||||
nextStep ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"
|
||||
onClick={goToNextStep}
|
||||
>
|
||||
{currentStep === "final-review"
|
||||
? "Finalize CommunityRule"
|
||||
: currentStep === "confirm-stakeholders"
|
||||
? "Confirm Stakeholders"
|
||||
: "Next"}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
onBackClick={previousStep ? goToPreviousStep : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CreateFlowLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<CreateFlowProvider>
|
||||
<CreateFlowBackendSync />
|
||||
<CreateFlowLayoutContent>{children}</CreateFlowLayoutContent>
|
||||
</CreateFlowProvider>
|
||||
);
|
||||
export default function CreateFlowLayout({ children }: { children: ReactNode }) {
|
||||
return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>;
|
||||
}
|
||||
|
||||
@@ -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<string[]>(
|
||||
@@ -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 (
|
||||
|
||||
+40
-12
@@ -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<SetStateAction<ChipOption[]>>,
|
||||
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
|
||||
|
||||
@@ -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() {
|
||||
<TextInput
|
||||
placeholder="Enter your community name"
|
||||
value={value}
|
||||
onChange={(e) => 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}`}
|
||||
|
||||
+7
-1
@@ -47,8 +47,14 @@ export interface CreateFlowContextValue {
|
||||
updateState: (_updates: Partial<CreateFlowState>) => 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
|
||||
+8
-5
@@ -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}`}
|
||||
>
|
||||
<MessagesProvider messages={messages}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<ConditionalNavigation />
|
||||
<main className="flex-1">{children}</main>
|
||||
<ConditionalFooter />
|
||||
</div>
|
||||
<AuthModalProvider>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<ConditionalNavigation />
|
||||
<main className="flex-1">{children}</main>
|
||||
<ConditionalFooter />
|
||||
</div>
|
||||
</AuthModalProvider>
|
||||
</MessagesProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]">
|
||||
<Login
|
||||
isOpen
|
||||
usePortal={false}
|
||||
onClose={() => {
|
||||
router.push("/");
|
||||
}}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
belowCard={
|
||||
<Link
|
||||
href="/"
|
||||
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
||||
>
|
||||
{t("backToHome")}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<LoginForm />
|
||||
</Login>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+46
-10
@@ -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 (
|
||||
<div className="min-h-screen bg-[var(--color-surface-inverse-brand-primary)] flex items-center justify-center">
|
||||
<div className={`${loginPageBgClass} flex items-center justify-center`}>
|
||||
<p className="font-inter text-[14px] text-[var(--color-content-default-primary)]">
|
||||
Loading…
|
||||
</p>
|
||||
@@ -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 (
|
||||
<div className={loginPageBgClass}>
|
||||
<Login
|
||||
isOpen
|
||||
usePortal={false}
|
||||
backdropVariant="solid"
|
||||
onClose={() => {
|
||||
router.push("/");
|
||||
}}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
belowCard={
|
||||
<Link
|
||||
href="/"
|
||||
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
||||
>
|
||||
{t("backToHome")}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<LoginForm />
|
||||
</Login>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoginFallback />}>
|
||||
<LoginPageClient />
|
||||
<Suspense fallback={<LoginLoadingFallback />}>
|
||||
<LoginWithSearchParams />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-2xl px-4 py-16 md:py-24">
|
||||
@@ -13,6 +36,20 @@ export default function ProfilePageClient() {
|
||||
<p className="mt-4 font-inter text-lg leading-relaxed text-[var(--color-content-default-secondary)]">
|
||||
{t("placeholderBody")}
|
||||
</p>
|
||||
{loaded && user ? (
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={() => void handleSignOut()}
|
||||
ariaLabel={t("signOut")}
|
||||
>
|
||||
{t("signOut")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<boolean>` 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.
|
||||
|
||||
+12
-7
@@ -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.
|
||||
|
||||
+97
-26
@@ -80,39 +80,110 @@ export async function fetchDraftFromServer(): Promise<CreateFlowState | null> {
|
||||
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<unknown> {
|
||||
const text = await res.text();
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type SaveDraftResult =
|
||||
| { ok: true }
|
||||
| { ok: false; message: string; status?: number };
|
||||
|
||||
async function errorBodyMessage(res: Response): Promise<string> {
|
||||
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<boolean> {
|
||||
const res = await fetch("/api/drafts/me", {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ payload: state }),
|
||||
});
|
||||
return res.ok;
|
||||
): Promise<SaveDraftResult> {
|
||||
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<string, unknown>;
|
||||
}): Promise<{ ok: true; id: string; title: string } | { error: string }> {
|
||||
const res = await fetch("/api/rules", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({
|
||||
title: input.title,
|
||||
summary: input.summary,
|
||||
document: input.document,
|
||||
}),
|
||||
});
|
||||
const data = await parseJson<{
|
||||
error?: string;
|
||||
rule?: { id: string; title: string };
|
||||
}>(res);
|
||||
if (!res.ok || !data.rule) {
|
||||
return { error: readApiErrorMessage(data) };
|
||||
}): Promise<
|
||||
| { ok: true; id: string; title: string }
|
||||
| { ok: false; error: string; status?: number }
|
||||
> {
|
||||
try {
|
||||
const res = await fetch("/api/rules", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({
|
||||
title: input.title,
|
||||
summary: input.summary,
|
||||
document: input.document,
|
||||
}),
|
||||
});
|
||||
const data = (await safeParseJsonResponse(res)) as {
|
||||
error?: string | { message?: string };
|
||||
rule?: { id: string; title: string };
|
||||
} | null;
|
||||
const rule = data && typeof data === "object" ? data.rule : undefined;
|
||||
if (!res.ok || !rule) {
|
||||
const fromBody =
|
||||
data && typeof data === "object" ? readApiErrorMessage(data) : null;
|
||||
const msg =
|
||||
fromBody && fromBody !== "Request failed"
|
||||
? fromBody
|
||||
: res.statusText?.trim() || PUBLISH_FAILED_FALLBACK;
|
||||
return {
|
||||
ok: false as const,
|
||||
error: msg,
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
return { ok: true, id: rule.id, title: rule.title };
|
||||
} catch {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: DRAFT_SAVE_NETWORK_ERROR,
|
||||
};
|
||||
}
|
||||
return { ok: true, id: data.rule.id, title: data.rule.title };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { CreateFlowState } from "../../app/create/types";
|
||||
import type { CommunityRuleDocumentSection } from "../../app/components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
|
||||
|
||||
function isDocumentEntry(x: unknown): x is { title: string; body: string } {
|
||||
if (!x || typeof x !== "object") return false;
|
||||
const o = x as Record<string, unknown>;
|
||||
return typeof o.title === "string" && typeof o.body === "string";
|
||||
}
|
||||
|
||||
function isDocumentSection(x: unknown): x is CommunityRuleDocumentSection {
|
||||
if (!x || typeof x !== "object") return false;
|
||||
const o = x as Record<string, unknown>;
|
||||
if (typeof o.categoryName !== "string") return false;
|
||||
if (!Array.isArray(o.entries)) return false;
|
||||
return o.entries.every(isDocumentEntry);
|
||||
}
|
||||
|
||||
/** Narrow `CreateFlowState.sections` into Community Rule document sections. */
|
||||
export function parseSectionsFromCreateFlowState(
|
||||
state: CreateFlowState,
|
||||
): CommunityRuleDocumentSection[] {
|
||||
const raw = state.sections;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: CommunityRuleDocumentSection[] = [];
|
||||
for (const x of raw) {
|
||||
if (isDocumentSection(x)) out.push(x);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type BuildPublishPayloadResult =
|
||||
| {
|
||||
ok: true;
|
||||
title: string;
|
||||
summary?: string;
|
||||
document: Record<string, unknown>;
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
const FALLBACK_CATEGORY = "Overview";
|
||||
|
||||
const DEFAULT_FALLBACK_BODY =
|
||||
"This CommunityRule was created in the create flow. Add more detail in a future edit.";
|
||||
|
||||
export function buildPublishPayload(
|
||||
state: CreateFlowState,
|
||||
): BuildPublishPayloadResult {
|
||||
const title = typeof state.title === "string" ? state.title.trim() : "";
|
||||
if (!title) {
|
||||
return { ok: false, error: "missingCommunityName" };
|
||||
}
|
||||
|
||||
let summary: string | undefined;
|
||||
if (typeof state.summary === "string") {
|
||||
const t = state.summary.trim();
|
||||
if (t.length > 0) summary = t;
|
||||
}
|
||||
|
||||
let sections = parseSectionsFromCreateFlowState(state);
|
||||
if (sections.length === 0) {
|
||||
const body = summary ?? DEFAULT_FALLBACK_BODY;
|
||||
sections = [
|
||||
{
|
||||
categoryName: FALLBACK_CATEGORY,
|
||||
entries: [{ title: "Community", body }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (summary !== undefined) {
|
||||
return { ok: true, title, summary, document: { sections } };
|
||||
}
|
||||
return { ok: true, title, document: { sections } };
|
||||
}
|
||||
|
||||
/** Read `document.sections` from a stored published payload for display. */
|
||||
export function parseDocumentSectionsForDisplay(
|
||||
document: unknown,
|
||||
): CommunityRuleDocumentSection[] {
|
||||
if (!document || typeof document !== "object") return [];
|
||||
const sections = (document as Record<string, unknown>).sections;
|
||||
if (!Array.isArray(sections)) return [];
|
||||
return sections.filter(isDocumentSection);
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Bridges final-review → completed without query strings.
|
||||
* Replace with GET /api/rules/[id] (CR-81) when public rule fetch exists.
|
||||
*/
|
||||
export const CREATE_FLOW_LAST_PUBLISHED_KEY = "createFlow.lastPublished";
|
||||
|
||||
export type StoredLastPublishedRule = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string | null;
|
||||
document: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function writeLastPublishedRule(data: StoredLastPublishedRule): void {
|
||||
if (typeof sessionStorage === "undefined") return;
|
||||
sessionStorage.setItem(CREATE_FLOW_LAST_PUBLISHED_KEY, JSON.stringify(data));
|
||||
}
|
||||
|
||||
export function readLastPublishedRule(): StoredLastPublishedRule | null {
|
||||
if (typeof sessionStorage === "undefined") return null;
|
||||
const raw = sessionStorage.getItem(CREATE_FLOW_LAST_PUBLISHED_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const o = parsed as Record<string, unknown>;
|
||||
if (typeof o.id !== "string" || typeof o.title !== "string") return null;
|
||||
const doc = o.document;
|
||||
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
|
||||
return null;
|
||||
}
|
||||
const summaryVal = o.summary;
|
||||
let summary: string | null | undefined;
|
||||
if (typeof summaryVal === "string") {
|
||||
summary = summaryVal;
|
||||
} else if (summaryVal === null) {
|
||||
summary = null;
|
||||
} else {
|
||||
summary = undefined;
|
||||
}
|
||||
return {
|
||||
id: o.id,
|
||||
title: o.title,
|
||||
summary,
|
||||
document: doc as Record<string, unknown>,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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."
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"finalizeBannerTitle": "Couldn't publish",
|
||||
"missingCommunityName": "Add a community name before finalizing.",
|
||||
"finalizeButtonPublishing": "Publishing…",
|
||||
"genericPublishFailed": "Something went wrong. Try again."
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative min-h-[100dvh] overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-br from-amber-100 via-white to-amber-50"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative z-0 px-8 py-16">
|
||||
<p className="font-inter max-w-md text-lg text-neutral-800">
|
||||
Placeholder page content — the login overlay portals above this and
|
||||
uses backdrop blur (`blurredYellow`).
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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) => (
|
||||
<MagicLinkFetchMock>
|
||||
<Story />
|
||||
</MagicLinkFetchMock>
|
||||
),
|
||||
],
|
||||
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: () => (
|
||||
<FakeMarketingPageBehindOverlay>
|
||||
<Login
|
||||
isOpen
|
||||
onClose={() => {}}
|
||||
backdropVariant="blurredYellow"
|
||||
usePortal
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
belowCard={
|
||||
<a
|
||||
href="/"
|
||||
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
||||
>
|
||||
← Back to home
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<p className="font-inter p-6">Loading…</p>}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</Login>
|
||||
</FakeMarketingPageBehindOverlay>
|
||||
),
|
||||
};
|
||||
|
||||
/** 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) => (
|
||||
<div className="min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]">
|
||||
<MagicLinkFetchMock>
|
||||
<Story />
|
||||
</MagicLinkFetchMock>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export const ModalChromeOnly = {
|
||||
name: "Modal (placeholder content)",
|
||||
render: () => (
|
||||
<Login
|
||||
isOpen
|
||||
onClose={() => {}}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
belowCard={
|
||||
<a
|
||||
href="/"
|
||||
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
||||
>
|
||||
← Back to home
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<p
|
||||
id="login-modal-heading"
|
||||
className="font-inter px-2 py-4 text-[var(--color-content-default-primary)]"
|
||||
>
|
||||
Placeholder body — use "With magic link form" for the real
|
||||
flow.
|
||||
</p>
|
||||
</Login>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithMagicLinkForm = {
|
||||
name: "With magic link form",
|
||||
render: () => (
|
||||
<Login
|
||||
isOpen
|
||||
onClose={() => {}}
|
||||
backdropVariant="solid"
|
||||
usePortal={false}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
belowCard={
|
||||
<a
|
||||
@@ -114,16 +165,54 @@ export const WithMagicLinkForm = {
|
||||
),
|
||||
};
|
||||
|
||||
export const ModalChromeOnly = {
|
||||
name: "Modal chrome only (placeholder)",
|
||||
render: () => (
|
||||
<div className="min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]">
|
||||
<Login
|
||||
isOpen
|
||||
onClose={() => {}}
|
||||
backdropVariant="solid"
|
||||
usePortal={false}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
belowCard={
|
||||
<a
|
||||
href="/"
|
||||
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
||||
>
|
||||
← Back to home
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<p
|
||||
id="login-modal-heading"
|
||||
className="font-inter px-2 py-4 text-[var(--color-content-default-primary)]"
|
||||
>
|
||||
Placeholder body — use "Header overlay" or "Full-page
|
||||
route" for the real flow.
|
||||
</p>
|
||||
</Login>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
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) => (
|
||||
<div className="min-h-[200px] bg-[var(--color-surface-inverse-brand-primary)] p-8">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
render: () => (
|
||||
<div className="mx-auto max-w-[560px] rounded-[20px] bg-[var(--color-surface-default-primary)] p-6 shadow-lg">
|
||||
<Suspense fallback={<p className="font-inter">Loading…</p>}>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<button type="button" onClick={() => openLogin()}>
|
||||
Open login modal
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openLogin({
|
||||
variant: "saveProgress",
|
||||
nextPath: "/create/select?syncDraft=1",
|
||||
})
|
||||
}
|
||||
>
|
||||
Open save progress
|
||||
</button>
|
||||
<button type="button" onClick={() => closeLogin()}>
|
||||
Close from outside
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<Suspense fallback={null}>
|
||||
<LoginTrigger />
|
||||
</Suspense>,
|
||||
);
|
||||
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(
|
||||
<Suspense fallback={null}>
|
||||
<LoginTrigger />
|
||||
</Suspense>,
|
||||
);
|
||||
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(
|
||||
<Suspense fallback={null}>
|
||||
<LoginTrigger />
|
||||
</Suspense>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<CreateFlowTopNavProps> = {
|
||||
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(<CreateFlowTopNav loggedIn={true} />);
|
||||
it("shows Save & Exit when saveDraftOnExit is true", () => {
|
||||
render(<CreateFlowTopNav saveDraftOnExit={true} />);
|
||||
const exitButton = screen.getByRole("button", { name: "Save & Exit" });
|
||||
expect(exitButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Exit when loggedIn is false", () => {
|
||||
render(<CreateFlowTopNav loggedIn={false} />);
|
||||
it("shows Exit when saveDraftOnExit is false", () => {
|
||||
render(<CreateFlowTopNav saveDraftOnExit={false} />);
|
||||
const exitButton = screen.getByRole("button", { name: "Exit" });
|
||||
expect(exitButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import FinalReviewPage from "../../app/create/final-review/page";
|
||||
import { useCreateFlow } from "../../app/create/context/CreateFlowContext";
|
||||
|
||||
const FALLBACK_CARD_TITLE = "Your community";
|
||||
const FALLBACK_CARD_DESCRIPTION_SNIPPET =
|
||||
"Add a short description of your community";
|
||||
|
||||
function FinalReviewWithFlowState({
|
||||
title,
|
||||
summary,
|
||||
}: {
|
||||
title: string;
|
||||
summary?: string;
|
||||
}) {
|
||||
const { replaceState } = useCreateFlow();
|
||||
useLayoutEffect(() => {
|
||||
replaceState({ title, ...(summary !== undefined ? { summary } : {}) });
|
||||
}, [replaceState, title, summary]);
|
||||
return <FinalReviewPage />;
|
||||
}
|
||||
|
||||
describe("FinalReviewPage", () => {
|
||||
it("renders without crashing", () => {
|
||||
@@ -27,17 +51,27 @@ describe("FinalReviewPage", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders RuleCard with title", () => {
|
||||
it("renders RuleCard with fallback title when context has no name", () => {
|
||||
render(<FinalReviewPage />);
|
||||
expect(screen.getByText("Mutual Aid Mondays")).toBeInTheDocument();
|
||||
expect(screen.getByText(FALLBACK_CARD_TITLE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders RuleCard with description", () => {
|
||||
it("renders RuleCard with fallback description when context has no summary", () => {
|
||||
render(<FinalReviewPage />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness./i,
|
||||
),
|
||||
screen.getByText(new RegExp(FALLBACK_CARD_DESCRIPTION_SNIPPET, "i")),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders RuleCard title from create flow state", async () => {
|
||||
render(
|
||||
<FinalReviewWithFlowState title="Oak Park Commons" summary="Local mutual aid." />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Oak Park Commons")).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByText(/Local mutual aid\./i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -46,7 +80,7 @@ describe("FinalReviewPage", () => {
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
buttons.some((el) => el.textContent?.includes("Mutual Aid Mondays")),
|
||||
buttons.some((el) => el.textContent?.includes(FALLBACK_CARD_TITLE)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
|
||||
<p id="login-modal-heading">Login content</p>
|
||||
</Login>,
|
||||
);
|
||||
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
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
backdropVariant="solid"
|
||||
>
|
||||
<p id="login-modal-heading">Login content</p>
|
||||
</Login>,
|
||||
);
|
||||
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(
|
||||
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
|
||||
@@ -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(
|
||||
<div data-testid="inline-root">
|
||||
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
|
||||
<p id="login-modal-heading">Portaled</p>
|
||||
</Login>
|
||||
</div>,
|
||||
);
|
||||
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(
|
||||
|
||||
@@ -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(
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm
|
||||
variant="saveProgress"
|
||||
magicLinkNextPath="/create/select?syncDraft=1"
|
||||
/>
|
||||
</Suspense>,
|
||||
);
|
||||
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"));
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildPublishPayload,
|
||||
parseDocumentSectionsForDisplay,
|
||||
parseSectionsFromCreateFlowState,
|
||||
} from "../../lib/create/buildPublishPayload";
|
||||
import type { CreateFlowState } from "../../app/create/types";
|
||||
|
||||
describe("buildPublishPayload", () => {
|
||||
it("returns error when title missing", () => {
|
||||
expect(buildPublishPayload({})).toEqual({
|
||||
ok: false,
|
||||
error: "missingCommunityName",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns error when title is whitespace only", () => {
|
||||
expect(buildPublishPayload({ title: " \n\t " })).toEqual({
|
||||
ok: false,
|
||||
error: "missingCommunityName",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns title and fallback Overview section when no sections", () => {
|
||||
const r = buildPublishPayload({ title: "Oak Park Commons" });
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.title).toBe("Oak Park Commons");
|
||||
expect(r.summary).toBeUndefined();
|
||||
expect(r.document).toEqual({
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Overview",
|
||||
entries: [
|
||||
{
|
||||
title: "Community",
|
||||
body: "This CommunityRule was created in the create flow. Add more detail in a future edit.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("includes trimmed summary in payload and uses it as fallback section body", () => {
|
||||
const r = buildPublishPayload({
|
||||
title: " My Group ",
|
||||
summary: " We organize locally. ",
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.title).toBe("My Group");
|
||||
expect(r.summary).toBe("We organize locally.");
|
||||
expect(r.document).toEqual({
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Overview",
|
||||
entries: [{ title: "Community", body: "We organize locally." }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses valid state.sections when present", () => {
|
||||
const sections: CreateFlowState["sections"] = [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [{ title: "A", body: "B" }],
|
||||
},
|
||||
];
|
||||
const r = buildPublishPayload({ title: "T", sections });
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.document).toEqual({ sections });
|
||||
});
|
||||
|
||||
it("filters invalid section entries from state.sections", () => {
|
||||
const r = buildPublishPayload({
|
||||
title: "T",
|
||||
sections: [
|
||||
{ categoryName: "Values", entries: [{ title: "A", body: "B" }] },
|
||||
{ bad: true } as unknown as Record<string, unknown>,
|
||||
],
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.document).toEqual({
|
||||
sections: [{ categoryName: "Values", entries: [{ title: "A", body: "B" }] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDocumentSectionsForDisplay", () => {
|
||||
it("returns empty for non-object", () => {
|
||||
expect(parseDocumentSectionsForDisplay(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses valid sections array", () => {
|
||||
const doc = {
|
||||
sections: [
|
||||
{ categoryName: "X", entries: [{ title: "t", body: "b" }] },
|
||||
],
|
||||
};
|
||||
expect(parseDocumentSectionsForDisplay(doc)).toEqual(doc.sections);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSectionsFromCreateFlowState", () => {
|
||||
it("returns empty when sections missing", () => {
|
||||
expect(parseSectionsFromCreateFlowState({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<RenderOptions, "wrapper">,
|
||||
) {
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <MessagesProvider messages={messages}>{children}</MessagesProvider>;
|
||||
return (
|
||||
<MessagesProvider messages={messages}>
|
||||
<AuthModalProvider>
|
||||
<CreateFlowProvider>{children}</CreateFlowProvider>
|
||||
</AuthModalProvider>
|
||||
</MessagesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return render(ui, { wrapper: Wrapper, ...options });
|
||||
|
||||
Reference in New Issue
Block a user