Component cleanup

This commit is contained in:
adilallo
2026-04-29 07:20:16 -06:00
parent 252848eba9
commit e6127f1a3f
267 changed files with 2087 additions and 2196 deletions
@@ -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;
}
+298
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
export { default } from "./Top.container";
export type { TopProps, NavSize } from "./Top.types";
export { avatarImages } from "./Top.container";