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 CalStatus,
} from './homeCalendar';
import { fetchHolidaySet } from '../admin/holidayApi';
import { HomeCharts } from './HomeCharts';
import './home.css';
@ -634,6 +635,14 @@ function HomeCalendar() {
queryKey: ['home-hall-centers'],
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 filtered = useMemo(
@ -750,12 +759,17 @@ function HomeCalendar() {
cell.inMonth ? '' : 'is-out',
cell.isToday ? 'is-today' : '',
cell.dow === 0 ? 'is-sun' : cell.dow === 6 ? 'is-sat' : '',
holidays.get(cell.key) ? 'is-holiday' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => setPopupDay(cell.key)}
title={holidays.get(cell.key) || undefined}
>
<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 && (
<span className="kx-home__cal-more tnum">
{t('home.calendar.more', { n: wk.hiddenByCol[cell.dow] })}

View File

@ -481,7 +481,7 @@
}
.kx-home__cal-week {
position: relative;
border-bottom: var(--border-card);
border-bottom: 1px solid var(--color-neutral-100);
}
.kx-home__cal-week:last-child {
border-bottom: none;
@ -492,7 +492,7 @@
}
.kx-home__cal-cell {
min-height: 84px;
border-right: var(--border-card);
border-right: 1px solid var(--color-neutral-100);
padding: 4px 6px;
text-align: left;
background: var(--color-white);
@ -523,6 +523,20 @@
.kx-home__cal-cell.is-sat .kx-home__cal-daynum {
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 {
background: var(--color-primary-600);
color: #fff;

View File

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

View File

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