feat(web): home calendar holiday markers + unify public header into single frame

- HomePage: wire fetchHolidaySet, is-holiday cell class + holiday name label
- home.css: is-holiday red daynum, kx-home__cal-holiday-name, neutral-100 grid lines
- PublicShell: absorb 3-track switcher into single top header (COEX 1-frame), drop dark topbar
- public.css: remove .kxp-topbar styles, tracktabs bar/menu variants

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-12 23:56:59 +09:00
parent a5a78f1ea1
commit c77cba7c5b
4 changed files with 133 additions and 148 deletions

View File

@ -35,6 +35,7 @@ import {
type CalEvent, type CalEvent,
type CalStatus, type CalStatus,
} from './homeCalendar'; } from './homeCalendar';
import { fetchHolidaySet } from '../admin/holidayApi';
import { HomeCharts } from './HomeCharts'; import { HomeCharts } from './HomeCharts';
import './home.css'; import './home.css';
@ -634,6 +635,14 @@ function HomeCalendar() {
queryKey: ['home-hall-centers'], queryKey: ['home-hall-centers'],
queryFn: () => homeApi.hallCenters(), queryFn: () => homeApi.hallCenters(),
}); });
// 공휴일 세트(붉은 날짜 + 공휴일명) — 커서 연도±1 창. 실패해도 달력 정상(빈 세트).
const holidayQ = useQuery({
queryKey: ['home-cal-holidays', cursor.getFullYear()],
queryFn: () =>
fetchHolidaySet(`${cursor.getFullYear() - 1}-01-01`, `${cursor.getFullYear() + 1}-12-31`),
retry: false,
});
const holidays = holidayQ.data ?? new Map<string, string>();
const allEvents = eventsQuery.data ?? []; const allEvents = eventsQuery.data ?? [];
const filtered = useMemo( const filtered = useMemo(
@ -750,12 +759,17 @@ function HomeCalendar() {
cell.inMonth ? '' : 'is-out', cell.inMonth ? '' : 'is-out',
cell.isToday ? 'is-today' : '', cell.isToday ? 'is-today' : '',
cell.dow === 0 ? 'is-sun' : cell.dow === 6 ? 'is-sat' : '', cell.dow === 0 ? 'is-sun' : cell.dow === 6 ? 'is-sat' : '',
holidays.get(cell.key) ? 'is-holiday' : '',
] ]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
onClick={() => setPopupDay(cell.key)} onClick={() => setPopupDay(cell.key)}
title={holidays.get(cell.key) || undefined}
> >
<span className="kx-home__cal-daynum tnum">{cell.day}</span> <span className="kx-home__cal-daynum tnum">{cell.day}</span>
{holidays.get(cell.key) && (
<span className="kx-home__cal-holiday-name">{holidays.get(cell.key)}</span>
)}
{wk.hiddenByCol[cell.dow] > 0 && ( {wk.hiddenByCol[cell.dow] > 0 && (
<span className="kx-home__cal-more tnum"> <span className="kx-home__cal-more tnum">
{t('home.calendar.more', { n: wk.hiddenByCol[cell.dow] })} {t('home.calendar.more', { n: wk.hiddenByCol[cell.dow] })}

View File

@ -481,7 +481,7 @@
} }
.kx-home__cal-week { .kx-home__cal-week {
position: relative; position: relative;
border-bottom: var(--border-card); border-bottom: 1px solid var(--color-neutral-100);
} }
.kx-home__cal-week:last-child { .kx-home__cal-week:last-child {
border-bottom: none; border-bottom: none;
@ -492,7 +492,7 @@
} }
.kx-home__cal-cell { .kx-home__cal-cell {
min-height: 84px; min-height: 84px;
border-right: var(--border-card); border-right: 1px solid var(--color-neutral-100);
padding: 4px 6px; padding: 4px 6px;
text-align: left; text-align: left;
background: var(--color-white); background: var(--color-white);
@ -523,6 +523,20 @@
.kx-home__cal-cell.is-sat .kx-home__cal-daynum { .kx-home__cal-cell.is-sat .kx-home__cal-daynum {
color: var(--color-primary-600); color: var(--color-primary-600);
} }
.kx-home__cal-cell.is-holiday .kx-home__cal-daynum {
color: var(--color-error);
font-weight: var(--fw-bold);
}
.kx-home__cal-holiday-name {
font-size: var(--fs-nano);
font-weight: var(--fw-semibold);
color: var(--color-error);
line-height: 1.1;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kx-home__cal-cell.is-today .kx-home__cal-daynum { .kx-home__cal-cell.is-today .kx-home__cal-daynum {
background: var(--color-primary-600); background: var(--color-primary-600);
color: #fff; color: #fff;

View File

@ -1,6 +1,7 @@
/* /*
* GNB + . (AppShell , ) * (1 ) + . (AppShell , )
* design.md §3B: 전통 + SEO· (react-i18next LanguageSwitcher). * COEX (docs/analysis/coex-website.md): + .
* design.md §3B/§3C-0: 3- (role=tablist· )·GNB· .
*/ */
import { useRef, useState, type KeyboardEvent, type ReactNode } from 'react'; import { useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -28,22 +29,20 @@ const TRACK_TABS: { key: PublicTrack; to: string; i18n: string }[] = [
]; ];
/** /**
* 3- (§3C-0). role=tablist· ·/Home/End . * 3- (§3C-0) . role=tablist· ·/Home/End .
* (SCR-T0) track (), (T1~T3) (aria-current). * (SCR-T0) track (), (T1~T3) (aria-current).
*/ */
function TrackSwitcher({ track }: { track?: PublicTrack }) { function TrackTabs({ track, variant }: { track?: PublicTrack; variant: 'bar' | 'menu' }) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const refs = useRef<(HTMLAnchorElement | null)[]>([]); const refs = useRef<(HTMLAnchorElement | null)[]>([]);
// 로빙 탭인덱스 기준 인덱스(활성 탭·없으면 첫 탭).
const activeIdx = TRACK_TABS.findIndex((it) => it.key === track); const activeIdx = TRACK_TABS.findIndex((it) => it.key === track);
const baseIdx = activeIdx >= 0 ? activeIdx : 0; const baseIdx = activeIdx >= 0 ? activeIdx : 0;
function onKeyDown(e: KeyboardEvent<HTMLAnchorElement>, i: number) { function onKeyDown(e: KeyboardEvent<HTMLAnchorElement>, i: number) {
let next = i; let next = i;
if (e.key === 'ArrowRight') next = (i + 1) % TRACK_TABS.length; if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (i + 1) % TRACK_TABS.length;
else if (e.key === 'ArrowLeft') next = (i - 1 + TRACK_TABS.length) % TRACK_TABS.length; else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
next = (i - 1 + TRACK_TABS.length) % TRACK_TABS.length;
else if (e.key === 'Home') next = 0; else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = TRACK_TABS.length - 1; else if (e.key === 'End') next = TRACK_TABS.length - 1;
else return; else return;
@ -52,13 +51,11 @@ function TrackSwitcher({ track }: { track?: PublicTrack }) {
} }
return ( return (
<div className="kxp-topbar"> <div
<div className="kxp-topbar__inner"> className={variant === 'bar' ? 'kxp-tracktabs' : 'kxp-tracktabs kxp-tracktabs--menu'}
<Link className="kxp-wordmark" to="/" aria-label={t('track.shell.wordmark')}> role="tablist"
{t('track.shell.wordmark')} aria-label={t('track.shell.tablistAria')}
</Link> >
<div className="kxp-tracktabs" role="tablist" aria-label={t('track.shell.tablistAria')}>
{TRACK_TABS.map((it, i) => { {TRACK_TABS.map((it, i) => {
const isActive = it.key === track; const isActive = it.key === track;
return ( return (
@ -80,25 +77,6 @@ function TrackSwitcher({ track }: { track?: PublicTrack }) {
); );
})} })}
</div> </div>
<div className="kxp-topbar__utils">
<LanguageSwitcher variant="light" className="kxp-lang__btn" />
{isAuthenticated ? (
<button
type="button"
className="kxp-topbar__link"
onClick={() => navigate('/home')}
>
{t('track.shell.myHome')}
</button>
) : (
<Link className="kxp-topbar__link" to="/login">
{t('track.shell.login')}
</Link>
)}
</div>
</div>
</div>
); );
} }
@ -125,6 +103,8 @@ export function PublicShell({
brand = 'KINTEX', brand = 'KINTEX',
}: PublicShellProps) { }: PublicShellProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const ctaLabel = cta ?? t('shell.cta.register'); const ctaLabel = cta ?? t('shell.cta.register');
// §3C-V 비주얼·모션 레이어 — 공개 트랙(.kxp) 하위에만 스코프 주입. // §3C-V 비주얼·모션 레이어 — 공개 트랙(.kxp) 하위에만 스코프 주입.
@ -133,13 +113,17 @@ export function PublicShell({
return ( return (
<div className="kxp" ref={rootRef}> <div className="kxp" ref={rootRef}>
<TrackSwitcher track={track} /> {/* ── 단일 상단 헤더 (1 프레임 · COEX 통합 top bar) ── */}
<header className="kxp-header"> <header className="kxp-header">
<div className="kxp-header__inner"> <div className="kxp-header__inner">
<div className="kxp-header__left"> <div className="kxp-header__left">
<a className="kxp-brand" href="#top" aria-label={t('shell.homeAria', { brand })}> <Link className="kxp-brand" to="/" aria-label={t('shell.homeAria', { brand })}>
{brand} {brand}
</a> </Link>
<TrackTabs track={track} variant="bar" />
</div>
<div className="kxp-header__right">
<nav className="kxp-gnb" aria-label={t('shell.primaryMenu')}> <nav className="kxp-gnb" aria-label={t('shell.primaryMenu')}>
{GNB.map((it) => ( {GNB.map((it) => (
<a <a
@ -152,9 +136,7 @@ export function PublicShell({
</a> </a>
))} ))}
</nav> </nav>
</div>
<div className="kxp-header__right">
{search && ( {search && (
<div className="kxp-search" role="search"> <div className="kxp-search" role="search">
<IconSearch className="kxp-search__icon" /> <IconSearch className="kxp-search__icon" />
@ -169,9 +151,19 @@ export function PublicShell({
<LanguageSwitcher variant="light" className="kxp-lang__btn" /> <LanguageSwitcher variant="light" className="kxp-lang__btn" />
{isAuthenticated ? (
<button
type="button"
className="kxp-btn kxp-btn--outline kxp-header__login"
onClick={() => navigate('/home')}
>
{t('track.shell.myHome')}
</button>
) : (
<Link className="kxp-btn kxp-btn--outline kxp-header__login" to="/login"> <Link className="kxp-btn kxp-btn--outline kxp-header__login" to="/login">
{t('shell.login')} {t('shell.login')}
</Link> </Link>
)}
<a className="kxp-btn kxp-btn--primary kxp-header__cta" href="#top"> <a className="kxp-btn kxp-btn--primary kxp-header__cta" href="#top">
{ctaLabel} {ctaLabel}
@ -191,14 +183,25 @@ export function PublicShell({
{menuOpen && ( {menuOpen && (
<nav className="kxp-mobilenav" aria-label={t('shell.mobileMenu')}> <nav className="kxp-mobilenav" aria-label={t('shell.mobileMenu')}>
<TrackTabs track={track} variant="menu" />
{GNB.map((it) => ( {GNB.map((it) => (
<a key={it.key} href="#top" className="kxp-mobilenav__link"> <a key={it.key} href="#top" className="kxp-mobilenav__link">
{t(it.i18n)} {t(it.i18n)}
</a> </a>
))} ))}
{isAuthenticated ? (
<button
type="button"
className="kxp-btn kxp-btn--outline kxp-mobilenav__cta"
onClick={() => navigate('/home')}
>
{t('track.shell.myHome')}
</button>
) : (
<Link className="kxp-btn kxp-btn--outline kxp-mobilenav__cta" to="/login"> <Link className="kxp-btn kxp-btn--outline kxp-mobilenav__cta" to="/login">
{t('shell.login')} {t('shell.login')}
</Link> </Link>
)}
<a className="kxp-btn kxp-btn--primary kxp-mobilenav__cta" href="#top"> <a className="kxp-btn kxp-btn--primary kxp-mobilenav__cta" href="#top">
{ctaLabel} {ctaLabel}
</a> </a>

View File

@ -2805,112 +2805,66 @@
* 신규 토큰 0 (기존 color·radius·kxp-maxw 변수 재사용). WCAG AA. * 신규 토큰 0 (기존 color·radius·kxp-maxw 변수 재사용). WCAG AA.
* =================================================================== */ * =================================================================== */
/* -- 상단 3-트랙 스위처 바 (전 SCR-T·SCR-P 공통 크롬) -- */ /* -- 3-트랙 (단일 헤더 내부 흡수 · COEX 통합 top bar) --
.kxp-topbar { * 상단 프레임은 1개(.kxp-header). 별도 topbar 제거(이중 프레임 해소).
position: sticky; * 라이트 헤더 배경 스타일(하단 보더 활성). 모바일은 헤더에서 숨기고 버거 메뉴로. */
top: 0;
z-index: 60;
background: var(--color-primary-700, #004c86);
color: #fff;
}
.kxp-topbar__inner {
max-width: var(--kxp-maxw);
margin: 0 auto;
min-height: 44px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 16px;
}
.kxp-wordmark {
font-size: 14px;
font-weight: 800;
letter-spacing: -0.01em;
color: #fff;
white-space: nowrap;
flex-shrink: 0;
}
.kxp-wordmark:hover {
color: #fff;
opacity: 0.9;
}
.kxp-tracktabs { .kxp-tracktabs {
display: flex; display: none;
align-items: stretch; align-items: stretch;
gap: 4px; gap: 2px;
flex: 1;
min-width: 0; min-width: 0;
} }
.kxp-tracktab { .kxp-tracktab {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 12px 14px; padding: 8px 12px;
font-size: 14px; font-size: var(--fs-body);
font-weight: 600; font-weight: 600;
color: rgba(255, 255, 255, 0.82); color: var(--color-neutral-600);
border-bottom: 3px solid transparent; border-bottom: 2px solid transparent;
white-space: nowrap; white-space: nowrap;
transition: color 0.15s, border-color 0.15s; transition: color 0.15s, border-color 0.15s;
} }
.kxp-tracktab:hover { .kxp-tracktab:hover {
color: #fff; color: var(--color-primary-600);
} }
.kxp-tracktab.is-active { .kxp-tracktab.is-active {
color: #fff; color: var(--color-primary-700);
font-weight: 800; font-weight: 800;
border-bottom-color: #fff; border-bottom-color: var(--color-primary-600);
} }
.kxp-tracktab:focus-visible { .kxp-tracktab:focus-visible {
outline: 2px solid #fff; outline: 2px solid var(--color-primary-600);
outline-offset: -2px; outline-offset: -2px;
border-radius: 2px; border-radius: 2px;
} }
.kxp-topbar__utils {
/* 모바일 버거 메뉴 내부 트랙 탭 (세로 스택) */
.kxp-tracktabs--menu {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 14px; align-items: stretch;
flex-shrink: 0; gap: 4px;
color: #fff; margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--color-neutral-200);
} }
.kxp-topbar__utils .kxp-lang__btn { .kxp-tracktabs--menu .kxp-tracktab {
color: #fff; padding: 12px 8px;
font-size: var(--fs-h3);
color: var(--color-neutral-900);
border-bottom: none;
border-left: 3px solid transparent;
border-radius: var(--radius-sm);
} }
.kxp-topbar__link { .kxp-tracktabs--menu .kxp-tracktab.is-active {
background: none; border-left-color: var(--color-primary-600);
border: none; background: var(--color-primary-050);
padding: 4px 2px;
font: inherit;
font-size: 13px;
font-weight: 700;
color: #fff;
cursor: pointer;
white-space: nowrap;
}
.kxp-topbar__link:hover {
text-decoration: underline;
} }
@media (max-width: 640px) { @media (min-width: 768px) {
.kxp-topbar__inner {
flex-wrap: wrap;
padding: 6px 12px;
gap: 8px;
}
.kxp-wordmark {
order: 1;
}
.kxp-topbar__utils {
order: 2;
margin-left: auto;
}
.kxp-tracktabs { .kxp-tracktabs {
order: 3; display: flex;
flex-basis: 100%;
}
.kxp-tracktab {
flex: 1;
justify-content: center;
padding: 10px 6px;
} }
} }