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
@@ -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>