Files
slot/next-app/apps/web/src/components/Chrome/Header.tsx
T
chpark ca965fec90 Wire menu URL rewrites + add /games hub + end-to-end verification (85/85)
## Menu rewrites against the production /bbs/*.php URLs
The g5_eyoom_menu rows store legacy PHP URLs verbatim (/bbs/tv.php,
/bbs/point_guide.php, /bbs/exchange-amount.php, /bbs/Bighandbro.php,
/bbs/event_exchange.php, /bbs/slotlife.php, /shop/list.php,
/shop/orderinquiry.php, /roulette/?idx=1, /plugin/swiunApi/game.php?gt=mix
…). Added rewriters that map every one of those to the new app routes so
clicking any menu item lands on a real page.

## /games hub page
The 포인트게임 top-level link used to 404 (it pointed to /games which
didn't exist as a page). Built a proper hub: gradient hero, 15-tile
slot simulator grid (each with a unique color gradient + emoji), and
two callout cards for sports/mini-game branches.

## /tv/bighand
큰손형 방송 dedicated page with KICK CTA + schedule.

## Header polish
- whitespace-nowrap on every menu label so 보증사이트 / 먹튀사이트 /
  슬생TV no longer wrap at narrow widths.
- MegaPanel renderer that turns 3rd-level eyoom menu groups (스포츠 /
  미니게임 / 슬롯·릴) into multi-column section blocks instead of
  collapsing to an empty dropdown.
- Replaced 구매내역 link with /mypage so utility-bar 404 disappears.

## verify-everything.mjs
New end-to-end script that:
- Crawls every menu link (64 unique URLs) and asserts non-404.
- Real interaction tests: testlogin login → board list → post view →
  comment POST → recommend POST → admin login → admin dashboard render
  check → admin theme picker visible (4 themes) → theme switch POST →
  logout POST → attendance check POST.

Result: PASS 85 / FAIL 0 / TOTAL 85. Report at next-app/verify-out/.
2026-04-27 20:54:51 +09:00

229 lines
11 KiB
TypeScript

'use client';
import Link from 'next/link';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Bookmark, UserPlus, ShoppingBag, Plus, LogIn, MessageCircle, Mail, Moon, Menu as MenuIcon, ChevronDown, Search,
} from 'lucide-react';
import type { MenuItem, SiteUser } from '@slot/themes';
export interface HeaderProps {
user: SiteUser | null;
menus: MenuItem[];
isDark: boolean;
}
export default function Header({ user, menus, isDark }: HeaderProps) {
return (
<header className="sticky top-0 z-30 bg-white/85 backdrop-blur-md shadow-[0_1px_0_rgba(0,0,0,0.04)]">
<UtilityBar user={user} />
<BrandRow user={user} />
<MegaNav menus={menus} isDark={isDark} />
</header>
);
}
function UtilityBar({ user }: { user: SiteUser | null }) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-neutral-100 text-[12px]">
<div className="mx-auto max-w-[1280px] flex items-center justify-between px-6 py-2">
<Link href="/bookmarks" className="inline-flex items-center gap-1 text-neutral-text-soft hover:text-brand-600">
<Bookmark size={13} />
</Link>
<div className="flex items-center gap-5 text-neutral-text-soft">
{user ? (
<Link href="/mypage" className="hover:text-brand-600">👤 {user.nick}</Link>
) : (
<Link href="/register" className="inline-flex items-center gap-1 hover:text-brand-600"><UserPlus size={13} /> </Link>
)}
{user ? null : (
<Link href="/login" className="inline-flex items-center gap-1 hover:text-brand-600"><LogIn size={13} /> </Link>
)}
<Link href="/mypage" className="inline-flex items-center gap-1 hover:text-brand-600"><ShoppingBag size={13} /> </Link>
<div className="relative" onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
<button className="inline-flex items-center gap-1 hover:text-brand-600">
<Plus size={13} /> <ChevronDown size={12} />
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}
className="absolute right-0 top-full mt-2 w-44 rounded-xl border border-neutral-100 bg-white p-1.5 shadow-[0_12px_32px_rgba(124,82,224,0.18)]"
>
<DropLink href="/new"></DropLink>
<DropLink href="/help/faq"> </DropLink>
<DropLink href="/help/qa">1:1문의</DropLink>
{user && <DropLink href="/mypage/profile"></DropLink>}
{user && (
<form action="/api/auth/logout" method="POST" className="m-0">
<button type="submit" className="block w-full rounded-lg px-3 py-2 text-left text-[13px] text-rose-600 hover:bg-rose-50"></button>
</form>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
);
}
function DropLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<Link href={href} className="block rounded-lg px-3 py-2 text-[13px] text-neutral-700 hover:bg-brand-50 hover:text-brand-700">
{children}
</Link>
);
}
function BrandRow({ user }: { user: SiteUser | null }) {
return (
<div className="bg-gradient-to-b from-white to-[#fbfaff]">
<div className="mx-auto flex max-w-[1280px] items-center justify-between gap-4 px-6 py-4">
<Link href="/" className="flex items-center gap-2 no-underline">
<span className="rounded-xl bg-gradient-to-br from-[#7c52e0] to-[#b794f4] px-4 py-2.5 text-2xl font-extrabold leading-none text-white shadow-[0_4px_14px_rgba(124,82,224,0.35)]"> </span>
<span className="rounded-md bg-amber-300 px-2 py-1 text-[11px] font-extrabold tracking-wide text-amber-900"></span>
</Link>
<form className="hidden flex-1 max-w-[420px] md:flex" action="/free/search" method="GET">
<div className="flex w-full items-center gap-2 rounded-full border border-brand-100 bg-white pl-4 pr-1.5 py-1.5 shadow-sm focus-within:border-brand-400 focus-within:shadow-[0_0_0_4px_rgba(167,139,250,0.18)]">
<Search size={16} className="text-neutral-400" />
<input name="stx" placeholder="게시판 검색…" className="flex-1 bg-transparent text-[13px] outline-none placeholder:text-neutral-400" />
<button className="rounded-full bg-brand-600 px-4 py-1.5 text-[12px] font-semibold text-white hover:bg-brand-700"></button>
</div>
</form>
<div className="flex items-center gap-5">
{user ? (
<>
<IconLink href="/mypage" emoji="👤" label={user.nick} />
<IconLink href="/mypage/respond" Icon={MessageCircle} label="내글반응" badge={user.respondCount} />
<IconLink href="/memo" Icon={Mail} label="쪽지" badge={user.memoCount} />
</>
) : (
<>
<IconLink href="/login" Icon={LogIn} label="로그인" />
<IconLink href="/login" Icon={MessageCircle} label="내글반응" badge={0} />
<IconLink href="/login" Icon={Mail} label="쪽지" badge={0} />
</>
)}
</div>
</div>
</div>
);
}
function IconLink({ href, Icon, emoji, label, badge }: { href: string; Icon?: React.ComponentType<{ size?: number; className?: string }>; emoji?: string; label: string; badge?: number }) {
return (
<Link href={href} className="group relative flex min-w-[56px] flex-col items-center gap-1 text-[11px] text-neutral-text-soft transition hover:text-brand-700">
<span className="relative grid h-9 w-9 place-items-center rounded-full bg-brand-50 group-hover:bg-brand-100">
{emoji ? <span className="text-base">{emoji}</span> : Icon ? <Icon size={18} /> : null}
{typeof badge === 'number' && (
<span className="absolute -top-1 -right-1 grid h-4 min-w-4 place-items-center rounded-full bg-rose-500 px-1 text-[10px] font-bold text-white">{badge}</span>
)}
</span>
<span className="whitespace-nowrap">{label}</span>
</Link>
);
}
/** Group submenu items: items with children render as section headers with their kids inline below. */
function MegaPanel({ items }: { items: MenuItem[] }) {
// Split into groups: items with children become a "section" with their kids underneath.
// Items without children stay as plain links.
const sections: { title?: string; href?: string; links: MenuItem[] }[] = [];
let currentLooseLinks: MenuItem[] = [];
for (const it of items) {
if (it.children && it.children.length > 0) {
if (currentLooseLinks.length > 0) {
sections.push({ links: currentLooseLinks });
currentLooseLinks = [];
}
sections.push({ title: it.label, href: it.href, links: it.children });
} else {
currentLooseLinks.push(it);
}
}
if (currentLooseLinks.length > 0) sections.push({ links: currentLooseLinks });
// Decide layout: if there are 3+ sections OR any section has many links, use multi-column.
const total = sections.reduce((n, s) => n + s.links.length + (s.title ? 1 : 0), 0);
const multiCol = sections.some((s) => s.links.length >= 6) || total >= 14;
return (
<div className={`grid gap-x-4 gap-y-3 ${multiCol ? 'grid-cols-3' : 'grid-cols-1'}`}>
{sections.map((s, si) => (
<div key={si} className="min-w-[160px]">
{s.title && (
<div className="mb-1 border-b border-brand-100 pb-1 text-[11px] font-bold uppercase tracking-widest text-brand-500">
{s.href && s.href !== '#' ? <Link href={s.href} className="hover:text-brand-700">{s.title}</Link> : s.title}
</div>
)}
<ul className="m-0 grid gap-0.5 p-0 list-none">
{s.links.map((c, ci) => (
<li key={ci}>
<Link
href={c.href || '#'}
className="block rounded-lg px-2.5 py-1.5 text-[13px] text-neutral-700 hover:bg-brand-50 hover:text-brand-700 whitespace-nowrap"
>
{c.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
);
}
function MegaNav({ menus, isDark: _isDark }: { menus: MenuItem[]; isDark: boolean }) {
const [open, setOpen] = useState<number | null>(null);
return (
<nav className="bg-mega text-white shadow-[0_4px_14px_rgba(108,76,209,0.35)]">
<div className="relative mx-auto flex max-w-[1280px] flex-nowrap items-center gap-1 px-6">
{menus.map((m, i) => {
const hasChildren = m.children && m.children.length > 0;
return (
<div
key={m.label + i}
className="relative"
onMouseEnter={() => setOpen(i)}
onMouseLeave={() => setOpen((v) => (v === i ? null : v))}
>
<Link
href={m.href || '#'}
className="flex items-center gap-1.5 whitespace-nowrap px-3 py-3.5 text-[14px] font-semibold tracking-tight hover:bg-white/10"
>
{m.icon && <span aria-hidden>{m.icon}</span>}
<span>{m.label}</span>
{hasChildren && <ChevronDown size={14} className="opacity-80" />}
</Link>
<AnimatePresence>
{open === i && hasChildren && (
<motion.div
initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 6 }} transition={{ duration: 0.16 }}
className="absolute left-0 top-full z-40 mt-0 rounded-2xl border border-brand-100 bg-white p-3 shadow-[0_18px_38px_rgba(60,30,120,0.22)]"
style={{ minWidth: 240 }}
>
<MegaPanel items={m.children!} />
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
<span className="ml-auto" />
<button title="모든 메뉴" className="grid h-10 w-10 place-items-center rounded-full hover:bg-white/10"><MenuIcon size={18} /></button>
<form action="/api/ui/dark-mode" method="POST" className="m-0">
<button title="다크모드" className="grid h-9 w-9 place-items-center rounded-full border border-white/30 hover:bg-white/10">
<Moon size={15} />
</button>
</form>
</div>
</nav>
);
}