summaryrefslogtreecommitdiff
path: root/components/layout
diff options
context:
space:
mode:
Diffstat (limited to 'components/layout')
-rw-r--r--components/layout/DynamicMenuRender.tsx146
-rw-r--r--components/layout/HeaderV2.tsx295
-rw-r--r--components/layout/MobileMenuV2.tsx160
3 files changed, 601 insertions, 0 deletions
diff --git a/components/layout/DynamicMenuRender.tsx b/components/layout/DynamicMenuRender.tsx
new file mode 100644
index 00000000..f94223ae
--- /dev/null
+++ b/components/layout/DynamicMenuRender.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import Link from "next/link";
+import { cn } from "@/lib/utils";
+import { NavigationMenuLink } from "@/components/ui/navigation-menu";
+import type { MenuTreeNode } from "@/lib/menu-v2/types";
+
+interface DynamicMenuRenderProps {
+ groups: MenuTreeNode[] | undefined;
+ lng: string;
+ getTitle: (node: MenuTreeNode) => string;
+ getDescription: (node: MenuTreeNode) => string | null;
+ onItemClick?: () => void;
+}
+
+export default function DynamicMenuRender({
+ groups,
+ lng,
+ getTitle,
+ getDescription,
+ onItemClick,
+}: DynamicMenuRenderProps) {
+ if (!groups || groups.length === 0) {
+ return (
+ <div className="p-4 text-sm text-muted-foreground">
+ 메뉴가 없습니다.
+ </div>
+ );
+ }
+
+ // 그룹별로 메뉴 분류
+ const groupedMenus = new Map<string, MenuTreeNode[]>();
+ const ungroupedMenus: MenuTreeNode[] = [];
+
+ for (const item of groups) {
+ if (item.nodeType === "group") {
+ // 그룹인 경우, 그룹의 children을 해당 그룹에 추가
+ const groupTitle = getTitle(item);
+ if (!groupedMenus.has(groupTitle)) {
+ groupedMenus.set(groupTitle, []);
+ }
+ if (item.children) {
+ groupedMenus.get(groupTitle)!.push(...item.children);
+ }
+ } else if (item.nodeType === "menu") {
+ // 직접 메뉴인 경우 (그룹 없이 직접 메뉴그룹에 속한 경우)
+ ungroupedMenus.push(item);
+ }
+ }
+
+ // 그룹이 없고 메뉴만 있는 경우 - 단순 그리드 렌더링
+ if (groupedMenus.size === 0 && ungroupedMenus.length > 0) {
+ return (
+ <ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px]">
+ {ungroupedMenus.map((menu) => (
+ <MenuListItem
+ key={menu.id}
+ href={`/${lng}${menu.menuPath}`}
+ title={getTitle(menu)}
+ onClick={onItemClick}
+ >
+ {getDescription(menu)}
+ </MenuListItem>
+ ))}
+ </ul>
+ );
+ }
+
+ // 그룹별 렌더링 - 가로 스크롤 지원
+ // 컨텐츠가 85vw를 초과할 때만 스크롤 발생
+ return (
+ <div className="p-4 max-w-[85vw]">
+ <div className="flex gap-6 overflow-x-auto">
+ {/* 그룹화되지 않은 메뉴 (있는 경우) */}
+ {ungroupedMenus.length > 0 && (
+ <div className="w-[200px] flex-shrink-0">
+ <ul className="space-y-2">
+ {ungroupedMenus.map((menu) => (
+ <MenuListItem
+ key={menu.id}
+ href={`/${lng}${menu.menuPath}`}
+ title={getTitle(menu)}
+ onClick={onItemClick}
+ >
+ {getDescription(menu)}
+ </MenuListItem>
+ ))}
+ </ul>
+ </div>
+ )}
+
+ {/* 그룹별 메뉴 - 순서대로 가로 배치 */}
+ {Array.from(groupedMenus.entries()).map(([groupTitle, menus]) => (
+ <div key={groupTitle} className="w-[200px] flex-shrink-0">
+ <h4 className="mb-2 text-sm font-semibold text-muted-foreground whitespace-nowrap">
+ {groupTitle}
+ </h4>
+ <ul className="space-y-2">
+ {menus.map((menu) => (
+ <MenuListItem
+ key={menu.id}
+ href={`/${lng}${menu.menuPath}`}
+ title={getTitle(menu)}
+ onClick={onItemClick}
+ >
+ {getDescription(menu)}
+ </MenuListItem>
+ ))}
+ </ul>
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
+
+interface MenuListItemProps {
+ href: string;
+ title: string;
+ children?: React.ReactNode;
+ onClick?: () => void;
+}
+
+function MenuListItem({ href, title, children, onClick }: MenuListItemProps) {
+ return (
+ <li>
+ <NavigationMenuLink asChild>
+ <Link
+ href={href}
+ onClick={onClick}
+ className={cn(
+ "block select-none space-y-1 rounded-md p-2 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
+ )}
+ >
+ <div className="text-sm font-medium leading-none">{title}</div>
+ {children && (
+ <p className="line-clamp-2 text-xs leading-snug text-muted-foreground">
+ {children}
+ </p>
+ )}
+ </Link>
+ </NavigationMenuLink>
+ </li>
+ );
+}
+
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>
+ </>
+ );
+}
diff --git a/components/layout/MobileMenuV2.tsx b/components/layout/MobileMenuV2.tsx
new file mode 100644
index 00000000..c83ba779
--- /dev/null
+++ b/components/layout/MobileMenuV2.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import * as React from "react";
+import Link from "next/link";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { X, ChevronDown, ChevronRight } from "lucide-react";
+import type { MenuTreeNode } from "@/lib/menu-v2/types";
+
+interface MobileMenuV2Props {
+ lng: string;
+ onClose: () => void;
+ tree: MenuTreeNode[];
+ getTitle: (node: MenuTreeNode) => string;
+ getDescription: (node: MenuTreeNode) => string | null;
+}
+
+export function MobileMenuV2({
+ lng,
+ onClose,
+ tree,
+ getTitle,
+ getDescription,
+}: MobileMenuV2Props) {
+ const [expandedGroups, setExpandedGroups] = React.useState<Set<number>>(
+ new Set()
+ );
+
+ const toggleGroup = (groupId: number) => {
+ setExpandedGroups((prev) => {
+ const next = new Set(prev);
+ if (next.has(groupId)) {
+ next.delete(groupId);
+ } else {
+ next.add(groupId);
+ }
+ return next;
+ });
+ };
+
+ // 드롭다운 메뉴인지 판단
+ const isDropdownMenu = (node: MenuTreeNode) =>
+ node.nodeType === 'menu_group' && node.children && node.children.length > 0;
+
+ return (
+ <div className="fixed inset-0 z-50 bg-background md:hidden">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between px-4 h-14 border-b">
+ <span className="font-semibold">메뉴</span>
+ <Button variant="ghost" size="icon" onClick={onClose}>
+ <X className="h-5 w-5" />
+ <span className="sr-only">닫기</span>
+ </Button>
+ </div>
+
+ {/* 스크롤 영역 */}
+ <ScrollArea className="h-[calc(100vh-56px)]">
+ <div className="px-4 py-4 space-y-2">
+ {tree.map((node) => {
+ // 드롭다운 메뉴 (menu_group with children)
+ if (isDropdownMenu(node)) {
+ return (
+ <div key={node.id} className="space-y-2">
+ {/* 메뉴그룹 헤더 */}
+ <button
+ onClick={() => toggleGroup(node.id)}
+ className="flex items-center justify-between w-full py-2 text-left font-semibold"
+ >
+ <span>{getTitle(node)}</span>
+ {expandedGroups.has(node.id) ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ </button>
+
+ {/* 하위 메뉴 */}
+ {expandedGroups.has(node.id) && (
+ <div className="pl-4 space-y-1">
+ {node.children?.map((item) => {
+ if (item.nodeType === "group") {
+ // 그룹인 경우
+ return (
+ <div key={item.id} className="space-y-1">
+ <div className="text-xs text-muted-foreground font-medium py-1">
+ {getTitle(item)}
+ </div>
+ <div className="pl-2 space-y-1">
+ {item.children?.map((menu) => (
+ <MobileMenuLink
+ key={menu.id}
+ href={`/${lng}${menu.menuPath}`}
+ title={getTitle(menu)}
+ onClick={onClose}
+ />
+ ))}
+ </div>
+ </div>
+ );
+ } else if (item.nodeType === "menu") {
+ // 직접 메뉴인 경우
+ return (
+ <MobileMenuLink
+ key={item.id}
+ href={`/${lng}${item.menuPath}`}
+ title={getTitle(item)}
+ onClick={onClose}
+ />
+ );
+ }
+ return null;
+ })}
+ </div>
+ )}
+ </div>
+ );
+ }
+
+ // 단일 링크 메뉴 (최상위 menu)
+ if (node.nodeType === 'menu' && node.menuPath) {
+ return (
+ <MobileMenuLink
+ key={node.id}
+ href={`/${lng}${node.menuPath}`}
+ title={getTitle(node)}
+ onClick={onClose}
+ />
+ );
+ }
+
+ return null;
+ })}
+ </div>
+ </ScrollArea>
+ </div>
+ );
+}
+
+interface MobileMenuLinkProps {
+ href: string;
+ title: string;
+ onClick: () => void;
+}
+
+function MobileMenuLink({ href, title, onClick }: MobileMenuLinkProps) {
+ return (
+ <Link
+ href={href}
+ onClick={onClick}
+ className={cn(
+ "block py-2 px-2 rounded-md text-sm",
+ "hover:bg-accent hover:text-accent-foreground",
+ "transition-colors"
+ )}
+ >
+ {title}
+ </Link>
+ );
+}