Create flow: session UI + sign out
This commit is contained in:
@@ -14,6 +14,7 @@ const LoginContainer = memo<LoginProps>(
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
usePortal = true,
|
||||
backdropVariant = "blurredYellow",
|
||||
}) => {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
@@ -126,6 +127,7 @@ const LoginContainer = memo<LoginProps>(
|
||||
backdropRef={backdropRef}
|
||||
portalReady={portalReady}
|
||||
usePortal={usePortal}
|
||||
backdropVariant={backdropVariant}
|
||||
>
|
||||
{children}
|
||||
</LoginView>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export type LoginBackdropVariant = "solid" | "blurredYellow";
|
||||
|
||||
export interface LoginProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -13,6 +15,8 @@ export interface LoginProps {
|
||||
* without waiting for a portal gate (more reliable across engines).
|
||||
*/
|
||||
usePortal?: boolean;
|
||||
/** `solid` = full-page marketing yellow; `blurredYellow` = blur + translucent yellow over underlying UI */
|
||||
backdropVariant?: LoginBackdropVariant;
|
||||
}
|
||||
|
||||
export interface LoginViewProps {
|
||||
@@ -28,4 +32,5 @@ export interface LoginViewProps {
|
||||
/** False until client mount — avoids SSR/client HTML mismatch for createPortal. */
|
||||
portalReady: boolean;
|
||||
usePortal: boolean;
|
||||
backdropVariant: LoginBackdropVariant;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,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"
|
||||
>
|
||||
|
||||
@@ -9,6 +9,7 @@ import TextInput from "../../controls/TextInput";
|
||||
import ContentLockup from "../../type/ContentLockup";
|
||||
import { requestMagicLink } from "../../../../lib/create/api";
|
||||
import { safeInternalPath } from "../../../../lib/safeInternalPath";
|
||||
import { setTransferPendingFlag } from "../../../create/anonymousDraftStorage";
|
||||
|
||||
/** Mail icon for login modal (inline SVG; same pattern as InfoMessageBox ExclamationIconInline). */
|
||||
function MailIconInline() {
|
||||
@@ -37,7 +38,18 @@ function MailIconInline() {
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export default function LoginForm() {
|
||||
export type LoginFormVariant = "default" | "saveProgress";
|
||||
|
||||
export type LoginFormProps = {
|
||||
variant?: LoginFormVariant;
|
||||
/** Overrides URL `next` for `requestMagicLink` (e.g. create-flow exit modal). */
|
||||
magicLinkNextPath?: string;
|
||||
};
|
||||
|
||||
export default function LoginForm({
|
||||
variant = "default",
|
||||
magicLinkNextPath,
|
||||
}: LoginFormProps) {
|
||||
const t = useTranslation("pages.login");
|
||||
const tFooter = useTranslation("footer");
|
||||
const router = useRouter();
|
||||
@@ -55,6 +67,8 @@ export default function LoginForm() {
|
||||
const nextParam = searchParams.get("next");
|
||||
const errorParam = searchParams.get("error");
|
||||
|
||||
const isSaveProgress = variant === "saveProgress";
|
||||
|
||||
/** Drop `error` from the URL so URL-driven messages don’t linger after a new attempt. */
|
||||
const stripErrorQuery = useCallback(() => {
|
||||
if (!searchParams.get("error")) return;
|
||||
@@ -75,7 +89,8 @@ export default function LoginForm() {
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const nextPath = safeInternalPath(nextParam);
|
||||
const rawNext = magicLinkNextPath ?? nextParam;
|
||||
const nextPath = safeInternalPath(rawNext);
|
||||
const result = await requestMagicLink(trimmed, nextPath);
|
||||
if (result.ok === false) {
|
||||
if (result.retryAfterMs != null && result.retryAfterMs > 0) {
|
||||
@@ -88,6 +103,9 @@ export default function LoginForm() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isSaveProgress) {
|
||||
setTransferPendingFlag();
|
||||
}
|
||||
setEmail(trimmed);
|
||||
setSent(true);
|
||||
} catch {
|
||||
@@ -95,7 +113,14 @@ export default function LoginForm() {
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [email, nextParam, stripErrorQuery, t]);
|
||||
}, [
|
||||
email,
|
||||
isSaveProgress,
|
||||
magicLinkNextPath,
|
||||
nextParam,
|
||||
stripErrorQuery,
|
||||
t,
|
||||
]);
|
||||
|
||||
const urlErrorMessage =
|
||||
errorParam === "expired_link"
|
||||
@@ -106,16 +131,36 @@ export default function LoginForm() {
|
||||
: t("errors.invalidLink")
|
||||
: "";
|
||||
|
||||
const titleId = "login-modal-heading";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 pt-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="relative flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-inverse-brand-primary)]">
|
||||
<div
|
||||
className={`relative flex h-12 w-12 shrink-0 items-center justify-center rounded-full ${
|
||||
isSaveProgress
|
||||
? "bg-[#fefcc9]"
|
||||
: "bg-[var(--color-surface-inverse-brand-primary)]"
|
||||
}`}
|
||||
>
|
||||
<MailIconInline />
|
||||
</div>
|
||||
<ContentLockup
|
||||
titleId="login-modal-heading"
|
||||
title={sent ? t("successTitle") : t("title")}
|
||||
description={sent ? t("successBody") : t("subtitle")}
|
||||
titleId={titleId}
|
||||
title={
|
||||
sent
|
||||
? t("successTitle")
|
||||
: isSaveProgress
|
||||
? t("saveProgressTitle")
|
||||
: t("title")
|
||||
}
|
||||
description={
|
||||
sent
|
||||
? t("successBody")
|
||||
: isSaveProgress
|
||||
? t("saveProgressSubtitle")
|
||||
: t("subtitle")
|
||||
}
|
||||
variant="login"
|
||||
alignment="left"
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,7 @@ const Footer = dynamic(() => import("./Footer"), {
|
||||
|
||||
/**
|
||||
* Conditionally renders Footer based on pathname.
|
||||
* Hides footer for /create/* and /login (full-screen flows; login uses a body portal).
|
||||
* Hides footer for /create/* and /login (full-screen flows; no site chrome).
|
||||
*/
|
||||
const ConditionalFooter = memo(() => {
|
||||
const pathname = usePathname();
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
const MenuBarItemContainer = memo<MenuBarItemProps>(
|
||||
({
|
||||
href = "#",
|
||||
buttonOnClick,
|
||||
children,
|
||||
state: stateProp,
|
||||
mode: modeProp,
|
||||
@@ -112,6 +113,7 @@ const MenuBarItemContainer = memo<MenuBarItemProps>(
|
||||
return (
|
||||
<MenuBarItemView
|
||||
href={href}
|
||||
buttonOnClick={buttonOnClick}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
combinedStyles={combinedStyles}
|
||||
|
||||
@@ -11,6 +11,8 @@ export type MenuBarItemModeValue = "default" | "inverse";
|
||||
|
||||
export interface MenuBarItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
href?: string;
|
||||
/** When set, renders a `<button type="button">` instead of a link (e.g. open login modal). */
|
||||
buttonOnClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* Menu bar item state: "default", "hover", or "selected".
|
||||
@@ -45,9 +47,12 @@ export interface MenuBarItemProps extends React.AnchorHTMLAttributes<HTMLAnchorE
|
||||
|
||||
export interface MenuBarItemViewProps {
|
||||
href: string;
|
||||
buttonOnClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
disabled: boolean;
|
||||
className: string;
|
||||
combinedStyles: string;
|
||||
accessibilityProps: React.HTMLAttributes<HTMLAnchorElement | HTMLSpanElement>;
|
||||
accessibilityProps: React.HTMLAttributes<
|
||||
HTMLAnchorElement | HTMLSpanElement | HTMLButtonElement
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MenuBarItemViewProps } from "./MenuBarItem.types";
|
||||
|
||||
function MenuBarItemView({
|
||||
href,
|
||||
buttonOnClick,
|
||||
children,
|
||||
disabled,
|
||||
combinedStyles,
|
||||
@@ -16,6 +17,19 @@ function MenuBarItemView({
|
||||
);
|
||||
}
|
||||
|
||||
if (buttonOnClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={combinedStyles}
|
||||
onClick={buttonOnClick}
|
||||
{...accessibilityProps}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={href} className={combinedStyles} {...accessibilityProps}>
|
||||
{children}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useAuthModal } from "../../../contexts/AuthModalContext";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import MenuBarItem from "../MenuBarItem";
|
||||
import Button from "../../buttons/Button";
|
||||
@@ -21,6 +22,7 @@ const TopNavContainer = memo<TopNavProps>(
|
||||
({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { openLogin } = useAuthModal();
|
||||
const t = useTranslation("header");
|
||||
|
||||
// Schema markup for site navigation
|
||||
@@ -139,7 +141,6 @@ const TopNavContainer = memo<TopNavProps>(
|
||||
const isSmallBreakpoint = size === "xsmall" || size === "home";
|
||||
const mode = folderTop && isSmallBreakpoint ? "inverse" : "default";
|
||||
|
||||
const href = loggedIn ? "/profile" : "/login";
|
||||
const label = loggedIn ? t("buttons.profile") : t("buttons.logIn");
|
||||
const ariaLabel = loggedIn
|
||||
? t("ariaLabels.goToProfile")
|
||||
@@ -148,9 +149,30 @@ const TopNavContainer = memo<TopNavProps>(
|
||||
(loggedIn && pathname === "/profile") ||
|
||||
(!loggedIn && pathname === "/login");
|
||||
|
||||
if (loggedIn) {
|
||||
return (
|
||||
<MenuBarItem
|
||||
href="/profile"
|
||||
size={sizeMap[size] || "Small"}
|
||||
mode={mode}
|
||||
state={navSelected ? "selected" : "default"}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{label}
|
||||
</MenuBarItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuBarItem
|
||||
href={href}
|
||||
buttonOnClick={() =>
|
||||
openLogin({
|
||||
variant: "default",
|
||||
backdropVariant: "blurredYellow",
|
||||
nextPath: pathname || "/",
|
||||
})
|
||||
}
|
||||
href="/login"
|
||||
size={sizeMap[size] || "Small"}
|
||||
mode={mode}
|
||||
state={navSelected ? "selected" : "default"}
|
||||
|
||||
@@ -10,7 +10,7 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
||||
hasShare = false,
|
||||
hasExport = false,
|
||||
hasEdit = false,
|
||||
loggedIn = false,
|
||||
saveDraftOnExit = false,
|
||||
onShare,
|
||||
onExport,
|
||||
onEdit,
|
||||
@@ -34,7 +34,7 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
||||
hasShare={hasShare}
|
||||
hasExport={hasExport}
|
||||
hasEdit={hasEdit}
|
||||
loggedIn={loggedIn}
|
||||
saveDraftOnExit={saveDraftOnExit}
|
||||
onShare={onShare}
|
||||
onExport={onExport}
|
||||
onEdit={onEdit}
|
||||
|
||||
@@ -22,10 +22,11 @@ export interface CreateFlowTopNavProps {
|
||||
*/
|
||||
hasEdit?: boolean;
|
||||
/**
|
||||
* Whether the user is logged in
|
||||
* When true, exit control is "Save & Exit" and `onExit` receives `{ saveDraft: true }`.
|
||||
* When false, shows "Exit" and `{ saveDraft: false }` (caller may confirm data loss).
|
||||
* @default false
|
||||
*/
|
||||
loggedIn?: boolean;
|
||||
saveDraftOnExit?: boolean;
|
||||
/**
|
||||
* Callback when Share button is clicked
|
||||
*/
|
||||
@@ -40,7 +41,7 @@ export interface CreateFlowTopNavProps {
|
||||
onEdit?: () => void;
|
||||
/**
|
||||
* Callback when Exit/Save & Exit button is clicked.
|
||||
* When user is logged in, called with { saveDraft: true } to stub "Save & Exit".
|
||||
* When `saveDraftOnExit` is true, called with `{ saveDraft: true }`.
|
||||
*/
|
||||
onExit?: (options?: { saveDraft?: boolean }) => void;
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import Logo from "../../asset/logo";
|
||||
import Button from "../../buttons/Button";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import type { CreateFlowTopNavProps } from "./CreateFlowTopNav.types";
|
||||
|
||||
const exitButtonFigmaClass =
|
||||
"!rounded-[var(--radius-measures-radius-full,9999px)] !border-[1.25px] !px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!text-[12px] md:!leading-[14px]";
|
||||
|
||||
export function CreateFlowTopNavView({
|
||||
hasShare = false,
|
||||
hasExport = false,
|
||||
hasEdit = false,
|
||||
loggedIn = false,
|
||||
saveDraftOnExit = false,
|
||||
onShare,
|
||||
onExport,
|
||||
onEdit,
|
||||
@@ -14,7 +20,8 @@ export function CreateFlowTopNavView({
|
||||
buttonPalette = "default",
|
||||
className = "",
|
||||
}: CreateFlowTopNavProps) {
|
||||
const exitButtonText = loggedIn ? "Save & Exit" : "Exit";
|
||||
const t = useTranslation("create.topNav");
|
||||
const exitButtonText = saveDraftOnExit ? t("saveAndExit") : t("exit");
|
||||
|
||||
return (
|
||||
<header
|
||||
@@ -27,11 +34,9 @@ export function CreateFlowTopNavView({
|
||||
role="navigation"
|
||||
aria-label="Create Flow Navigation"
|
||||
>
|
||||
{/* Logo - Left */}
|
||||
<Logo size="createFlow" wordmark palette={buttonPalette} />
|
||||
|
||||
{/* Button Group - Right */}
|
||||
<div className="flex items-center gap-[var(--spacing-scale-012,12px)]">
|
||||
<div className="flex flex-wrap items-center justify-end gap-[var(--spacing-scale-012,12px)]">
|
||||
{hasShare && (
|
||||
<Button
|
||||
buttonType="outline"
|
||||
@@ -89,9 +94,10 @@ export function CreateFlowTopNavView({
|
||||
buttonType="outline"
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
onClick={() => onExit?.({ saveDraft: loggedIn })}
|
||||
type="button"
|
||||
onClick={() => void onExit?.({ saveDraft: saveDraftOnExit })}
|
||||
ariaLabel={exitButtonText}
|
||||
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
|
||||
className={`md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !py-[6px] md:!py-[8px] shrink-0 ${exitButtonFigmaClass}`}
|
||||
>
|
||||
{exitButtonText}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import Link from "next/link";
|
||||
import Login from "../components/modals/Login";
|
||||
import LoginForm from "../components/modals/Login/LoginForm";
|
||||
import { useTranslation } from "./MessagesContext";
|
||||
|
||||
export type AuthModalLoginVariant = "default" | "saveProgress";
|
||||
|
||||
export type AuthModalBackdropVariant = "solid" | "blurredYellow";
|
||||
|
||||
export type OpenLoginOptions = {
|
||||
variant?: AuthModalLoginVariant;
|
||||
/** Passed to `requestMagicLink` as `next` (internal path). */
|
||||
nextPath?: string;
|
||||
backdropVariant?: AuthModalBackdropVariant;
|
||||
};
|
||||
|
||||
type AuthModalContextValue = {
|
||||
openLogin: (_opts?: OpenLoginOptions) => void;
|
||||
closeLogin: () => void;
|
||||
};
|
||||
|
||||
const AuthModalContext = createContext<AuthModalContextValue | null>(null);
|
||||
|
||||
export function AuthModalProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [opts, setOpts] = useState<OpenLoginOptions>({});
|
||||
const t = useTranslation("pages.login");
|
||||
|
||||
const openLogin = useCallback((o?: OpenLoginOptions) => {
|
||||
setOpts(o ?? {});
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeLogin = useCallback(() => {
|
||||
setOpen(false);
|
||||
setOpts({});
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ openLogin, closeLogin }),
|
||||
[openLogin, closeLogin],
|
||||
);
|
||||
|
||||
const backdropVariant = opts.backdropVariant ?? "blurredYellow";
|
||||
|
||||
return (
|
||||
<AuthModalContext.Provider value={value}>
|
||||
{children}
|
||||
<Login
|
||||
isOpen={open}
|
||||
onClose={closeLogin}
|
||||
backdropVariant={backdropVariant}
|
||||
usePortal
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
belowCard={
|
||||
<Link
|
||||
href="/"
|
||||
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
||||
onClick={() => closeLogin()}
|
||||
>
|
||||
{t("backToHome")}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<LoginForm
|
||||
variant={opts.variant ?? "default"}
|
||||
magicLinkNextPath={opts.nextPath}
|
||||
/>
|
||||
</Login>
|
||||
</AuthModalContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuthModal(): AuthModalContextValue {
|
||||
const ctx = useContext(AuthModalContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuthModal must be used within AuthModalProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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 pre–anonymous-draft keys. */
|
||||
export function clearLegacyCreateFlowKeysOnce(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const done = window.sessionStorage.getItem("create-flow-legacy-cleared");
|
||||
if (done) return;
|
||||
window.localStorage.removeItem(LEGACY_LIVE_KEY);
|
||||
window.localStorage.removeItem(LEGACY_DRAFT_KEY);
|
||||
window.sessionStorage.setItem("create-flow-legacy-cleared", "1");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import CardStack from "../../components/utility/CardStack";
|
||||
import Create from "../../components/modals/Create";
|
||||
import TextArea from "../../components/controls/TextArea";
|
||||
@@ -130,6 +131,7 @@ function AddPlatformModalContent({
|
||||
}: {
|
||||
platformCardId: string;
|
||||
}) {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const defaults = ADD_PLATFORM_SECTION_DEFAULTS[platformCardId];
|
||||
const [sectionValues, setSectionValues] = useState<
|
||||
Record<SectionKey, string>
|
||||
@@ -141,9 +143,13 @@ function AddPlatformModalContent({
|
||||
},
|
||||
);
|
||||
|
||||
const updateSection = useCallback((key: SectionKey, value: string) => {
|
||||
setSectionValues((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
const updateSection = useCallback(
|
||||
(key: SectionKey, value: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setSectionValues((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
if (!defaults) return null;
|
||||
|
||||
@@ -230,6 +236,7 @@ function getCreateModalConfig(pendingCardId: string | null) {
|
||||
|
||||
/** Create flow card stack step: compact grid with optional expand to full list. */
|
||||
export default function CardsPage() {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
@@ -239,10 +246,14 @@ export default function CardsPage() {
|
||||
const description = expanded ? EXPANDED_DESCRIPTION : COMPACT_DESCRIPTION;
|
||||
const modalConfig = getCreateModalConfig(pendingCardId);
|
||||
|
||||
const handleCardClick = useCallback((id: string) => {
|
||||
setPendingCardId(id);
|
||||
setCreateModalOpen(true);
|
||||
}, []);
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setPendingCardId(id);
|
||||
setCreateModalOpen(true);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
@@ -250,6 +261,7 @@ export default function CardsPage() {
|
||||
}, []);
|
||||
|
||||
const handleCreateModalConfirm = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (pendingCardId) {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(pendingCardId) ? prev : [...prev, pendingCardId],
|
||||
@@ -257,7 +269,7 @@ export default function CardsPage() {
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, [pendingCardId]);
|
||||
}, [markCreateFlowInteraction, pendingCardId]);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[1280px] shrink-0 px-5 md:px-16">
|
||||
@@ -276,7 +288,10 @@ export default function CardsPage() {
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardClick}
|
||||
expanded={expanded}
|
||||
onToggleExpand={() => setExpanded((prev) => !prev)}
|
||||
onToggleExpand={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded((prev) => !prev);
|
||||
}}
|
||||
hasMore={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import MultiSelect from "../../components/controls/MultiSelect";
|
||||
import Alert from "../../components/modals/Alert";
|
||||
import type { ChipOption } from "../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
const TITLE =
|
||||
"Do other stakeholders need to be involved in creating your community?";
|
||||
@@ -20,6 +21,7 @@ const DRAFT_TOAST_TITLE = "Congratulations! You've drafted your CommunityRule!";
|
||||
* Figma: 21104-46594.
|
||||
*/
|
||||
export default function ConfirmStakeholdersPage() {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [toastDismissed, setToastDismissed] = useState(false);
|
||||
const [stakeholderOptions, setStakeholderOptions] = useState<ChipOption[]>(
|
||||
@@ -35,6 +37,7 @@ export default function ConfirmStakeholdersPage() {
|
||||
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
|
||||
|
||||
const handleAddStakeholder = () => {
|
||||
markCreateFlowInteraction();
|
||||
setStakeholderOptions((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), label: "", state: "Custom" },
|
||||
@@ -42,6 +45,7 @@ export default function ConfirmStakeholdersPage() {
|
||||
};
|
||||
|
||||
const handleCustomChipConfirm = (chipId: string, value: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setStakeholderOptions((prev) =>
|
||||
prev.map((opt) =>
|
||||
opt.id === chipId ? { ...opt, label: value, state: "Selected" } : opt,
|
||||
@@ -50,10 +54,12 @@ export default function ConfirmStakeholdersPage() {
|
||||
};
|
||||
|
||||
const handleCustomChipClose = (chipId: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
|
||||
};
|
||||
|
||||
const handleChipClick = (chipId: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
fetchAuthSession,
|
||||
fetchDraftFromServer,
|
||||
saveDraftToServer,
|
||||
} from "../../../lib/create/api";
|
||||
import { useCreateFlow } from "./CreateFlowContext";
|
||||
|
||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||
|
||||
const DEBOUNCE_MS = 1000;
|
||||
|
||||
/**
|
||||
* When NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true, loads the signed-in user's draft
|
||||
* from the server and debounces saves. Anonymous users keep localStorage-only behavior.
|
||||
*/
|
||||
export function CreateFlowBackendSync() {
|
||||
const { state, replaceState } = useCreateFlow();
|
||||
const [hydrated, setHydrated] = useState(!SYNC_ENABLED);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!SYNC_ENABLED) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { user } = await fetchAuthSession();
|
||||
if (cancelled || !user) {
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
const serverDraft = await fetchDraftFromServer();
|
||||
if (cancelled) return;
|
||||
if (serverDraft && Object.keys(serverDraft).length > 0) {
|
||||
replaceState(serverDraft);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setHydrated(true);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [replaceState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!SYNC_ENABLED || !hydrated) return;
|
||||
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
|
||||
saveTimer.current = setTimeout(() => {
|
||||
saveTimer.current = null;
|
||||
void (async () => {
|
||||
const { user } = await fetchAuthSession();
|
||||
if (!user) return;
|
||||
await saveDraftToServer(state);
|
||||
})();
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, [state, hydrated]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
@@ -13,64 +14,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) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { CreateFlowState } from "./types";
|
||||
|
||||
const IGNORED_KEYS = new Set<string>(["currentStep"]);
|
||||
|
||||
function valueIndicatesUserInput(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (typeof value === "object") {
|
||||
return Object.keys(value as object).length > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the user has entered meaningful create-flow data (not only navigation metadata).
|
||||
* Used to show "Save & Exit" vs a plain "Exit" that confirms data loss.
|
||||
*/
|
||||
export function hasCreateFlowUserInput(state: CreateFlowState): boolean {
|
||||
for (const key of Object.keys(state)) {
|
||||
if (IGNORED_KEYS.has(key)) continue;
|
||||
if (valueIndicatesUserInput(state[key])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import DecisionMakingSidebar from "../../components/utility/DecisionMakingSideba
|
||||
import CardStack from "../../components/utility/CardStack";
|
||||
import type { InfoMessageBoxItem } from "../../components/utility/InfoMessageBox/InfoMessageBox.types";
|
||||
import type { CardStackItem } from "../../components/utility/CardStack/CardStack.types";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
const SIDEBAR_TITLE = "How should conflicts be resolved?";
|
||||
|
||||
@@ -78,6 +79,7 @@ const SAMPLE_CARDS: CardStackItem[] = [
|
||||
* Two-column layout (sidebar + card stack) at 640+, single column at 320-639.
|
||||
*/
|
||||
export default function RightRailPage() {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
|
||||
@@ -96,22 +98,28 @@ export default function RightRailPage() {
|
||||
|
||||
const handleMessageBoxCheckboxChange = useCallback(
|
||||
(id: string, checked: boolean) => {
|
||||
markCreateFlowInteraction();
|
||||
setMessageBoxCheckedIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((x) => x !== id),
|
||||
);
|
||||
},
|
||||
[],
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleCardSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
}, []);
|
||||
const handleCardSelect = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded((prev) => !prev);
|
||||
}, []);
|
||||
}, [markCreateFlowInteraction]);
|
||||
|
||||
if (showDesktopLayout) {
|
||||
return (
|
||||
|
||||
+40
-12
@@ -11,27 +11,36 @@ import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import MultiSelect from "../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
function createListCustomHandlers(
|
||||
setList: Dispatch<SetStateAction<ChipOption[]>>,
|
||||
confirmState: "Unselected" | "Selected",
|
||||
onInteraction?: () => void,
|
||||
) {
|
||||
const touch = () => onInteraction?.();
|
||||
return {
|
||||
onAddClick: () =>
|
||||
onAddClick: () => {
|
||||
touch();
|
||||
setList((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), label: "", state: "Custom" },
|
||||
]),
|
||||
onCustomChipConfirm: (chipId: string, value: string) =>
|
||||
]);
|
||||
},
|
||||
onCustomChipConfirm: (chipId: string, value: string) => {
|
||||
touch();
|
||||
setList((prev) =>
|
||||
prev.map((opt) =>
|
||||
opt.id === chipId
|
||||
? { ...opt, label: value, state: confirmState }
|
||||
: opt,
|
||||
),
|
||||
),
|
||||
onCustomChipClose: (chipId: string) =>
|
||||
setList((prev) => prev.filter((o) => o.id !== chipId)),
|
||||
);
|
||||
},
|
||||
onCustomChipClose: (chipId: string) => {
|
||||
touch();
|
||||
setList((prev) => prev.filter((o) => o.id !== chipId));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,6 +52,7 @@ function createListCustomHandlers(
|
||||
* Responsive sizing: uses L/M for HeaderLockup and S for MultiSelect based on 640px breakpoint.
|
||||
*/
|
||||
export default function SelectPage() {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
@@ -85,19 +95,35 @@ export default function SelectPage() {
|
||||
]);
|
||||
|
||||
const communityCustomHandlers = useMemo(
|
||||
() => createListCustomHandlers(setCommunitySizeOptions, "Unselected"),
|
||||
[],
|
||||
() =>
|
||||
createListCustomHandlers(
|
||||
setCommunitySizeOptions,
|
||||
"Unselected",
|
||||
markCreateFlowInteraction,
|
||||
),
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
const organizationCustomHandlers = useMemo(
|
||||
() => createListCustomHandlers(setOrganizationTypeOptions, "Unselected"),
|
||||
[],
|
||||
() =>
|
||||
createListCustomHandlers(
|
||||
setOrganizationTypeOptions,
|
||||
"Unselected",
|
||||
markCreateFlowInteraction,
|
||||
),
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
const governanceCustomHandlers = useMemo(
|
||||
() => createListCustomHandlers(setGovernanceStyleOptions, "Unselected"),
|
||||
[],
|
||||
() =>
|
||||
createListCustomHandlers(
|
||||
setGovernanceStyleOptions,
|
||||
"Unselected",
|
||||
markCreateFlowInteraction,
|
||||
),
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleCommunitySizeClick = (chipId: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setCommunitySizeOptions((prev) =>
|
||||
prev.map((opt) =>
|
||||
opt.id === chipId
|
||||
@@ -111,6 +137,7 @@ export default function SelectPage() {
|
||||
};
|
||||
|
||||
const handleOrganizationTypeClick = (chipId: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setOrganizationTypeOptions((prev) =>
|
||||
prev.map((opt) =>
|
||||
opt.id === chipId
|
||||
@@ -124,6 +151,7 @@ export default function SelectPage() {
|
||||
};
|
||||
|
||||
const handleGovernanceStyleClick = (chipId: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setGovernanceStyleOptions((prev) =>
|
||||
prev.map((opt) =>
|
||||
opt.id === chipId
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import TextInput from "../../components/controls/TextInput";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
/**
|
||||
* Text page for the create flow
|
||||
@@ -12,9 +13,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
@@ -47,8 +47,14 @@ export interface CreateFlowContextValue {
|
||||
updateState: (_updates: Partial<CreateFlowState>) => void;
|
||||
/** Replace entire flow state (e.g. hydrate from server draft). */
|
||||
replaceState: (_next: CreateFlowState) => void;
|
||||
/** Clear all flow state (e.g. on exit). Also clears persisted draft. */
|
||||
/** Reset flow state and clear anonymous localStorage draft keys when present. */
|
||||
clearState: () => void;
|
||||
/**
|
||||
* True after the user edits any template control (pages use local state until wired to `state`).
|
||||
* Drives Save & Exit visibility together with `hasCreateFlowUserInput(state)`.
|
||||
*/
|
||||
interactionTouched: boolean;
|
||||
markCreateFlowInteraction: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import Upload from "../../components/controls/Upload";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
/**
|
||||
* Upload page for the create flow
|
||||
@@ -13,6 +14,7 @@ import Upload from "../../components/controls/Upload";
|
||||
* Responsive sizing: uses L/M for HeaderLockup based on 640px breakpoint.
|
||||
*/
|
||||
export default function UploadPage() {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
@@ -25,6 +27,7 @@ export default function UploadPage() {
|
||||
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
|
||||
|
||||
const handleUploadClick = () => {
|
||||
markCreateFlowInteraction();
|
||||
// TODO: Handle upload button click (e.g. open file picker)
|
||||
};
|
||||
|
||||
|
||||
+8
-5
@@ -1,6 +1,7 @@
|
||||
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { AuthModalProvider } from "./contexts/AuthModalContext";
|
||||
import { MessagesProvider } from "./contexts/MessagesContext";
|
||||
import messages from "../messages/en/index";
|
||||
import "./globals.css";
|
||||
@@ -101,11 +102,13 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable}`}
|
||||
>
|
||||
<MessagesProvider messages={messages}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<ConditionalNavigation />
|
||||
<main className="flex-1">{children}</main>
|
||||
<ConditionalFooter />
|
||||
</div>
|
||||
<AuthModalProvider>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<ConditionalNavigation />
|
||||
<main className="flex-1">{children}</main>
|
||||
<ConditionalFooter />
|
||||
</div>
|
||||
</AuthModalProvider>
|
||||
</MessagesProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "../contexts/MessagesContext";
|
||||
import Login from "../components/modals/Login";
|
||||
import LoginForm from "../components/modals/Login/LoginForm";
|
||||
|
||||
export default function LoginPageClient() {
|
||||
const router = useRouter();
|
||||
const t = useTranslation("pages.login");
|
||||
|
||||
return (
|
||||
<div className="min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]">
|
||||
<Login
|
||||
isOpen
|
||||
usePortal={false}
|
||||
onClose={() => {
|
||||
router.push("/");
|
||||
}}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
belowCard={
|
||||
<Link
|
||||
href="/"
|
||||
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
||||
>
|
||||
{t("backToHome")}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<LoginForm />
|
||||
</Login>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Log in · CommunityRule",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function LoginLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
+46
-10
@@ -1,15 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import LoginPageClient from "./LoginPageClient";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "../contexts/MessagesContext";
|
||||
import Login from "../components/modals/Login";
|
||||
import LoginForm from "../components/modals/Login/LoginForm";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Log in · CommunityRule",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
const loginPageBgClass =
|
||||
"min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]";
|
||||
|
||||
function LoginFallback() {
|
||||
function LoginLoadingFallback() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-surface-inverse-brand-primary)] flex items-center justify-center">
|
||||
<div className={`${loginPageBgClass} flex items-center justify-center`}>
|
||||
<p className="font-inter text-[14px] text-[var(--color-content-default-primary)]">
|
||||
Loading…
|
||||
</p>
|
||||
@@ -17,10 +20,43 @@ function LoginFallback() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-page login shell for magic-link **error redirects** (`?error=*`) and direct `/login` visits.
|
||||
* Header **Log in** uses `AuthModalProvider` instead; this route stays for verify failures and bookmarks.
|
||||
*/
|
||||
function LoginWithSearchParams() {
|
||||
const router = useRouter();
|
||||
const t = useTranslation("pages.login");
|
||||
|
||||
return (
|
||||
<div className={loginPageBgClass}>
|
||||
<Login
|
||||
isOpen
|
||||
usePortal={false}
|
||||
backdropVariant="solid"
|
||||
onClose={() => {
|
||||
router.push("/");
|
||||
}}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
belowCard={
|
||||
<Link
|
||||
href="/"
|
||||
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
||||
>
|
||||
{t("backToHome")}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<LoginForm />
|
||||
</Login>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoginFallback />}>
|
||||
<LoginPageClient />
|
||||
<Suspense fallback={<LoginLoadingFallback />}>
|
||||
<LoginWithSearchParams />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user