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>
|
||||
|
||||
Reference in New Issue
Block a user