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:
parent
a5a78f1ea1
commit
c77cba7c5b
@ -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] })}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,52 +51,31 @@ 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')}>
|
||||
{TRACK_TABS.map((it, i) => {
|
||||
const isActive = it.key === track;
|
||||
return (
|
||||
<Link
|
||||
key={it.key}
|
||||
ref={(el) => {
|
||||
refs.current[i] = el;
|
||||
}}
|
||||
to={it.to}
|
||||
role="tab"
|
||||
className={`kxp-tracktab${isActive ? ' is-active' : ''}`}
|
||||
aria-selected={isActive}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
tabIndex={i === baseIdx ? 0 : -1}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
>
|
||||
{t(it.i18n)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</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
|
||||
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 (
|
||||
<Link
|
||||
key={it.key}
|
||||
ref={(el) => {
|
||||
refs.current[i] = el;
|
||||
}}
|
||||
to={it.to}
|
||||
role="tab"
|
||||
className={`kxp-tracktab${isActive ? ' is-active' : ''}`}
|
||||
aria-selected={isActive}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
tabIndex={i === baseIdx ? 0 : -1}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
>
|
||||
{t(it.i18n)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</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" />
|
||||
|
||||
<Link className="kxp-btn kxp-btn--outline kxp-header__login" to="/login">
|
||||
{t('shell.login')}
|
||||
</Link>
|
||||
{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>
|
||||
))}
|
||||
<Link className="kxp-btn kxp-btn--outline kxp-mobilenav__cta" to="/login">
|
||||
{t('shell.login')}
|
||||
</Link>
|
||||
{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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user