Component cleanup
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useAuthModal } from "../../../contexts/AuthModalContext";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import MenuItem from "../MenuItem";
|
||||
import Button from "../../buttons/Button";
|
||||
import AvatarContainer from "../../asset/AvatarContainer";
|
||||
import Avatar from "../../asset/Avatar";
|
||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
||||
import { clearAnonymousCreateFlowStorage } from "../../../(app)/create/utils/anonymousDraftStorage";
|
||||
import { clearCoreValueDetailsLocalStorage } from "../../../(app)/create/utils/coreValueDetailsLocalStorage";
|
||||
import { TopView } from "./Top.view";
|
||||
import type { TopProps, NavSize } from "./Top.types";
|
||||
|
||||
type MenuClusterSize = "X Small" | "Small" | "Medium" | "Large" | "X Large";
|
||||
|
||||
/** Map responsive `NavSize` breakpoints to Figma menu item sizes (shared by nav links + login). */
|
||||
const NAV_SIZE_TO_MENU_ITEM_SIZE: Record<NavSize, MenuClusterSize> = {
|
||||
default: "Small",
|
||||
xsmall: "X Small",
|
||||
xsmallUseCases: "X Small",
|
||||
home: "X Small",
|
||||
homeMd: "Medium",
|
||||
homeUseCases: "Small",
|
||||
large: "Large",
|
||||
largeUseCases: "Large",
|
||||
homeXlarge: "X Large",
|
||||
xlarge: "X Large",
|
||||
};
|
||||
|
||||
export const avatarImages = [
|
||||
{ src: getAssetPath(ASSETS.AVATAR_1), alt: "Avatar 1" },
|
||||
{ src: getAssetPath(ASSETS.AVATAR_2), alt: "Avatar 2" },
|
||||
{ src: getAssetPath(ASSETS.AVATAR_3), alt: "Avatar 3" },
|
||||
];
|
||||
|
||||
const TopContainer = memo<TopProps>(
|
||||
({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { openLogin } = useAuthModal();
|
||||
const t = useTranslation("header");
|
||||
|
||||
/**
|
||||
* `Top` is hidden on `/create` routes by ConditionalNavigationClient, so
|
||||
* this button is always clicked from outside the wizard — there is no
|
||||
* mounted CreateFlowProvider to reset. Wiping the anonymous draft keys
|
||||
* here guarantees a fresh start; the provider that mounts on `/create`
|
||||
* will read empty storage. Server drafts (signed-in Save & Exit) are
|
||||
* left alone — they're intentional persistence the user opted into.
|
||||
*/
|
||||
const handleCreateRuleClick = useCallback(() => {
|
||||
clearAnonymousCreateFlowStorage();
|
||||
clearCoreValueDetailsLocalStorage();
|
||||
router.push("/create");
|
||||
}, [router]);
|
||||
|
||||
// Schema markup for site navigation
|
||||
const schemaData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
name: "CommunityRule",
|
||||
url: "https://communityrule.com",
|
||||
...(folderTop && {
|
||||
description: "Build operating manuals for successful communities",
|
||||
}),
|
||||
potentialAction: {
|
||||
"@type": "SearchAction",
|
||||
target: "https://communityrule.com/search?q={search_term_string}",
|
||||
"query-input": "required name=search_term_string",
|
||||
},
|
||||
};
|
||||
|
||||
// Logo size based on folderTop prop
|
||||
const logoSize = folderTop ? "topNavFolderTop" : "topNavHeader";
|
||||
|
||||
// Navigation items with translations
|
||||
const navigationItems = [
|
||||
{ href: "#", text: t("navigation.useCases"), extraPadding: true },
|
||||
{ href: "/learn", text: t("navigation.learn") },
|
||||
{ href: "#", text: t("navigation.about") },
|
||||
];
|
||||
|
||||
const renderNavigationItems = (size: NavSize) => {
|
||||
const mode = folderTop ? "inverse" : "default";
|
||||
|
||||
return navigationItems.map((item, index) => {
|
||||
const itemSize = NAV_SIZE_TO_MENU_ITEM_SIZE[size];
|
||||
|
||||
const isUseCases = item.extraPadding === true;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={index}
|
||||
href={item.href}
|
||||
size={itemSize}
|
||||
mode={mode}
|
||||
state={pathname === item.href ? "selected" : "default"}
|
||||
reducedPadding={isUseCases}
|
||||
ariaLabel={t("ariaLabels.navigateToPage").replace(
|
||||
"{text}",
|
||||
item.text,
|
||||
)}
|
||||
>
|
||||
{item.text}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const renderAvatarGroup = (
|
||||
containerSize: "small" | "medium" | "large" | "xlarge",
|
||||
avatarSize: "small" | "medium" | "large" | "xlarge",
|
||||
) => {
|
||||
return (
|
||||
<AvatarContainer size={containerSize}>
|
||||
{avatarImages.map((avatar, index) => (
|
||||
<Avatar
|
||||
key={index}
|
||||
src={avatar.src}
|
||||
alt={avatar.alt}
|
||||
size={avatarSize}
|
||||
/>
|
||||
))}
|
||||
</AvatarContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLoginButton = (size: NavSize) => {
|
||||
const itemSize = NAV_SIZE_TO_MENU_ITEM_SIZE[size];
|
||||
|
||||
// Determine mode based on folderTop and breakpoint size
|
||||
// folderTop: inverse mode (black text) for smallest breakpoints (xsmall/home)
|
||||
// folderTop: default mode (yellow text) for 640px+ breakpoints (homeMd/large/homeXlarge/xlarge)
|
||||
// false folderTop: always default mode (yellow text on dark background)
|
||||
const isSmallBreakpoint = size === "xsmall" || size === "home";
|
||||
const mode = folderTop && isSmallBreakpoint ? "inverse" : "default";
|
||||
|
||||
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");
|
||||
|
||||
if (loggedIn) {
|
||||
return (
|
||||
<MenuItem
|
||||
href="/profile"
|
||||
size={itemSize}
|
||||
mode={mode}
|
||||
state={navSelected ? "selected" : "default"}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
buttonOnClick={() =>
|
||||
openLogin({
|
||||
variant: "default",
|
||||
backdropVariant: "blurredYellow",
|
||||
nextPath: pathname || "/",
|
||||
})
|
||||
}
|
||||
href="/login"
|
||||
size={itemSize}
|
||||
mode={mode}
|
||||
state={navSelected ? "selected" : "default"}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCreateRuleButton = (
|
||||
buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
|
||||
containerSize: "small" | "medium" | "large" | "xlarge",
|
||||
avatarSize: "small" | "medium" | "large" | "xlarge",
|
||||
) => {
|
||||
// Use ghost type when folderTop is true, filled (default) otherwise
|
||||
const buttonType = folderTop ? "ghost" : "filled";
|
||||
const palette = "default";
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={buttonSize}
|
||||
buttonType={buttonType}
|
||||
palette={palette}
|
||||
onClick={handleCreateRuleClick}
|
||||
ariaLabel={t("ariaLabels.createNewRule")}
|
||||
>
|
||||
{renderAvatarGroup(containerSize, avatarSize)}
|
||||
<span>{t("buttons.createRule")}</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TopView
|
||||
folderTop={folderTop}
|
||||
loggedIn={loggedIn}
|
||||
profile={profile}
|
||||
logIn={logIn}
|
||||
schemaData={schemaData}
|
||||
logoSize={logoSize}
|
||||
renderNavigationItems={renderNavigationItems}
|
||||
renderLoginButton={renderLoginButton}
|
||||
renderCreateRuleButton={renderCreateRuleButton}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TopContainer.displayName = "Top";
|
||||
|
||||
export default TopContainer;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type React from "react";
|
||||
|
||||
export interface TopProps {
|
||||
className?: string;
|
||||
size?: "320-429" | "430-639" | "640-1023" | "1024-1440" | "1440+";
|
||||
loggedIn?: boolean;
|
||||
folderTop?: boolean;
|
||||
profile?: boolean;
|
||||
logIn?: boolean;
|
||||
}
|
||||
|
||||
export type NavSize =
|
||||
| "default"
|
||||
| "xsmall"
|
||||
| "xsmallUseCases"
|
||||
| "home"
|
||||
| "homeMd"
|
||||
| "homeUseCases"
|
||||
| "large"
|
||||
| "largeUseCases"
|
||||
| "homeXlarge"
|
||||
| "xlarge";
|
||||
|
||||
export interface TopViewProps {
|
||||
folderTop: boolean;
|
||||
loggedIn: boolean;
|
||||
profile: boolean;
|
||||
logIn: boolean;
|
||||
schemaData: {
|
||||
"@context": string;
|
||||
"@type": string;
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
potentialAction: {
|
||||
"@type": string;
|
||||
target: string;
|
||||
"query-input": string;
|
||||
};
|
||||
};
|
||||
logoSize: "topNavFolderTop" | "topNavHeader";
|
||||
renderNavigationItems: (_size: NavSize) => React.ReactNode;
|
||||
renderLoginButton: (_size: NavSize) => React.ReactNode;
|
||||
renderCreateRuleButton: (
|
||||
_buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
|
||||
_containerSize: "small" | "medium" | "large" | "xlarge",
|
||||
_avatarSize: "small" | "medium" | "large" | "xlarge",
|
||||
) => React.ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Script from "next/script";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||
import Menu from "../Menu";
|
||||
import type { TopViewProps } from "./Top.types";
|
||||
|
||||
import Logo from "../../asset/Logo";
|
||||
|
||||
function TopView({
|
||||
folderTop,
|
||||
loggedIn: _loggedIn,
|
||||
profile: _profile,
|
||||
logIn,
|
||||
schemaData,
|
||||
logoSize,
|
||||
renderNavigationItems,
|
||||
renderLoginButton,
|
||||
renderCreateRuleButton,
|
||||
}: TopViewProps) {
|
||||
const t = useTranslation(folderTop ? "homeHeader" : "header");
|
||||
|
||||
// Render folderTop variant (HomeHeader style)
|
||||
if (folderTop) {
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
id="top-navigation-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
|
||||
/>
|
||||
<header
|
||||
className="w-full bg-transparent overflow-hidden"
|
||||
role="banner"
|
||||
aria-label={t("ariaLabels.homePageNavigationHeader")}
|
||||
>
|
||||
<nav
|
||||
className="relative flex items-center justify-between mx-auto h-[50px] sm:h-[62px] md:h-[68px] lg:h-[68px] xl:h-[88px] pl-[var(--spacing-scale-008)] pr-[var(--spacing-scale-016)] pt-[var(--spacing-scale-010)] sm:px-[var(--spacing-scale-010)] sm:pr-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] md:pr-[var(--spacing-scale-032)] md:pt-[var(--spacing-scale-016)] lg:pl-[var(--spacing-scale-024)] lg:pt-[var(--spacing-scale-016)] lg:pr-[var(--spacing-scale-056)] xl:pl-[var(--spacing-scale-048)] xl:pt-[var(--spacing-scale-024)] xl:pr-[var(--spacing-scale-056)]"
|
||||
role="navigation"
|
||||
aria-label={t("ariaLabels.mainNavigation")}
|
||||
>
|
||||
{/* Header Tab - Yellow tab container with decorative Union images */}
|
||||
<div className="HeaderTab header-breakpoint-transition relative bg-[var(--color-surface-inverse-brand-primary)] rounded-tl-[var(--radius-measures-radius-medium)] rounded-tr-[var(--radius-measures-radius-medium)] sm:rounded-t-[var(--radius-measures-radius-xlarge)] md:rounded-t-[var(--radius-measures-radius-xlarge)] lg:rounded-t-[var(--radius-measures-radius-xlarge)] xl:rounded-t-[var(--radius-measures-radius-xlarge)] pl-[var(--spacing-scale-012)] pr-[var(--spacing-scale-048)] h-[var(--spacing-scale-040)] sm:pl-[var(--spacing-scale-012)] sm:h-[52px] sm:pr-[var(--spacing-scale-006)] md:h-[52px] md:pl-[var(--spacing-scale-024)] md:pr-[var(--spacing-scale-012)] lg:h-[52px] lg:pl-[var(--spacing-scale-024)] lg:pr-[var(--spacing-scale-048)] xl:h-[64px] xl:pl-[var(--spacing-scale-032)] xl:pr-[var(--spacing-scale-120)] md:gap-[var(--spacing-scale-032)] flex-1 min-w-0 min-w-[197px] sm:min-w-0 sm:mr-[var(--spacing-scale-008)] md:mr-[185px] lg:mr-[var(--spacing-scale-024)] xl:mr-[var(--spacing-scale-032)] flex items-center self-end">
|
||||
{/* Logo - Consistent left positioning within HeaderTab */}
|
||||
<Logo
|
||||
size={logoSize}
|
||||
wordmark
|
||||
palette={folderTop ? "inverse" : "default"}
|
||||
/>
|
||||
|
||||
{/* XSmall menu — positioned next to logo */}
|
||||
<div className="block sm:hidden -me-[2px]">
|
||||
<Menu size="X Small">
|
||||
{renderNavigationItems("xsmall")}
|
||||
{logIn && renderLoginButton("xsmall")}
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
{/* Decorative Union images for tab appearance */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG, not content */}
|
||||
<img
|
||||
src={getAssetPath("assets/Union_xsm.svg")}
|
||||
alt=""
|
||||
role="presentation"
|
||||
className="absolute -bottom-[3px] -right-[52px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] sm:hidden -z-10"
|
||||
/>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG */}
|
||||
<img
|
||||
src={getAssetPath("assets/Union_sm_md_lg.svg")}
|
||||
alt=""
|
||||
role="presentation"
|
||||
className="absolute -bottom-[3.7px] -right-[53px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] hidden sm:block xl:hidden -z-10"
|
||||
/>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG */}
|
||||
<img
|
||||
src={getAssetPath("assets/Union_xlg.svg")}
|
||||
alt=""
|
||||
role="presentation"
|
||||
className="absolute -bottom-[6px] -right-[94px] w-[105px] h-[53px] hidden xl:block -z-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links - Centered in header for SM and up */}
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 hidden sm:block">
|
||||
{/* 430-639px (sm: breakpoint): Menu X Small */}
|
||||
<div className="hidden sm:block md:hidden">
|
||||
<Menu size="X Small">
|
||||
{renderNavigationItems("xsmall")}
|
||||
{logIn && renderLoginButton("xsmall")}
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
{/* 640-1023px (md: breakpoint): Menu Small */}
|
||||
<div className="hidden md:block lg:hidden">
|
||||
<Menu size="Small">
|
||||
{renderNavigationItems("homeMd")}
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
{/* 1024-1440px (lg: breakpoint): Menu Large */}
|
||||
<div className="hidden lg:block xl:hidden">
|
||||
<Menu size="Large">{renderNavigationItems("large")}</Menu>
|
||||
</div>
|
||||
|
||||
{/* 1440px+ (xl: breakpoint): Menu X Large */}
|
||||
<div className="hidden xl:block">
|
||||
<Menu size="X Large">
|
||||
{renderNavigationItems("homeXlarge")}
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication Elements - Consistent right alignment outside HeaderTab */}
|
||||
<div className="flex items-center">
|
||||
{/* XSmall and Small breakpoints - create rule button outside HeaderTab */}
|
||||
<div className="block md:hidden">
|
||||
{renderCreateRuleButton("xsmall", "small", "small")}
|
||||
</div>
|
||||
|
||||
{/* Medium breakpoint - login outside HeaderTab, create rule outside */}
|
||||
<div className="hidden md:block lg:hidden absolute right-[var(--spacing-measures-spacing-016)]">
|
||||
<div className="flex items-center gap-[var(--spacing-scale-010)]">
|
||||
{logIn && renderLoginButton("homeMd")}
|
||||
{renderCreateRuleButton("small", "medium", "medium")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large breakpoint */}
|
||||
<div className="hidden lg:flex xl:hidden items-center">
|
||||
<div className="flex items-center gap-[var(--spacing-scale-004)]">
|
||||
{logIn && renderLoginButton("large")}
|
||||
{renderCreateRuleButton("large", "large", "large")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* XLarge breakpoint */}
|
||||
<div className="hidden xl:flex items-center">
|
||||
<div className="flex items-center gap-[var(--spacing-scale-004)]">
|
||||
{logIn && renderLoginButton("homeXlarge")}
|
||||
{renderCreateRuleButton("xlarge", "xlarge", "xlarge")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard marketing / app top nav.
|
||||
* Figma: "Navigation / Top" (Community-Rule-System, node 22078-808559) — horizontal
|
||||
* padding, logo ~200px left, menu cluster centered in the bar (`left-1/2` + translate),
|
||||
* log in + create rule on the right. Breakpoints and Menu sizes unchanged from prior map.
|
||||
*/
|
||||
// Render standard variant (Header style)
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
|
||||
/>
|
||||
<header
|
||||
className="relative z-50 w-full border-b border-[var(--border-color-default-tertiary)] bg-[var(--color-surface-default-primary)]"
|
||||
role="banner"
|
||||
aria-label={t("ariaLabels.mainNavigationHeader")}
|
||||
>
|
||||
<nav
|
||||
className="relative flex w-full items-center
|
||||
px-[var(--spacing-scale-016)]
|
||||
py-[var(--spacing-scale-016)]
|
||||
sm:px-[var(--spacing-measures-spacing-016)]
|
||||
sm:py-[var(--spacing-scale-016)]
|
||||
lg:px-[var(--spacing-measures-spacing-64,64px)]
|
||||
lg:py-[var(--spacing-scale-016)]
|
||||
xl:py-[var(--spacing-scale-016)]
|
||||
min-h-[var(--spacing-scale-040)]"
|
||||
role="navigation"
|
||||
aria-label={t("ariaLabels.mainNavigation")}
|
||||
>
|
||||
<div
|
||||
className="relative z-20 min-w-0 shrink-0 sm:w-[200px] sm:max-w-[200px] sm:shrink-0"
|
||||
data-top="logo"
|
||||
>
|
||||
<Logo
|
||||
size={logoSize}
|
||||
wordmark
|
||||
palette={folderTop ? "inverse" : "default"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* XSmall: nav + login in flow (flex-1) — same as before */}
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center justify-end sm:hidden"
|
||||
data-top="nav-xs-flow"
|
||||
>
|
||||
<div className="block" data-testid="nav-xs">
|
||||
<Menu size="X Small">
|
||||
{renderNavigationItems("xsmall")}
|
||||
{logIn && renderLoginButton("xsmall")}
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* sm+ — Figma: nav cluster centered in bar (not between logo and actions) */}
|
||||
<div
|
||||
className="pointer-events-none hidden sm:absolute sm:left-1/2 sm:top-1/2 sm:z-10 sm:flex sm:-translate-x-1/2 sm:-translate-y-1/2 sm:items-center sm:justify-center"
|
||||
data-top="nav-center"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto hidden sm:flex md:hidden"
|
||||
data-testid="nav-sm"
|
||||
>
|
||||
<Menu size="X Small">
|
||||
{renderNavigationItems("xsmall")}
|
||||
{logIn && renderLoginButton("xsmall")}
|
||||
</Menu>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-auto hidden md:flex lg:hidden"
|
||||
data-testid="nav-md"
|
||||
>
|
||||
<Menu size="X Small">
|
||||
{renderNavigationItems("xsmall")}
|
||||
</Menu>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-auto hidden lg:flex xl:hidden"
|
||||
data-testid="nav-lg"
|
||||
>
|
||||
<Menu size="Large">{renderNavigationItems("large")}</Menu>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-auto hidden xl:flex"
|
||||
data-testid="nav-xl"
|
||||
>
|
||||
<Menu size="X Large">
|
||||
{renderNavigationItems("xlarge")}
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication Elements - Consistent right alignment across all breakpoints */}
|
||||
<div className="relative z-20 ml-auto flex shrink-0 items-center">
|
||||
{/* XSmall breakpoint - Only Create Rule button */}
|
||||
<div className="block sm:hidden shrink-0" data-testid="auth-xs">
|
||||
{renderCreateRuleButton("xsmall", "small", "small")}
|
||||
</div>
|
||||
|
||||
{/* Small breakpoint - Only Create Rule button */}
|
||||
<div className="hidden sm:block md:hidden" data-testid="auth-sm">
|
||||
<div className="flex items-center gap-[var(--spacing-scale-004)]">
|
||||
{renderCreateRuleButton("xsmall", "small", "small")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Medium breakpoint */}
|
||||
<div className="hidden md:block lg:hidden" data-testid="auth-md">
|
||||
<div className="flex items-center gap-[var(--spacing-measures-spacing-010)]">
|
||||
<Menu size="Small">
|
||||
{logIn && renderLoginButton("xsmall")}
|
||||
</Menu>
|
||||
{renderCreateRuleButton("xsmall", "medium", "medium")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large breakpoint */}
|
||||
<div className="hidden lg:block xl:hidden" data-testid="auth-lg">
|
||||
<div className="flex items-center gap-[var(--spacing-measures-spacing-004)]">
|
||||
<Menu size="Large">
|
||||
{logIn && renderLoginButton("large")}
|
||||
</Menu>
|
||||
{renderCreateRuleButton("large", "xlarge", "xlarge")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* XLarge breakpoint */}
|
||||
<div className="hidden xl:block" data-testid="auth-xl">
|
||||
<div className="flex items-center gap-[var(--spacing-measures-spacing-004)]">
|
||||
<Menu size="X Large">
|
||||
{logIn && renderLoginButton("xlarge")}
|
||||
</Menu>
|
||||
{renderCreateRuleButton("xlarge", "xlarge", "xlarge")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TopView.displayName = "TopView";
|
||||
|
||||
export default memo(TopView);
|
||||
export { TopView };
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Top from "./Top.container";
|
||||
import type { TopProps } from "./Top.types";
|
||||
import { fetchAuthSession } from "../../../../lib/create/api";
|
||||
|
||||
export type TopWithPathnameProps = Omit<TopProps, "folderTop"> & {
|
||||
/** From Server Component (`getNavAuthSignedIn`); matches first HTML paint. */
|
||||
initialSignedIn?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* `Top` 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 TopWithPathname = memo<TopWithPathnameProps>((props) => {
|
||||
const { initialSignedIn = false, ...topRest } = props;
|
||||
const pathname = usePathname();
|
||||
const isHomePage = pathname === "/";
|
||||
const [loggedIn, setLoggedIn] = useState(initialSignedIn);
|
||||
|
||||
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 <Top {...topRest} folderTop={isHomePage} loggedIn={loggedIn} />;
|
||||
});
|
||||
|
||||
TopWithPathname.displayName = "TopWithPathname";
|
||||
|
||||
export default TopWithPathname;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from "./Top.container";
|
||||
export type { TopProps, NavSize } from "./Top.types";
|
||||
export { avatarImages } from "./Top.container";
|
||||
Reference in New Issue
Block a user