summaryrefslogtreecommitdiff
path: root/components/layout/HeaderV2.tsx
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-12-04 21:05:28 +0900
committerjoonhoekim <26rote@gmail.com>2025-12-04 21:05:28 +0900
commite5b36fa6a1b12446883f51fc5e7cd56d8df8d8f5 (patch)
treec8f9fb50eb593dd5322d26d9276947c155997858 /components/layout/HeaderV2.tsx
parent240f4f31b3b6ff6a46436978fb988588a1972721 (diff)
parent04ed774ff60a83c00711d4e8615cb4122954dba5 (diff)
Merge branch 'jh-auth-menu' into dujinkim
Diffstat (limited to 'components/layout/HeaderV2.tsx')
-rw-r--r--components/layout/HeaderV2.tsx295
1 files changed, 295 insertions, 0 deletions
diff --git a/components/layout/HeaderV2.tsx b/components/layout/HeaderV2.tsx
new file mode 100644
index 00000000..88d50cc5
--- /dev/null
+++ b/components/layout/HeaderV2.tsx
@@ -0,0 +1,295 @@
+"use client";
+
+import * as React from "react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ NavigationMenu,
+ NavigationMenuContent,
+ NavigationMenuItem,
+ NavigationMenuLink,
+ NavigationMenuList,
+ NavigationMenuTrigger,
+ navigationMenuTriggerStyle,
+} from "@/components/ui/navigation-menu";
+import { SearchIcon, Loader2 } from "lucide-react";
+import { useParams, usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import { useSession } from "next-auth/react";
+import { customSignOut } from "@/lib/auth/custom-signout";
+import DynamicMenuRender from "./DynamicMenuRender";
+import { MobileMenuV2 } from "./MobileMenuV2";
+import { CommandMenu } from "./command-menu";
+import { NotificationDropdown } from "./NotificationDropdown";
+import { useVisibleMenuTree } from "@/hooks/use-visible-menu-tree";
+import { useTranslation } from "@/i18n/client";
+import type { MenuDomain, MenuTreeNode } from "@/lib/menu-v2/types";
+
+// 도메인별 브랜드명
+const domainBrandingKeys: Record<MenuDomain, string> = {
+ evcp: "branding.evcp_main",
+ partners: "branding.evcp_partners",
+};
+
+export function HeaderV2() {
+ const params = useParams();
+ const lng = (params?.lng as string) || "ko";
+ const pathname = usePathname();
+ const { data: session } = useSession();
+ const { t } = useTranslation(lng, "menu");
+
+ // 현재 도메인 결정
+ const domain: MenuDomain = pathname?.includes("/partners") ? "partners" : "evcp";
+
+ // 메뉴 데이터 로드 (tree에 드롭다운과 단일 링크가 모두 포함됨)
+ const { tree, isLoading } = useVisibleMenuTree(domain);
+
+ const userName = session?.user?.name || "";
+ const initials = userName
+ .split(" ")
+ .map((word) => word[0]?.toUpperCase())
+ .join("");
+
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
+ const [openMenuKey, setOpenMenuKey] = React.useState<string>("");
+
+ const toggleMobileMenu = () => {
+ setIsMobileMenuOpen(!isMobileMenuOpen);
+ };
+
+ const toggleMenu = React.useCallback((menuKey: string) => {
+ setOpenMenuKey((prev) => (prev === menuKey ? "" : menuKey));
+ }, []);
+
+ // 페이지 이동 시 메뉴 닫기
+ React.useEffect(() => {
+ setOpenMenuKey("");
+ }, [pathname]);
+
+ // 브랜딩 및 경로 설정
+ const brandNameKey = domainBrandingKeys[domain];
+ const logoHref = `/${lng}/${domain}`;
+ const basePath = `/${lng}/${domain}`;
+
+ // 다국어 텍스트 선택
+ const getTitle = (node: MenuTreeNode) =>
+ lng === "ko" ? node.titleKo : node.titleEn || node.titleKo;
+
+ const getDescription = (node: MenuTreeNode) =>
+ lng === "ko"
+ ? node.descriptionKo
+ : node.descriptionEn || node.descriptionKo;
+
+ // 메뉴 노드가 드롭다운(자식 있음)인지 단일 링크인지 판단
+ const isDropdownMenu = (node: MenuTreeNode) =>
+ node.nodeType === 'menu_group' && node.children && node.children.length > 0;
+
+ return (
+ <>
+ <header className="border-grid sticky top-0 z-40 w-full border-b bg-slate-100 backdrop-blur supports-[backdrop-filter]:bg-background/60">
+ <div className="container-wrapper">
+ <div className="container flex h-14 items-center">
+ {/* 햄버거 메뉴 버튼 (모바일) */}
+ <Button
+ onClick={toggleMobileMenu}
+ variant="ghost"
+ className="-ml-2 mr-2 h-8 w-8 px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden"
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ strokeWidth="1.5"
+ stroke="currentColor"
+ className="!size-6"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ d="M3.75 9h16.5m-16.5 6.75h16.5"
+ />
+ </svg>
+ <span className="sr-only">{t("menu.toggle_menu")}</span>
+ </Button>
+
+ {/* 로고 영역 */}
+ <div className="mr-4 flex-shrink-0 flex items-center gap-2 lg:mr-6">
+ <Link href={logoHref} className="flex items-center gap-2">
+ <Image
+ className="dark:invert"
+ src="/images/vercel.svg"
+ alt="EVCP Logo"
+ width={20}
+ height={20}
+ />
+ <span className="hidden font-bold lg:inline-block">
+ {t(brandNameKey)}
+ </span>
+ </Link>
+ </div>
+
+ {/* 네비게이션 메뉴 */}
+ <div className="hidden md:block flex-1 min-w-0">
+ {isLoading ? (
+ <div className="flex items-center justify-center h-10">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ </div>
+ ) : (
+ <NavigationMenu
+ className="relative z-50"
+ value={openMenuKey}
+ onValueChange={setOpenMenuKey}
+ >
+ <div className="w-full overflow-x-auto pb-1">
+ <NavigationMenuList className="flex-nowrap w-max">
+ {tree.map((node) => {
+ // 드롭다운 메뉴 (menu_group with children)
+ if (isDropdownMenu(node)) {
+ return (
+ <NavigationMenuItem
+ key={node.id}
+ value={String(node.id)}
+ >
+ <NavigationMenuTrigger
+ className="px-2 xl:px-3 text-sm whitespace-nowrap"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleMenu(String(node.id));
+ }}
+ onPointerEnter={(e) => e.preventDefault()}
+ onPointerMove={(e) => e.preventDefault()}
+ onPointerLeave={(e) => e.preventDefault()}
+ >
+ {getTitle(node)}
+ </NavigationMenuTrigger>
+
+ <NavigationMenuContent
+ className="max-h-[80vh] overflow-y-auto overflow-x-hidden"
+ onPointerEnter={(e) => e.preventDefault()}
+ onPointerLeave={(e) => e.preventDefault()}
+ forceMount={
+ openMenuKey === String(node.id)
+ ? true
+ : undefined
+ }
+ >
+ <DynamicMenuRender
+ groups={node.children}
+ lng={lng}
+ getTitle={getTitle}
+ getDescription={getDescription}
+ onItemClick={() => setOpenMenuKey("")}
+ />
+ </NavigationMenuContent>
+ </NavigationMenuItem>
+ );
+ }
+
+ // 단일 링크 메뉴 (최상위 menu)
+ if (node.nodeType === 'menu' && node.menuPath) {
+ return (
+ <NavigationMenuItem key={node.id}>
+ <Link
+ href={`/${lng}${node.menuPath}`}
+ legacyBehavior
+ passHref
+ >
+ <NavigationMenuLink
+ className={cn(
+ navigationMenuTriggerStyle(),
+ "px-2 xl:px-3 text-sm whitespace-nowrap"
+ )}
+ onPointerEnter={(e) => e.preventDefault()}
+ onPointerLeave={(e) => e.preventDefault()}
+ >
+ {getTitle(node)}
+ </NavigationMenuLink>
+ </Link>
+ </NavigationMenuItem>
+ );
+ }
+
+ return null;
+ })}
+ </NavigationMenuList>
+ </div>
+ </NavigationMenu>
+ )}
+ </div>
+
+ {/* 우측 영역 */}
+ <div className="ml-auto flex flex-shrink-0 items-center space-x-2">
+ {/* 데스크탑에서는 CommandMenu, 모바일에서는 검색 아이콘만 */}
+ <div className="hidden md:block md:w-auto">
+ <CommandMenu />
+ </div>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="md:hidden"
+ aria-label={t("common.search")}
+ >
+ <SearchIcon className="h-5 w-5" />
+ </Button>
+
+ {/* 알림 버튼 */}
+ <NotificationDropdown />
+
+ {/* 사용자 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Avatar className="cursor-pointer h-8 w-8">
+ <AvatarImage
+ src={`${session?.user?.image}` || "/user-avatar.jpg"}
+ alt="User Avatar"
+ />
+ <AvatarFallback>{initials || "?"}</AvatarFallback>
+ </Avatar>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-48" align="end">
+ <DropdownMenuLabel>{t("user.my_account")}</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem asChild>
+ <Link href={`${basePath}/settings`}>{t("user.settings")}</Link>
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() =>
+ customSignOut({
+ callbackUrl: `${window.location.origin}${basePath}`,
+ })
+ }
+ >
+ {t("user.logout")}
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+ </div>
+
+ {/* 모바일 메뉴 */}
+ {isMobileMenuOpen && (
+ <MobileMenuV2
+ lng={lng}
+ onClose={toggleMobileMenu}
+ tree={tree}
+ getTitle={getTitle}
+ getDescription={getDescription}
+ />
+ )}
+ </header>
+ </>
+ );
+}