Magic-link sign in UI and APIs
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { LoginView } from "./Login.view";
|
||||
import type { LoginProps } from "./Login.types";
|
||||
|
||||
const LoginContainer = memo<LoginProps>(
|
||||
({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
belowCard,
|
||||
className = "",
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
usePortal = true,
|
||||
}) => {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const previousActiveElementRef = useRef<HTMLElement | null>(null);
|
||||
const [portalReady, setPortalReady] = useState(() => !usePortal);
|
||||
|
||||
// Defer enabling the portal until after the layout commit so we don’t sync-setState
|
||||
// inside the effect (eslint react-hooks/set-state-in-effect) while still mounting
|
||||
// before the next paint, avoiding a flash of underlying layout.
|
||||
useLayoutEffect(() => {
|
||||
if (!usePortal) return;
|
||||
const id = requestAnimationFrame(() => {
|
||||
setPortalReady(true);
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [usePortal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (usePortal && !portalReady) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [isOpen, portalReady, onClose, usePortal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (usePortal && !portalReady) return;
|
||||
|
||||
previousActiveElementRef.current = document.activeElement as HTMLElement;
|
||||
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
if (dialogRef.current) {
|
||||
const dialog = dialogRef.current;
|
||||
const focusableSelector =
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const focusInitial = () => {
|
||||
const emailField = dialog.querySelector<HTMLInputElement>(
|
||||
'input[type="email"]:not([disabled])',
|
||||
);
|
||||
if (emailField) {
|
||||
emailField.focus();
|
||||
return;
|
||||
}
|
||||
const focusableElements = dialog.querySelectorAll(focusableSelector);
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
if (firstElement) {
|
||||
firstElement.focus();
|
||||
} else {
|
||||
dialog.setAttribute("tabindex", "-1");
|
||||
dialog.focus();
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(focusInitial);
|
||||
});
|
||||
}
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab" || !dialogRef.current) return;
|
||||
|
||||
const focusableElements = dialogRef.current.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
) as NodeListOf<HTMLElement>;
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[
|
||||
focusableElements.length - 1
|
||||
] as HTMLElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement?.focus();
|
||||
}
|
||||
} else if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleTab);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
document.removeEventListener("keydown", handleTab);
|
||||
previousActiveElementRef.current?.focus();
|
||||
};
|
||||
}, [isOpen, portalReady, usePortal]);
|
||||
|
||||
return (
|
||||
<LoginView
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
belowCard={belowCard}
|
||||
className={className}
|
||||
ariaLabel={ariaLabel}
|
||||
ariaLabelledBy={ariaLabelledBy}
|
||||
dialogRef={dialogRef}
|
||||
backdropRef={backdropRef}
|
||||
portalReady={portalReady}
|
||||
usePortal={usePortal}
|
||||
>
|
||||
{children}
|
||||
</LoginView>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
LoginContainer.displayName = "Login";
|
||||
|
||||
export default LoginContainer;
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface LoginProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children?: React.ReactNode;
|
||||
/** Rendered below the dialog card (e.g. “Back to home”) on the dimmed backdrop */
|
||||
belowCard?: React.ReactNode;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledBy?: string;
|
||||
/**
|
||||
* When false, render the overlay in the React tree instead of `document.body`.
|
||||
* Use on the dedicated `/login` page so the shell (and heading) mount on first paint
|
||||
* without waiting for a portal gate (more reliable across engines).
|
||||
*/
|
||||
usePortal?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginViewProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children?: React.ReactNode;
|
||||
belowCard?: React.ReactNode;
|
||||
className: string;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledBy?: string;
|
||||
dialogRef: React.RefObject<HTMLDivElement | null>;
|
||||
backdropRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** False until client mount — avoids SSR/client HTML mismatch for createPortal. */
|
||||
portalReady: boolean;
|
||||
usePortal: boolean;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import ModalHeader from "../../utility/ModalHeader";
|
||||
import type { LoginViewProps } from "./Login.types";
|
||||
|
||||
export function LoginView({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
belowCard,
|
||||
className,
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
dialogRef,
|
||||
backdropRef,
|
||||
portalReady,
|
||||
usePortal,
|
||||
}: LoginViewProps) {
|
||||
if (!isOpen) return null;
|
||||
if (usePortal && !portalReady) return null;
|
||||
|
||||
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"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
className={`flex min-h-0 max-h-[90vh] w-full max-w-[560px] shrink-0 flex-col overflow-hidden rounded-[var(--radius-500,20px)] bg-[var(--color-surface-default-primary)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] z-[9999] ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
|
||||
<div className="scrollbar-design flex min-h-0 flex-1 flex-col overflow-x-clip overflow-y-auto px-6 pb-8 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{belowCard ? (
|
||||
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{belowCard}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (usePortal) {
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useId, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import Button from "../../buttons/Button";
|
||||
import TextInput from "../../controls/TextInput";
|
||||
import ContentLockup from "../../type/ContentLockup";
|
||||
import { requestMagicLink } from "../../../../lib/create/api";
|
||||
import { safeInternalPath } from "../../../../lib/safeInternalPath";
|
||||
|
||||
/** Mail icon for login modal (inline SVG; same pattern as InfoMessageBox ExclamationIconInline). */
|
||||
function MailIconInline() {
|
||||
return (
|
||||
<svg
|
||||
width={22}
|
||||
height={22}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
aria-hidden
|
||||
data-name="Asset / Icon / mail"
|
||||
>
|
||||
<path
|
||||
fill="#000000"
|
||||
d="M1.5 8.67v8.58a3 3 0 0 0 3 3h15a3 3 0 0 0 3-3V8.67l-8.928 5.493a3 3 0 0 1-3.144 0L1.5 8.67Z"
|
||||
/>
|
||||
<path
|
||||
fill="#000000"
|
||||
d="M22.5 6.908V6.75A2.25 2.25 0 0 0 20.25 4.5h-16.5A2.25 2.25 0 0 0 1.5 6.75v.158l9.714 5.978a1.5 1.5 0 0 0 1.572 0L22.5 6.908Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export default function LoginForm() {
|
||||
const t = useTranslation("pages.login");
|
||||
const tFooter = useTranslation("footer");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const formAlertId = useId();
|
||||
const emailErrorId = useId();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [emailError, setEmailError] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const nextParam = searchParams.get("next");
|
||||
const errorParam = searchParams.get("error");
|
||||
|
||||
/** Drop `error` from the URL so URL-driven messages don’t linger after a new attempt. */
|
||||
const stripErrorQuery = useCallback(() => {
|
||||
if (!searchParams.get("error")) return;
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("error");
|
||||
const q = params.toString();
|
||||
router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false });
|
||||
}, [pathname, router, searchParams]);
|
||||
|
||||
const sendLink = useCallback(async () => {
|
||||
stripErrorQuery();
|
||||
setEmailError("");
|
||||
setFormError("");
|
||||
const trimmed = email.trim().toLowerCase();
|
||||
if (!EMAIL_PATTERN.test(trimmed)) {
|
||||
setEmailError(t("errors.emailInvalid"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const nextPath = safeInternalPath(nextParam);
|
||||
const result = await requestMagicLink(trimmed, nextPath);
|
||||
if (result.ok === false) {
|
||||
if (result.retryAfterMs != null && result.retryAfterMs > 0) {
|
||||
const seconds = Math.ceil(result.retryAfterMs / 1000);
|
||||
setFormError(
|
||||
t("errors.rateLimited").replace("{seconds}", String(seconds)),
|
||||
);
|
||||
} else {
|
||||
setFormError(result.error || t("errors.generic"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
setEmail(trimmed);
|
||||
setSent(true);
|
||||
} catch {
|
||||
setFormError(t("errors.network"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [email, nextParam, stripErrorQuery, t]);
|
||||
|
||||
const urlErrorMessage =
|
||||
errorParam === "expired_link"
|
||||
? t("errors.expiredLink")
|
||||
: errorParam === "invalid_link" || errorParam === "server"
|
||||
? errorParam === "server"
|
||||
? t("errors.serverError")
|
||||
: t("errors.invalidLink")
|
||||
: "";
|
||||
|
||||
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)]">
|
||||
<MailIconInline />
|
||||
</div>
|
||||
<ContentLockup
|
||||
titleId="login-modal-heading"
|
||||
title={sent ? t("successTitle") : t("title")}
|
||||
description={sent ? t("successBody") : t("subtitle")}
|
||||
variant="login"
|
||||
alignment="left"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{urlErrorMessage ? (
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="text-center font-inter text-[14px] leading-[20px] text-[var(--color-border-default-utility-negative)]"
|
||||
>
|
||||
{urlErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{formError ? (
|
||||
<p
|
||||
id={formAlertId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="font-inter text-[14px] leading-[20px] text-[var(--color-border-default-utility-negative)]"
|
||||
>
|
||||
{formError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!sent ? (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void sendLink();
|
||||
}}
|
||||
noValidate
|
||||
>
|
||||
<TextInput
|
||||
label={t("emailLabel")}
|
||||
placeholder={t("emailPlaceholder")}
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
stripErrorQuery();
|
||||
}}
|
||||
disabled={submitting}
|
||||
error={Boolean(emailError)}
|
||||
showHelpIcon
|
||||
/>
|
||||
{emailError ? (
|
||||
<p
|
||||
id={emailErrorId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="font-inter text-[14px] text-[var(--color-border-default-utility-negative)]"
|
||||
>
|
||||
{emailError}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
size="large"
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
disabled={submitting}
|
||||
className="w-full !justify-center text-center px-[var(--spacing-scale-016)] py-[var(--spacing-scale-012)]"
|
||||
>
|
||||
{t("sendMagicLink")}
|
||||
</Button>
|
||||
<p className="text-center font-inter text-[14px] leading-[20px] text-[var(--color-content-default-tertiary)]">
|
||||
{t("legalPrefix")}
|
||||
<Link
|
||||
href="#"
|
||||
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
|
||||
>
|
||||
{tFooter("legal.termsOfService")}
|
||||
</Link>
|
||||
{t("legalAnd")}
|
||||
<Link
|
||||
href="#"
|
||||
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
|
||||
>
|
||||
{tFooter("legal.privacyPolicy")}
|
||||
</Link>
|
||||
{t("legalSuffix")}
|
||||
</p>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Login.container";
|
||||
export type { LoginProps } from "./Login.types";
|
||||
@@ -14,13 +14,14 @@ const Footer = dynamic(() => import("./Footer"), {
|
||||
|
||||
/**
|
||||
* Conditionally renders Footer based on pathname.
|
||||
* Hides footer for /create/* routes (full-screen create flow).
|
||||
* Hides footer for /create/* and /login (full-screen flows; login uses a body portal).
|
||||
*/
|
||||
const ConditionalFooter = memo(() => {
|
||||
const pathname = usePathname();
|
||||
const isCreateFlow = pathname?.startsWith("/create");
|
||||
const isLogin = pathname === "/login";
|
||||
|
||||
if (isCreateFlow) {
|
||||
if (isCreateFlow || isLogin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import TopNavWithPathname from "./TopNav/TopNavWithPathname";
|
||||
import { getNavAuthSignedIn } from "../../../lib/server/navAuth";
|
||||
import ConditionalNavigationClient from "./ConditionalNavigationClient";
|
||||
|
||||
/**
|
||||
* Conditionally renders TopNav based on pathname.
|
||||
* Hides navigation for /create/* routes (full-screen create flow).
|
||||
* Resolves the session on the server so the header matches the HttpOnly cookie on the
|
||||
* first HTML response (no “Log in” flash before `/api/auth/session`).
|
||||
*/
|
||||
const ConditionalNavigation = memo(() => {
|
||||
const pathname = usePathname();
|
||||
const isCreateFlow = pathname?.startsWith("/create");
|
||||
|
||||
if (isCreateFlow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TopNavWithPathname />;
|
||||
});
|
||||
|
||||
ConditionalNavigation.displayName = "ConditionalNavigation";
|
||||
|
||||
export default ConditionalNavigation;
|
||||
export default async function ConditionalNavigation() {
|
||||
const initialSignedIn = await getNavAuthSignedIn();
|
||||
return <ConditionalNavigationClient initialSignedIn={initialSignedIn} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import TopNavWithPathname from "./TopNav/TopNavWithPathname";
|
||||
|
||||
export type ConditionalNavigationClientProps = {
|
||||
initialSignedIn: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Client shell: pathname-based visibility. Session for the first paint comes from the
|
||||
* parent Server Component (`ConditionalNavigation`) via `initialSignedIn`.
|
||||
*/
|
||||
const ConditionalNavigationClient = memo(
|
||||
({ initialSignedIn }: ConditionalNavigationClientProps) => {
|
||||
const pathname = usePathname();
|
||||
const isCreateFlow = pathname?.startsWith("/create");
|
||||
const isLogin = pathname === "/login";
|
||||
|
||||
if (isCreateFlow || isLogin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TopNavWithPathname initialSignedIn={initialSignedIn} />;
|
||||
},
|
||||
);
|
||||
|
||||
ConditionalNavigationClient.displayName = "ConditionalNavigationClient";
|
||||
|
||||
export default ConditionalNavigationClient;
|
||||
@@ -139,14 +139,24 @@ 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")
|
||||
: t("ariaLabels.logInToAccount");
|
||||
const navSelected =
|
||||
(loggedIn && pathname === "/profile") ||
|
||||
(!loggedIn && pathname === "/login");
|
||||
|
||||
return (
|
||||
<MenuBarItem
|
||||
href="#"
|
||||
href={href}
|
||||
size={sizeMap[size] || "Small"}
|
||||
mode={mode}
|
||||
ariaLabel={t("ariaLabels.logInToAccount")}
|
||||
state={navSelected ? "selected" : "default"}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{t("buttons.logIn")}
|
||||
{label}
|
||||
</MenuBarItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import TopNav from "./TopNav.container";
|
||||
import type { TopNavProps } from "./TopNav.types";
|
||||
import { fetchAuthSession } from "../../../../lib/create/api";
|
||||
|
||||
export type TopNavWithPathnameProps = Omit<TopNavProps, "folderTop"> & {
|
||||
/** From Server Component (`getNavAuthSignedIn`); matches first HTML paint. */
|
||||
initialSignedIn?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* TopNav wrapper that automatically determines folderTop based on current pathname.
|
||||
* Use this in layout.tsx instead of ConditionalHeader.
|
||||
* TopNav wrapper: `folderTop` from pathname; Log in vs Profile from session.
|
||||
*
|
||||
* **SSR:** Parent passes `initialSignedIn` from `getSessionUser()` so the hydrated
|
||||
* header matches the cookie (Next.js pattern for HttpOnly session UI).
|
||||
*
|
||||
* **Client:** Refetch on pathname change (magic-link redirect, stale layout after
|
||||
* `router.refresh()`), **popstate** / **pageshow** `persisted` (bfcache / back).
|
||||
*/
|
||||
const TopNavWithPathname = memo<Omit<TopNavProps, "folderTop">>((props) => {
|
||||
const TopNavWithPathname = memo<TopNavWithPathnameProps>((props) => {
|
||||
const { initialSignedIn = false, ...topNavRest } = props;
|
||||
const pathname = usePathname();
|
||||
const isHomePage = pathname === "/";
|
||||
const [loggedIn, setLoggedIn] = useState(initialSignedIn);
|
||||
|
||||
return <TopNav {...props} folderTop={isHomePage} />;
|
||||
useEffect(() => {
|
||||
setLoggedIn(initialSignedIn);
|
||||
}, [initialSignedIn]);
|
||||
|
||||
const applySessionUser = useCallback(
|
||||
(user: { id: string; email: string } | null) => {
|
||||
setLoggedIn(Boolean(user));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const syncSession = useCallback(() => {
|
||||
fetchAuthSession().then(({ user }) => {
|
||||
applySessionUser(user);
|
||||
});
|
||||
}, [applySessionUser]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchAuthSession().then(({ user }) => {
|
||||
if (!cancelled) applySessionUser(user);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pathname, applySessionUser]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPageShow = (e: PageTransitionEvent) => {
|
||||
if (e.persisted) syncSession();
|
||||
};
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
return () => window.removeEventListener("pageshow", onPageShow);
|
||||
}, [syncSession]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => {
|
||||
queueMicrotask(syncSession);
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
}, [syncSession]);
|
||||
|
||||
return <TopNav {...topNavRest} folderTop={isHomePage} loggedIn={loggedIn} />;
|
||||
});
|
||||
|
||||
TopNavWithPathname.displayName = "TopNavWithPathname";
|
||||
|
||||
@@ -112,6 +112,20 @@ const ContentLockupContainer = memo<ContentLockupProps>(
|
||||
"font-inter font-normal text-[16px] leading-[24px] tracking-[0] text-[var(--color-content-default-tertiary)] text-left",
|
||||
shape: "w-[16px] h-[16px]",
|
||||
},
|
||||
login: {
|
||||
container:
|
||||
"flex flex-col gap-[var(--spacing-scale-012)] items-start justify-center relative w-full",
|
||||
textContainer: "flex flex-col gap-[var(--spacing-scale-012)] w-full",
|
||||
titleGroup: "flex flex-col gap-[var(--spacing-scale-012)] w-full",
|
||||
titleContainer: "flex items-center justify-start w-full",
|
||||
title:
|
||||
"font-bricolage-grotesque font-extrabold text-[36px] leading-[44px] tracking-[0] text-[var(--color-content-default-primary)] text-left",
|
||||
subtitle:
|
||||
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] text-[var(--color-content-default-tertiary)] text-left",
|
||||
description:
|
||||
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] text-[var(--color-content-default-tertiary)] text-left",
|
||||
shape: "w-[16px] h-[16px]",
|
||||
},
|
||||
};
|
||||
|
||||
const styles = variantStyles[variant] || variantStyles.hero;
|
||||
|
||||
@@ -5,12 +5,14 @@ export type ContentLockupVariantValue =
|
||||
| "ask"
|
||||
| "ask-inverse"
|
||||
| "modal"
|
||||
| "login"
|
||||
| "Hero"
|
||||
| "Feature"
|
||||
| "Learn"
|
||||
| "Ask"
|
||||
| "Ask-Inverse"
|
||||
| "Modal";
|
||||
| "Modal"
|
||||
| "Login";
|
||||
|
||||
export type ContentLockupAlignmentValue = "center" | "left" | "Center" | "Left";
|
||||
|
||||
@@ -58,7 +60,14 @@ export interface ContentLockupViewProps {
|
||||
ctaText?: string;
|
||||
ctaHref?: string;
|
||||
buttonClassName: string;
|
||||
variant: "hero" | "feature" | "learn" | "ask" | "ask-inverse" | "modal";
|
||||
variant:
|
||||
| "hero"
|
||||
| "feature"
|
||||
| "learn"
|
||||
| "ask"
|
||||
| "ask-inverse"
|
||||
| "modal"
|
||||
| "login";
|
||||
linkText?: string;
|
||||
linkHref?: string;
|
||||
alignment: "center" | "left";
|
||||
|
||||
@@ -20,18 +20,21 @@ function ContentLockupView({
|
||||
}: ContentLockupViewProps) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{variant === "ask" || variant === "ask-inverse" || variant === "modal" ? (
|
||||
/* Simplified structure for ask and modal variants */
|
||||
{variant === "ask" ||
|
||||
variant === "ask-inverse" ||
|
||||
variant === "modal" ||
|
||||
variant === "login" ? (
|
||||
/* Simplified structure for ask, modal, and login variants */
|
||||
<div
|
||||
className={`${styles.titleGroup} ${
|
||||
alignment === "left" || variant === "modal"
|
||||
alignment === "left" || variant === "modal" || variant === "login"
|
||||
? "text-left"
|
||||
: "text-center"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`${styles.titleContainer} ${
|
||||
alignment === "left" || variant === "modal"
|
||||
alignment === "left" || variant === "modal" || variant === "login"
|
||||
? "justify-start"
|
||||
: "justify-center"
|
||||
}`}
|
||||
@@ -43,7 +46,7 @@ function ContentLockupView({
|
||||
) : null}
|
||||
</div>
|
||||
{subtitle ? <h2 className={styles.subtitle}>{subtitle}</h2> : null}
|
||||
{variant === "modal" && description && (
|
||||
{(variant === "modal" || variant === "login") && description && (
|
||||
<p className={styles.description}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||
import type { ModalHeaderProps } from "./ModalHeader.types";
|
||||
|
||||
const iconButtonClass =
|
||||
"absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full flex items-center justify-center cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]";
|
||||
|
||||
export function ModalHeaderView({
|
||||
onClose,
|
||||
onMoreOptions,
|
||||
@@ -15,8 +18,9 @@ export function ModalHeaderView({
|
||||
{/* Close Button - Left */}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full left-[24px] top-[12px] flex items-center justify-center cursor-pointer"
|
||||
className={`${iconButtonClass} left-[24px] top-[12px]`}
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
|
||||
@@ -34,8 +38,9 @@ export function ModalHeaderView({
|
||||
{/* More Options Button - Right */}
|
||||
{showMoreOptionsButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoreOptions}
|
||||
className="absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full right-[24px] top-[12px] flex items-center justify-center cursor-pointer"
|
||||
className={`${iconButtonClass} right-[24px] top-[12px]`}
|
||||
aria-label="More options"
|
||||
>
|
||||
<svg
|
||||
|
||||
Reference in New Issue
Block a user