Create flow: session UI + sign out

This commit is contained in:
adilallo
2026-04-06 19:22:50 -06:00
parent 4b14510dde
commit 759f5f1555
47 changed files with 1383 additions and 370 deletions
+1 -1
View File
@@ -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`** only.
## 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;
}
+10 -2
View File
@@ -2,7 +2,14 @@
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 +23,7 @@ export function LoginView({
backdropRef,
portalReady,
usePortal,
backdropVariant,
}: LoginViewProps) {
if (!isOpen) return null;
if (usePortal && !portalReady) return null;
@@ -23,7 +31,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"
>
+52 -7
View File
@@ -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 dont 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) {
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>
+90
View File
@@ -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;
}
+125
View File
@@ -0,0 +1,125 @@
"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";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
/**
* After magic-link verify, redirects to `/create/...?syncDraft=1` with session cookie.
* Uploads anonymous localStorage draft to `RuleDraft` once, then hydrates context.
*/
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) {
if (attemptedRef.current) return;
attemptedRef.current = true;
setTransferError(
"Saving to your account is not available (server sync is disabled). Your progress stays on this device.",
);
if (pathname) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
router.replace(q ? `${pathname}?${q}` : pathname);
}
return;
}
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 ok = await saveDraftToServer(payload);
if (cancelled) return;
if (!ok) {
setTransferError(
"Could not save your draft to your account. Your progress is still stored on this device.",
);
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>
);
}
+91
View File
@@ -0,0 +1,91 @@
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;
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 preanonymous-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
}
}
+24 -9
View File
@@ -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
View File
@@ -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;
}
+48 -58
View File
@@ -5,6 +5,7 @@ import {
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
@@ -13,64 +14,68 @@ 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;
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 +86,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 +100,8 @@ export function CreateFlowProvider({
updateState,
replaceState,
clearState,
interactionTouched,
markCreateFlowInteraction,
};
return (
@@ -105,22 +111,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) {
+27
View File
@@ -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;
}
+62
View File
@@ -0,0 +1,62 @@
"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,
}: {
state: CreateFlowState;
currentStep: CreateFlowStep | null;
clearState: CreateFlowExitClearState;
router: AppRouterLike;
user: { id: string; email: string } | null;
}): (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 ok = await saveDraftToServer(payload);
if (!ok && typeof window !== "undefined") {
const leave = window.confirm(
messages.create.topNav.leaveConfirmSaveFailed,
);
if (!leave) return;
}
}
clearState();
router.push("/");
},
[state, currentStep, clearState, router, user],
);
}
+92 -35
View File
@@ -1,27 +1,71 @@
"use client";
import type { ReactNode } from "react";
import { useRouter } from "next/navigation";
import { CreateFlowBackendSync } from "./context/CreateFlowBackendSync";
import {
CreateFlowProvider,
useCreateFlow,
saveCreateFlowDraft,
} from "./context/CreateFlowContext";
Suspense,
useEffect,
useState,
type ReactNode,
} from "react";
import { usePathname, useRouter } from "next/navigation";
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
import { getStepIndex } from "./utils/flowSteps";
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
import Button from "../components/buttons/Button";
import { fetchAuthSession } from "../../lib/create/api";
import { useAuthModal } from "../contexts/AuthModalContext";
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
/**
* 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 }) {
/** 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}
>
<CreateFlowLayoutContent
sessionUser={sessionUser}
sessionResolved={sessionResolved}
>
{children}
</CreateFlowLayoutContent>
</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,
@@ -31,40 +75,58 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
} = 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;
const runAuthenticatedExit = useCreateFlowExit({
state,
currentStep,
clearState,
router,
user: sessionUser ?? null,
});
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 (saveDraft) {
saveCreateFlowDraft(state);
}
clearState();
router.push("/");
if (!sessionUser) return;
await runAuthenticatedExit(opts);
};
const isCompletedStep = currentStep === "completed";
const isRightRailStep = currentStep === "right-rail";
const useFullHeightMain = isCompletedStep || isRightRailStep;
const stepIdx =
currentStep != null ? getStepIndex(currentStep) : -1;
const saveDraftOnExit =
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
return (
<div
className={`bg-black flex flex-col ${useFullHeightMain ? "h-screen overflow-hidden" : "min-h-screen"}`}
>
<Suspense fallback={null}>
<PostLoginDraftTransfer sessionUser={sessionUser} />
</Suspense>
<CreateFlowTopNav
hasShare={isCompletedStep}
hasExport={isCompletedStep}
hasEdit={isCompletedStep}
loggedIn={isCompletedStep}
saveDraftOnExit={saveDraftOnExit}
onEdit={
isCompletedStep
? () => router.push("/create/final-review")
: undefined
}
onExit={handleExit}
onExit={(opts) => void handleExit(opts)}
buttonPalette={isCompletedStep ? "inverse" : undefined}
className={
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : undefined
@@ -106,10 +168,5 @@ export default function CreateFlowLayout({
}: {
children: ReactNode;
}) {
return (
<CreateFlowProvider>
<CreateFlowBackendSync />
<CreateFlowLayoutContent>{children}</CreateFlowLayoutContent>
</CreateFlowProvider>
);
return <CreateFlowSessionShell>{children}</CreateFlowSessionShell>;
}
+15 -7
View File
@@ -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
View File
@@ -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
+17 -2
View File
@@ -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,18 @@ 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;
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 +53,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
View File
@@ -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;
}
/**
+3
View File
@@ -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
View File
@@ -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>
-35
View File
@@ -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>
);
}
+14
View File
@@ -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
View File
@@ -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>
);
}
+39
View File
@@ -1,9 +1,34 @@
"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 +38,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>
);
}
+33 -37
View File
@@ -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,15 +183,15 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
**Depends on:** Tickets 24.
**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; create flow starts fresh for signed-in users until profile “open draft” (future). Residual risks: silent **PUT** failure (confirm on exit today), richer error surfaces.
**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.
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`. Use that result in [useCreateFlowExit](app/create/hooks/useCreateFlowExit.ts) and [PostLoginDraftTransfer](app/create/PostLoginDraftTransfer.tsx).
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).
@@ -199,7 +200,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
- [ ] No silent data loss when server save fails.
- [ ] User understands when server draft replaced local state (if applicable).
**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), possibly [CreateFlowContext](app/create/context/CreateFlowContext.tsx), tests under `tests/`.
---
@@ -392,7 +393,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 +419,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 +453,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.
---
@@ -476,21 +477,21 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
| 14 | 14 | Session lifecycle + cleanup |
| 15 | 15 | Profile + account (Figma profile) |
Tickets **1011** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Tickets 1314** 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 **1011** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Tickets 1314** 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-7283 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), not in the CR-7283 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 |
| 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 |
@@ -504,13 +505,8 @@ Tickets **1011** can be deferred without blocking the core “auth + drafts +
---
## 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.
+5 -5
View File
@@ -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.
@@ -178,7 +178,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,11 +216,11 @@ 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).
+6
View File
@@ -0,0 +1,6 @@
{
"saveAndExit": "Save & Exit",
"exit": "Exit",
"leaveConfirmLoss": "Leave create flow? Your progress will be lost.",
"leaveConfirmSaveFailed": "Could not save to your account. Leave anyway?"
}
+2
View File
@@ -18,6 +18,7 @@ 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";
export default {
common,
@@ -41,6 +42,7 @@ export default {
},
create: {
communication,
topNav: createTopNav,
},
navigation,
metadata,
+2
View File
@@ -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",
+2 -1
View File
@@ -1,4 +1,5 @@
{
"placeholderTitle": "Your profile",
"placeholderBody": "Were building this space for your CommunityRules and account options. Check back soon."
"placeholderBody": "Were building this space for your CommunityRules and account options. Check back soon.",
"signOut": "Sign out"
}
+127 -38
View File
@@ -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 &quot;With magic link form&quot; 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 &quot;Header overlay&quot; or &quot;Full-page
route&quot; 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>}>
+7 -6
View File
@@ -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,
},
};
+151
View File
@@ -0,0 +1,151 @@
import React, { 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();
});
});
+6 -6
View File
@@ -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();
});
+48
View File
@@ -10,6 +10,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 +66,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(
+58 -2
View File
@@ -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"));
+36
View File
@@ -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);
});
});
+10 -4
View File
@@ -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 });