From 04ed774ff60a83c00711d4e8615cb4122954dba5 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Thu, 4 Dec 2025 19:46:55 +0900
Subject: (김준회) 메뉴 관리기능 초안 개발 (시딩 필요)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 components/layout/HeaderV2.tsx | 295 +++++++++++++++++++++++++++++++++++++++++
 1 file changed, 295 insertions(+)
 create mode 100644 components/layout/HeaderV2.tsx

(limited to 'components/layout/HeaderV2.tsx')

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>
+    </>
+  );
+}
-- 
cgit v1.2.3

