diff --git a/src/frontend/src/screens/public/EventSubscribeForm.tsx b/src/frontend/src/screens/public/EventSubscribeForm.tsx
new file mode 100644
index 0000000..d49023c
--- /dev/null
+++ b/src/frontend/src/screens/public/EventSubscribeForm.tsx
@@ -0,0 +1,137 @@
+/*
+ * F-C1 관심 행사 구독 폼 (공개) — POST /api/public/events/{eventId}/subscribe.
+ * 오픈 예정/진행 행사에만 노출(호출부가 deriveStatus 로 판정). 개인정보 동의(필수) 미체크 시 차단.
+ * 3상태: 폼 → 성공(emailMasked·신규/재활성) · 이미구독(alreadySubscribed) · 오류(멱등·rate 포함).
+ * Nifty 문자 그대로: kxp-subcard/kxp-field/kxp-input/kxp-check/kxp-btn + kx 토큰(publicExtras.css). 하드코딩 0.
+ */
+import { useId, useState, type FormEvent } from 'react';
+import { useTranslation } from 'react-i18next';
+import { publicApi, type SubscribeResult } from './publicApi';
+import { errorMessage } from './publicFormat';
+import { IconBell, IconCheckCircle } from './publicIcons';
+import './publicExtras.css';
+
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+/** 이메일 위치 무관 분할용 고유 센티넬(제어문자 — 어떤 번역문에도 미등장). */
+const EMAIL_SENTINEL = String.fromCharCode(0);
+
+export function EventSubscribeForm({
+ eventId,
+ variant = 'card',
+}: {
+ eventId: string;
+ variant?: 'card' | 'banner';
+}) {
+ const { t } = useTranslation();
+ const fid = useId();
+ const [email, setEmail] = useState('');
+ const [agree, setAgree] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState('');
+ const [result, setResult] = useState(null);
+
+ async function onSubmit(e: FormEvent) {
+ e.preventDefault();
+ setError('');
+ if (!agree) {
+ setError(t('subscribe.errPrivacy', { defaultValue: '구독 알림 수신을 위한 개인정보 수집·이용 동의가 필요합니다.' }));
+ return;
+ }
+ if (!EMAIL_RE.test(email.trim())) {
+ setError(t('subscribe.errEmail', { defaultValue: '올바른 이메일 주소를 입력해 주세요.' }));
+ return;
+ }
+ setSubmitting(true);
+ try {
+ const r = await publicApi.subscribeEvent(eventId, { email: email.trim(), agreePrivacy: agree });
+ setResult(r);
+ } catch (err) {
+ setError(errorMessage(err));
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ // ── 성공/이미구독 상태 ──
+ if (result) {
+ const already = result.alreadySubscribed;
+ // {{email}} 위치 무관: 센티넬로 분할해 emailMasked 를 굵게 유지(전 로케일 견고).
+ const key = already ? 'subscribe.already' : 'subscribe.done';
+ const dv = already
+ ? '{{email}} 주소는 이미 오픈 알림을 신청하셨습니다.'
+ : '{{email}} 주소로 티켓 오픈 시 알림을 보내드립니다.';
+ const [pre, post = ''] = t(key, { email: EMAIL_SENTINEL, defaultValue: dv }).split(EMAIL_SENTINEL);
+ return (
+
+
+
+
+
+
+
+ {pre}
+ {result.emailMasked}
+ {post}
+
+
+ {t('subscribe.unsubNote', { defaultValue: '알림 메일 하단의 수신거부 링크로 언제든 해지할 수 있습니다.' })}
+
+
+
+
+ );
+ }
+
+ // ── 입력 폼 상태 ──
+ return (
+
+ );
+}
diff --git a/src/frontend/src/screens/public/LiveNoticeFeed.tsx b/src/frontend/src/screens/public/LiveNoticeFeed.tsx
new file mode 100644
index 0000000..b63100d
--- /dev/null
+++ b/src/frontend/src/screens/public/LiveNoticeFeed.tsx
@@ -0,0 +1,123 @@
+/*
+ * F-B6 라이브 공지 피드 위젯 (공개·관람객) — GET /api/public/events/{eventId}/live-notices.
+ * 고정(pinned) 상단·긴급(URGENT) 강조·30초 폴링. 게시분만(백엔드가 published 만 반환).
+ * 공지가 없으면 self-hide(null 반환) → 행사 상세·visitor 트랙 어디에 두어도 안전.
+ * Nifty 문자 그대로: kxp-livefeed/kxp-lnbadge + kx 토큰(publicExtras.css). 하드코딩 0.
+ */
+import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { publicApi, type LiveNoticeCategory, type LiveNoticeDto } from './publicApi';
+import { IconBell, IconPin } from './publicIcons';
+import './publicExtras.css';
+
+/** 카테고리 → 배지 클래스 접미 + i18n 키. 조직자 화면과 공유. */
+export const LIVE_NOTICE_CATEGORY_META: Record<
+ LiveNoticeCategory,
+ { cls: string; i18n: string; fallback: string }
+> = {
+ URGENT: { cls: 'kxp-lnbadge--urgent', i18n: 'liveNotice.cat.urgent', fallback: '긴급' },
+ PROGRAM: { cls: 'kxp-lnbadge--program', i18n: 'liveNotice.cat.program', fallback: '프로그램' },
+ INFO: { cls: 'kxp-lnbadge--info', i18n: 'liveNotice.cat.info', fallback: '안내' },
+ GENERAL: { cls: 'kxp-lnbadge--general', i18n: 'liveNotice.cat.general', fallback: '일반' },
+};
+
+function catMeta(category: string) {
+ return LIVE_NOTICE_CATEGORY_META[(category as LiveNoticeCategory)] ?? LIVE_NOTICE_CATEGORY_META.GENERAL;
+}
+
+/** 라이브 공지 카테고리 배지 — 공개·조직자 공용. */
+export function LiveNoticeBadge({ category }: { category: string }) {
+ const { t } = useTranslation();
+ const meta = catMeta(category);
+ return {t(meta.i18n, { defaultValue: meta.fallback })};
+}
+
+function fmt(iso: string | null): string {
+ if (!iso) return '';
+ const d = new Date(iso);
+ return Number.isNaN(d.getTime())
+ ? ''
+ : d.toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' });
+}
+
+const POLL_MS = 30_000;
+
+/**
+ * @param eventId 공지 대상 행사 (없으면 렌더 안 함)
+ * @param limit 최대 건수(기본 20 — 위젯)
+ */
+export function LiveNoticeFeed({ eventId, limit = 20 }: { eventId: string | null | undefined; limit?: number }) {
+ const { t } = useTranslation();
+ const [items, setItems] = useState([]);
+ const [loaded, setLoaded] = useState(false);
+
+ useEffect(() => {
+ if (!eventId) return;
+ let alive = true;
+ const load = () => {
+ publicApi
+ .listLiveNotices(eventId, limit)
+ .then((list) => {
+ if (alive) {
+ setItems(list);
+ setLoaded(true);
+ }
+ })
+ .catch(() => {
+ if (alive) setLoaded(true); // 실패 시 조용히 숨김(공개 부가 위젯)
+ });
+ };
+ load();
+ const id = window.setInterval(load, POLL_MS);
+ return () => {
+ alive = false;
+ window.clearInterval(id);
+ };
+ }, [eventId, limit]);
+
+ // self-hide: 대상 없음 / 로딩 전 / 게시 공지 없음.
+ if (!eventId || !loaded || items.length === 0) return null;
+
+ return (
+
+
+
+
+ {t('liveNotice.feedTitle', { defaultValue: '라이브 공지' })}
+
+
+
+ {t('liveNotice.live', { defaultValue: 'LIVE' })}
+
+
+
+ {items.map((n) => {
+ const urgent = n.category === 'URGENT';
+ return (
+ -
+
+
+ {n.pinned && (
+
+
+ {t('liveNotice.pinned', { defaultValue: '고정' })}
+
+ )}
+
+
+
{n.title}
+ {n.body &&
{n.body}
}
+
+ {[n.authorName, fmt(n.updatedAt ?? n.createdAt)].filter(Boolean).join(' · ')}
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/frontend/src/screens/public/PublicEventDetailPage.tsx b/src/frontend/src/screens/public/PublicEventDetailPage.tsx
index 75c5f36..adc9343 100644
--- a/src/frontend/src/screens/public/PublicEventDetailPage.tsx
+++ b/src/frontend/src/screens/public/PublicEventDetailPage.tsx
@@ -8,7 +8,9 @@ import { useTranslation } from 'react-i18next';
import { Link, useParams } from 'react-router-dom';
import { PublicShell } from './PublicShell';
import { publicApi, type PublicEvent } from './publicApi';
-import { formatRange, errorMessage } from './publicFormat';
+import { formatRange, errorMessage, deriveStatus } from './publicFormat';
+import { LiveNoticeFeed } from './LiveNoticeFeed';
+import { EventSubscribeForm } from './EventSubscribeForm';
import {
IconCalendar,
IconPin,
@@ -82,6 +84,9 @@ export function PublicEventDetailPage() {
const dateRange = event ? formatRange(event.startDate, event.endDate) : t('detail.dateTbd');
const place = event?.hallLabel ?? t('detail.placeTbd');
const registerTo = eventId ? `/public/events/${encodeURIComponent(eventId)}/register` : '#program';
+ // F-C1: 구독 폼은 오픈 예정/진행 행사에만 노출(종료 행사 제외).
+ const status = event ? deriveStatus(event.startDate, event.endDate) : 'upcoming';
+ const showSubscribe = !!eventId && status !== 'ended';
return (
@@ -151,6 +156,18 @@ export function PublicEventDetailPage() {
+ {/* F-B6 라이브 공지 피드 + F-C1 오픈 알림 구독 — 게시 공지 없으면 피드는 self-hide */}
+ {eventId && (
+
+
+
+
+ {showSubscribe && }
+
+
+
+ )}
+
{/* 공유 사이드 */}
+ {/* F-B6 라이브 공지 피드(진행/예정 행사) — 게시 공지 없으면 self-hide */}
+ {liveEvent && (
+
+
+
+ )}
+
{noticeLoading && (
{t('home.loading')}
diff --git a/src/frontend/src/screens/public/index.ts b/src/frontend/src/screens/public/index.ts
index 973c7fc..f908b28 100644
--- a/src/frontend/src/screens/public/index.ts
+++ b/src/frontend/src/screens/public/index.ts
@@ -16,3 +16,11 @@ export { PublicRegistrationPage } from './PublicRegistrationPage'; // SCR-P4 /pu
export { PublicMicrositePage } from './PublicMicrositePage'; // SCR-P5 /public/exhibitors/:exhibitorId
export { PublicInquiryPage } from './PublicInquiryPage'; // SCR-P6 /public/exhibit-inquiry
export { PublicTicketPage } from './PublicTicketPage'; // SCR-P7 /tickets/:eventId/purchase · /tickets/lookup
+export { PublicUnsubscribePage } from './PublicUnsubscribePage'; // F-C1 /public/subscriptions/unsubscribe
+// GNB 정보 페이지 (2026-07-23 — 헤더 메뉴 데드링크 해소)
+export {
+ PublicEventsListPage, // /public/events (행사 목록·검색)
+ PublicExhibitGuidePage, // /public/exhibit-guide (참가안내·CMS PAGE)
+ PublicVisitGuidePage, // /public/visit-guide (관람안내·visitor-guide)
+ PublicTransportPage, // /public/transport (교통·transport)
+} from './PublicInfoPages';
diff --git a/src/frontend/src/screens/public/live.css b/src/frontend/src/screens/public/live.css
new file mode 100644
index 0000000..b122e8d
--- /dev/null
+++ b/src/frontend/src/screens/public/live.css
@@ -0,0 +1,164 @@
+/*
+ * F-C2/C3 관람객 라이브 정보(주차·혼잡) — 자체 스타일(kx 토큰만, 하드코딩 0). 공유 css 무수정.
+ * Nifty: 카드(서브틀 그림자·라운드)·중립 팔레트·상태색 토큰.
+ */
+.kxl {
+ max-width: var(--kxp-maxw, 1120px);
+ margin: 0 auto;
+ padding: var(--space-5) var(--space-4);
+}
+.kxl-hero {
+ margin-bottom: var(--space-5);
+}
+.kxl-hero h1 {
+ font-size: var(--fs-h1);
+ font-weight: var(--fw-bold);
+ color: var(--color-neutral-900);
+ margin: 0 0 var(--space-1);
+}
+.kxl-hero p {
+ font-size: var(--fs-body);
+ color: var(--color-text-muted);
+ margin: 0;
+}
+.kxl-grid {
+ display: grid;
+ grid-template-columns: 1fr 1.2fr;
+ gap: var(--space-4);
+}
+@media (max-width: 860px) {
+ .kxl-grid { grid-template-columns: 1fr; }
+}
+
+.kx-card.kxl-cong,
+.kx-card.kxl-parking {
+ padding: var(--space-4);
+ display: grid;
+ gap: var(--space-3);
+ align-content: start;
+}
+.kxl-cong__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+}
+.kxl-sec__title {
+ font-size: var(--fs-h3);
+ font-weight: var(--fw-semibold);
+ color: var(--color-neutral-900);
+ margin: 0;
+}
+.kxl-sec__title--sm { font-size: var(--fs-body); }
+
+.kxl-overall,
+.kxl-badge {
+ font-size: var(--fs-nano);
+ font-weight: var(--fw-semibold);
+ padding: var(--space-1) var(--space-2);
+ border-radius: var(--radius-pill);
+ white-space: nowrap;
+}
+.is-free { background: var(--color-success-bg); color: var(--color-success); }
+.is-normal { background: var(--color-warning-bg); color: var(--color-warning); }
+.is-busy { background: var(--color-error-bg); color: var(--color-error); }
+
+.kxl-cong__onsite {
+ font-size: var(--fs-caption);
+ color: var(--color-neutral-700);
+ margin: 0;
+}
+.kxl-cong__onsite b { color: var(--color-primary-700); font-weight: var(--fw-bold); }
+
+.kxl-cong__row {
+ display: grid;
+ gap: var(--space-1);
+}
+.kxl-cong__row-label {
+ font-size: var(--fs-nano);
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.kxl-cong__chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+.kxl-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--fs-caption);
+ padding: var(--space-1) var(--space-2);
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--color-neutral-200);
+ background: var(--color-neutral-050);
+ color: var(--color-neutral-700);
+}
+.kxl-chip.is-busy { border-color: var(--color-error); }
+.kxl-chip.is-normal { border-color: var(--color-warning); }
+.kxl-chip b { font-weight: var(--fw-semibold); }
+
+.kxl-updated {
+ font-size: var(--fs-nano);
+ color: var(--color-text-muted);
+ margin: 0;
+}
+
+/* 주차 */
+.kxl-parklist { display: grid; gap: var(--space-3); }
+.kxl-lot {
+ display: grid;
+ gap: var(--space-2);
+ padding: var(--space-3);
+ border: 1px solid var(--color-neutral-100);
+ border-radius: var(--radius-md);
+ background: var(--color-white);
+}
+.kxl-lot__top { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-2); }
+.kxl-lot__name {
+ display: flex; align-items: center; gap: var(--space-1);
+ font-size: var(--fs-body); font-weight: var(--fw-semibold);
+ color: var(--color-neutral-900); margin: 0;
+}
+.kxl-lot__note { font-size: var(--fs-nano); color: var(--color-text-muted); margin: var(--space-1) 0 0; }
+.kxl-lot__bar {
+ height: 8px; border-radius: var(--radius-pill);
+ background: var(--color-neutral-100); overflow: hidden;
+}
+.kxl-lot__fill { display: block; height: 100%; border-radius: var(--radius-pill); }
+.kxl-lot__fill.is-free { background: var(--color-success); }
+.kxl-lot__fill.is-normal { background: var(--color-warning); }
+.kxl-lot__fill.is-busy { background: var(--color-error); }
+.kxl-lot__meta {
+ display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
+ gap: var(--space-2); font-size: var(--fs-caption); color: var(--color-neutral-600);
+}
+.kxl-lot__meta b { color: var(--color-neutral-900); font-weight: var(--fw-semibold); }
+
+.kxl-mypasses { margin-top: var(--space-2); display: grid; gap: var(--space-2); }
+.kxl-passlist { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2); }
+.kxl-pass {
+ display: flex; justify-content: space-between; align-items: center; gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-neutral-100); border-radius: var(--radius-sm);
+}
+.kxl-pass__no { display: block; font-weight: var(--fw-semibold); color: var(--color-neutral-900); font-size: var(--fs-caption); }
+.kxl-pass__meta { display: block; font-size: var(--fs-nano); color: var(--color-text-muted); }
+.kxl-empty { font-size: var(--fs-caption); color: var(--color-text-muted); }
+
+/* 구매 폼 */
+.kxl-buy { display: grid; gap: var(--space-3); }
+.kxl-buy__lot { font-weight: var(--fw-semibold); color: var(--color-neutral-900); margin: 0; }
+.kxl-field { display: grid; gap: var(--space-1); font-size: var(--fs-caption); color: var(--color-neutral-700); }
+.kxl-field input, .kxl-field select {
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-neutral-300);
+ border-radius: var(--radius-sm);
+ font-size: var(--fs-body);
+ color: var(--color-neutral-900);
+ background: var(--color-white);
+}
+.kxl-buy__note { display: flex; align-items: center; gap: var(--space-1); font-size: var(--fs-nano); color: var(--color-text-muted); margin: 0; }
+.kxl-buy__err { font-size: var(--fs-caption); color: var(--color-error); margin: 0; }
diff --git a/src/frontend/src/screens/public/liveApi.ts b/src/frontend/src/screens/public/liveApi.ts
new file mode 100644
index 0000000..792ca5a
--- /dev/null
+++ b/src/frontend/src/screens/public/liveApi.ts
@@ -0,0 +1,87 @@
+/*
+ * 관람객 대면 라이브 정보 API — 주차 현황(F-C2)·혼잡 안내(F-C3)·주차권.
+ * 백엔드 계약: _workspace/parking_congestion_contract.md
+ * GET /api/public/parking/lots?eventId (공개)
+ * GET /api/public/congestion?eventId (공개)
+ * POST /api/parking/passes (인증)
+ * GET /api/parking/passes/me (인증)
+ * 보안: 차량번호·PII 원문 없음(서버 마스킹 필드만).
+ */
+import { api } from '../../api/client';
+
+const pub = { anonymous: true } as const;
+
+export interface ParkingLotStatus {
+ lotId: string;
+ code: string;
+ name: string;
+ exhibitionCenter: number | null;
+ totalCapacity: number;
+ occupied: number;
+ available: number;
+ occupancyPercent: number;
+ congestionLevel: 'FREE' | 'NORMAL' | 'BUSY';
+ congestionLabel: string;
+ hourlyRate: number;
+ dailyMax: number | null;
+ passPrice: number;
+ note: string | null;
+ updatedAt: string;
+}
+
+export interface CongestionArea {
+ id: string;
+ label: string;
+ level: 'FREE' | 'NORMAL' | 'BUSY';
+ levelLabel: string;
+ occupancyPercent: number | null;
+}
+export interface CongestionOverview {
+ eventId: string;
+ overallLevel: 'FREE' | 'NORMAL' | 'BUSY';
+ overallLabel: string;
+ onSiteCount: number;
+ entryGates: CongestionArea[];
+ parking: CongestionArea[];
+ popularSessions: CongestionArea[];
+ updatedAt: string;
+}
+
+export interface ParkingPass {
+ passNo: string;
+ status: string; // PAID/CANCELLED/USED
+ lotId: string;
+ lotName: string;
+ eventId: string | null;
+ useDate: string;
+ vehiclePlateMasked: string | null;
+ amount: number;
+ payMethod: string;
+ payApprovalNo: string;
+ issuedAt: string;
+}
+export interface MyPasses {
+ passes: ParkingPass[];
+}
+export interface PurchasePassRequest {
+ lotId: string;
+ useDate: string;
+ eventId?: string;
+ vehiclePlate?: string;
+ payMethod?: string;
+}
+
+export const liveApi = {
+ parkingLots: (eventId?: string) =>
+ api.get(
+ `/api/public/parking/lots${eventId ? `?eventId=${encodeURIComponent(eventId)}` : ''}`,
+ pub,
+ ),
+ congestion: (eventId: string) =>
+ api.get(`/api/public/congestion?eventId=${encodeURIComponent(eventId)}`, pub),
+
+ // 인증(로그인 관람객)
+ myPasses: () => api.get('/api/parking/passes/me'),
+ purchasePass: (payload: PurchasePassRequest) =>
+ api.post('/api/parking/passes', payload),
+};
diff --git a/src/frontend/src/screens/public/public.css b/src/frontend/src/screens/public/public.css
index fa71d70..ad291de 100644
--- a/src/frontend/src/screens/public/public.css
+++ b/src/frontend/src/screens/public/public.css
@@ -227,12 +227,20 @@
min-width: 0;
}
.kxp-brand {
+ display: inline-flex;
+ align-items: center;
font-size: var(--fs-h2);
font-weight: 800;
letter-spacing: -0.02em;
color: var(--color-primary-700);
white-space: nowrap;
}
+/* 정식 CI 워드마크(311×56). 흰 헤더 위 원본 비율 유지, 높이 기준 스케일. */
+.kxp-brand__logo {
+ display: block;
+ height: 28px; /* 브랜드 규격 header(28px) — brand-logo.css 규격 정합 */
+ width: auto;
+}
.kxp-gnb {
display: none;
gap: 24px;
@@ -271,14 +279,40 @@
color: var(--color-neutral-500);
flex-shrink: 0;
}
+/* 검색 실행 버튼(아이콘) — 배경 없는 아이콘 버튼, 터치타깃 확보. */
+.kxp-search__btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ margin: 0;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ color: var(--color-neutral-500);
+}
+.kxp-search__btn:hover {
+ color: var(--color-primary-600);
+}
.kxp-search__input {
border: none;
background: transparent;
outline: none;
font-size: var(--fs-body);
- width: 130px;
+ /* 한글 약 10자 입력폭(소유자 지시 ≈10ch). */
+ width: 10ch;
color: var(--color-neutral-700);
}
+/* 모바일 메뉴 내 검색 — 버거 메뉴에서 노출(데스크톱 헤더 검색은 768px+에서만). */
+.kxp-search--mobile {
+ display: flex;
+ width: 100%;
+ margin-bottom: 8px;
+}
+.kxp-search--mobile .kxp-search__input {
+ flex: 1;
+ width: auto;
+}
.kxp-lang {
position: relative;
}
@@ -755,6 +789,11 @@
height: 100%;
object-fit: cover;
}
+/* 실 포스터 오버레이 — 그라디언트 폴백 div 위 절대배치(칩은 DOM 후순위라 위에 렌더). */
+.kxp-ecard__mediaimg {
+ position: absolute;
+ inset: 0;
+}
.kxp-ecard__topright {
position: absolute;
top: 12px;
@@ -923,6 +962,16 @@
color: var(--color-on-accent);
margin-bottom: 12px;
}
+/* 풋터 워드마크 — 규격 footer(22px). 어두운 풋터 대비 위해 흰 배경 패딩 카드로 노출(소유자 대비 관례). */
+.kxp-footer__logo-img {
+ height: 22px;
+ width: auto;
+ display: block;
+ margin-bottom: 12px;
+ background: #fff;
+ padding: 6px 10px;
+ border-radius: var(--radius-sm);
+}
.kxp-footer__desc {
font-size: var(--fs-body);
margin: 0;
@@ -2594,7 +2643,7 @@
.kxp-ticktable th {
text-align: left;
font-size: var(--fs-caption);
- font-weight: 600;
+ font-weight: var(--fw-semibold);
letter-spacing: 0.02em;
color: var(--color-neutral-500);
padding: 12px 8px;
@@ -4104,6 +4153,35 @@ a.kxp-ai__cite:hover {
opacity: 1;
}
+/* ── 히어로 인디케이터 도트 (Nifty 페이지네이션 규칙: --radius-pill·현재강조·gap --space-2) ──
+ 전경(히어로 직속 자식)으로 배치 — 배경 레이어(z 0/-2)가 아니라 스크림/콘텐츠 위(z 3).
+ 장식 인디케이터라 비상호작용(pointer-events:none). kx 토큰만 사용(하드코딩 색 없음). */
+.kxp-hero-dots {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: var(--space-4);
+ z-index: 3;
+ display: flex;
+ justify-content: center;
+ gap: var(--space-2);
+ pointer-events: none;
+}
+.kxp-hero-dots__dot {
+ width: 8px;
+ height: 8px;
+ border-radius: var(--radius-pill);
+ background: var(--color-on-accent);
+ opacity: 0.45;
+ transition:
+ width var(--motion-base) var(--ease-standard),
+ opacity var(--motion-base) var(--ease-standard);
+}
+.kxp-hero-dots__dot.is-active {
+ width: 22px;
+ opacity: 1;
+}
+
/* ── ④ 포스터 카드 리프트 + 이미지 줌 ──────────────────────────────────────── */
.pub-card--lift {
position: relative;
@@ -4232,3 +4310,37 @@ a.kxp-ai__cite:hover {
scroll-behavior: auto;
}
}
+
+/* ═══ 공개 GNB 정보 페이지 (2026-07-23) — 행사 검색바·교통 그룹. 전부 kx 토큰. ═══ */
+/* 행사 목록 페이지의 넓은 인라인 검색바(헤더 검색과 동일 대상). */
+.kxp-search--page {
+ display: flex;
+ width: 100%;
+ max-width: 480px;
+ margin: 0 0 20px;
+}
+.kxp-search--page .kxp-search__input {
+ flex: 1;
+ width: auto;
+}
+/* 교통 수단(mode)별 그룹 */
+.kxp-tgroup {
+ margin-bottom: 24px;
+}
+.kxp-tgroup__title {
+ font-size: var(--fs-h3);
+ font-weight: var(--fw-bold, 700);
+ color: var(--color-neutral-900);
+ margin: 0 0 12px;
+}
+
+/* ═══ 유색 배경 버튼 글자 흰색 통일 안전망 (2026-07-23 소유자 지시) ═══
+ 채워진(filled) 유색 버튼·d-day 배지는 배경 대비 항상 흰 글자(--color-on-accent)를 강제.
+ 개별 규칙 상속에 의존하지 않도록 명시적 스윕(회귀 방지). 아웃라인/고스트는 제외. */
+.kxp .kxp-btn--primary,
+.kxp .kxp-btn--primary:hover,
+.kxp .kxp-btn--danger:hover,
+.kxp .kxp-dday,
+.kxp .kxp-cta-banner {
+ color: var(--color-on-accent);
+}
diff --git a/src/frontend/src/screens/public/publicApi.ts b/src/frontend/src/screens/public/publicApi.ts
index c208263..805e956 100644
--- a/src/frontend/src/screens/public/publicApi.ts
+++ b/src/frontend/src/screens/public/publicApi.ts
@@ -155,6 +155,39 @@ export interface RegistrationReceipt {
badgeCode: string;
}
+// ── F-C1 관심 행사 구독 (백엔드 SubscribeResult / UnsubscribeResult) ──────────
+/** 구독 신청 결과 — 원문 이메일 미포함(emailMasked 만). status='active'. */
+export interface SubscribeResult {
+ eventId: string;
+ eventName: string | null;
+ emailMasked: string;
+ status: string;
+ /** true=이미 구독 중(멱등 200). false=신규 또는 해지분 재활성화. */
+ alreadySubscribed: boolean;
+}
+
+/** 수신거부(해지) 결과 — 멱등. ok=false 는 잘못된 토큰. */
+export interface UnsubscribeResult {
+ ok: boolean;
+ message: string;
+}
+
+// ── F-B6 행사 라이브 공지 (백엔드 LiveNoticeDto) ──────────────────────────────
+/** 배지 색상 매핑: URGENT=red · PROGRAM=amber · INFO=blue · GENERAL=slate. */
+export type LiveNoticeCategory = 'URGENT' | 'PROGRAM' | 'GENERAL' | 'INFO';
+export interface LiveNoticeDto {
+ id: string;
+ eventId: string;
+ category: LiveNoticeCategory | string;
+ title: string;
+ body: string | null;
+ pinned: boolean;
+ status: string;
+ authorName: string | null;
+ createdAt: string | null;
+ updatedAt: string | null;
+}
+
// ── 참가업체 마이크로사이트 (cms 팀 계약 — 관용적 옵셔널) ────────────────────
export interface PublicMicrosite {
exhibitorId?: string;
@@ -207,6 +240,10 @@ export const publicApi = {
listNotices: (limit = 5) =>
api.get(`/api/public/cms/NOTICE${qs({ limit: String(limit) })}`, opt),
+ /** M17 공개 CMS 콘텐츠(게시분) — type ∈ PAGE|POST|NOTICE|BLOCK. 참가안내(PAGE)·소식(POST) 등. */
+ listCms: (type: string, limit = 20) =>
+ api.get(`/api/public/cms/${type}${qs({ limit: String(limit) })}`, opt),
+
// ── 관람 안내 · AI 도우미 (V43 라이브) ──────────────────────────────────────
/** AI 관람 도우미 — 자연어 질문을 크롤 적재 DB 근거로 답변(비인증). */
askVisitorAssistant: (question: string) =>
@@ -238,4 +275,28 @@ export const publicApi = {
/** 교통수단 구조화(SUBWAY/GTX/BUS/CAR/AIRPORT/KTX). */
listTransport: (mode?: string) =>
api.get(`/api/public/transport${qs({ mode })}`, opt),
+
+ // ── F-C1 관심 행사 구독 (비인증) ────────────────────────────────────────────
+ /** 오픈 예정/진행 행사 이메일 구독. agreePrivacy=true 필수(false 면 400). */
+ subscribeEvent: (eventId: string, payload: { email: string; agreePrivacy: boolean }) =>
+ api.post(
+ `/api/public/events/${encodeURIComponent(eventId)}/subscribe`,
+ payload,
+ opt,
+ ),
+
+ /** 수신거부(해지) — 멱등. 메일 링크(GET)·버튼(POST) 공통 토큰. */
+ unsubscribe: (token: string) =>
+ api.post(
+ `/api/public/subscriptions/unsubscribe${qs({ token })}`,
+ undefined,
+ opt,
+ ),
+
+ // ── F-B6 행사 라이브 공지 공개 조회 (비인증, 게시분만·고정 우선) ──────────────
+ listLiveNotices: (eventId: string, limit = 100) =>
+ api.get(
+ `/api/public/events/${encodeURIComponent(eventId)}/live-notices${qs({ limit: String(limit) })}`,
+ opt,
+ ),
};
diff --git a/src/frontend/src/screens/public/publicExtras.css b/src/frontend/src/screens/public/publicExtras.css
new file mode 100644
index 0000000..c11c3da
--- /dev/null
+++ b/src/frontend/src/screens/public/publicExtras.css
@@ -0,0 +1,231 @@
+/*
+ * F-C1 구독 폼 · F-B6 라이브 공지 피드 스타일 — 공개/관람객 영역.
+ * Nifty 문자 그대로: kx 토큰만(--color-*·--space-*·--radius-*·--fs-*·--fw-*·--shadow-card·--border-card).
+ * 하드코딩 색·폰트·px 0(테두리 1px 는 관용). 기존 kxp-* 폼/섹션 컨벤션과 톤 일치.
+ */
+
+/* ── F-C1 구독 카드 ─────────────────────────────────────────────────────────── */
+.kxp-subcard {
+ background: var(--color-white);
+ border: var(--border-card);
+ border-radius: var(--radius-md, var(--radius-sm));
+ box-shadow: var(--shadow-card);
+ padding: var(--space-5, var(--space-4));
+}
+.kxp-subcard--banner {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-4);
+ justify-content: space-between;
+}
+.kxp-subcard__head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ color: var(--color-primary-600);
+}
+.kxp-subcard__title {
+ margin: 0;
+ font-size: var(--fs-h3);
+ font-weight: var(--fw-bold, var(--fw-semibold));
+ color: var(--color-neutral-900);
+}
+.kxp-subcard__sub {
+ margin: var(--space-1) 0 0;
+ font-size: var(--fs-body);
+ color: var(--color-neutral-500);
+}
+.kxp-subform {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-3);
+ align-items: flex-end;
+ margin-top: var(--space-4);
+}
+.kxp-subcard--banner .kxp-subform {
+ margin-top: 0;
+ flex: 1 1 auto;
+ min-width: 16rem;
+}
+.kxp-subform__field {
+ flex: 1 1 14rem;
+ min-width: 12rem;
+}
+.kxp-subform__consent {
+ flex: 1 1 100%;
+}
+.kxp-subresult {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ margin-top: var(--space-4);
+ padding: var(--space-4);
+ border-radius: var(--radius-sm);
+ background: color-mix(in srgb, var(--color-primary-600) 8%, transparent);
+ border: 1px solid color-mix(in srgb, var(--color-primary-600) 30%, transparent);
+ color: var(--color-neutral-900);
+ font-size: var(--fs-body);
+}
+.kxp-subresult--ok {
+ background: color-mix(in srgb, var(--color-success) 10%, transparent);
+ border-color: color-mix(in srgb, var(--color-success) 35%, transparent);
+}
+.kxp-subresult--warn {
+ background: color-mix(in srgb, var(--color-warning) 12%, transparent);
+ border-color: color-mix(in srgb, var(--color-warning) 40%, transparent);
+}
+.kxp-subresult__ic {
+ flex: none;
+ color: var(--color-primary-600);
+}
+.kxp-subresult--ok .kxp-subresult__ic {
+ color: var(--color-success);
+}
+.kxp-subresult--warn .kxp-subresult__ic {
+ color: var(--color-warning);
+}
+.kxp-subresult__strong {
+ font-weight: var(--fw-bold, var(--fw-semibold));
+}
+.kxp-suberror {
+ margin-top: var(--space-3);
+ color: var(--color-error);
+ font-size: var(--fs-body);
+}
+
+/* ── F-B6 라이브 공지 피드 ──────────────────────────────────────────────────── */
+.kxp-livefeed {
+ background: var(--color-white);
+ border: var(--border-card);
+ border-radius: var(--radius-md, var(--radius-sm));
+ box-shadow: var(--shadow-card);
+ overflow: hidden;
+}
+.kxp-livefeed__head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-4) var(--space-5, var(--space-4));
+ border-bottom: var(--border-card);
+}
+.kxp-livefeed__head svg {
+ color: var(--color-primary-600);
+}
+.kxp-livefeed__title {
+ margin: 0;
+ font-size: var(--fs-h3);
+ font-weight: var(--fw-bold, var(--fw-semibold));
+ color: var(--color-neutral-900);
+}
+.kxp-livefeed__live {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ margin-left: auto;
+ font-size: var(--fs-nano);
+ font-weight: var(--fw-semibold, var(--fw-medium));
+ color: var(--color-error);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.kxp-livefeed__dot {
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 50%;
+ background: var(--color-error);
+}
+@media (prefers-reduced-motion: no-preference) {
+ .kxp-livefeed__dot {
+ animation: kxpLivePulse 1.8s ease-in-out infinite;
+ }
+}
+@keyframes kxpLivePulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.35; }
+}
+.kxp-livefeed__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.kxp-livenotice {
+ display: flex;
+ gap: var(--space-3);
+ padding: var(--space-4) var(--space-5, var(--space-4));
+ border-bottom: var(--border-card);
+}
+.kxp-livenotice:last-child {
+ border-bottom: 0;
+}
+.kxp-livenotice.is-pinned {
+ background: color-mix(in srgb, var(--color-primary-600) 5%, transparent);
+}
+.kxp-livenotice.is-urgent {
+ background: color-mix(in srgb, var(--color-error) 6%, transparent);
+}
+.kxp-livenotice__main {
+ min-width: 0;
+ flex: 1 1 auto;
+}
+.kxp-livenotice__top {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+.kxp-livenotice__title {
+ margin: var(--space-1) 0 0;
+ font-size: var(--fs-body);
+ font-weight: var(--fw-semibold, var(--fw-medium));
+ color: var(--color-neutral-900);
+}
+.kxp-livenotice__body {
+ margin: var(--space-1) 0 0;
+ font-size: var(--fs-nano);
+ color: var(--color-neutral-500);
+ white-space: pre-line;
+}
+.kxp-livenotice__meta {
+ margin: var(--space-2) 0 0;
+ font-size: var(--fs-micro, var(--fs-nano));
+ color: var(--color-neutral-500);
+}
+.kxp-lnpin {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ font-size: var(--fs-micro, var(--fs-nano));
+ font-weight: var(--fw-semibold, var(--fw-medium));
+ color: var(--color-primary-600);
+}
+
+/* 카테고리 배지 — 색상 매핑(계약 §4): URGENT=red · PROGRAM=amber · INFO=blue · GENERAL=slate. */
+.kxp-lnbadge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: 0 var(--space-2);
+ height: 1.4rem;
+ border-radius: var(--radius-pill, var(--radius-sm));
+ font-size: var(--fs-micro, var(--fs-nano));
+ font-weight: var(--fw-bold, var(--fw-semibold));
+ letter-spacing: 0.02em;
+ white-space: nowrap;
+}
+.kxp-lnbadge--urgent {
+ background: color-mix(in srgb, var(--color-error) 14%, transparent);
+ color: var(--color-error);
+}
+.kxp-lnbadge--program {
+ background: color-mix(in srgb, var(--color-warning) 16%, transparent);
+ color: var(--color-warning);
+}
+.kxp-lnbadge--info {
+ background: color-mix(in srgb, var(--color-primary-600) 12%, transparent);
+ color: var(--color-primary-600);
+}
+.kxp-lnbadge--general {
+ background: color-mix(in srgb, var(--color-neutral-500) 14%, transparent);
+ color: var(--color-neutral-700);
+}
diff --git a/src/frontend/src/screens/public/smartticket.css b/src/frontend/src/screens/public/smartticket.css
new file mode 100644
index 0000000..2ccb845
--- /dev/null
+++ b/src/frontend/src/screens/public/smartticket.css
@@ -0,0 +1,80 @@
+/*
+ * F-B1 스마트티켓 회전 QR — 자체 스타일(kx 토큰만, 하드코딩 0). 공유 css 무수정.
+ * Nifty 정합: 카드 서브틀 그림자·중립 팔레트·라운드 토큰.
+ */
+.kx-smtk {
+ display: grid;
+ gap: var(--space-3);
+ justify-items: center;
+ padding: var(--space-4);
+ background: var(--color-white);
+ border: 1px solid var(--color-neutral-200);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-card);
+}
+
+.kx-smtk__qrwrap {
+ width: 180px;
+ height: 180px;
+ padding: var(--space-2);
+ background: var(--color-white);
+ border: 1px solid var(--color-neutral-200);
+ border-radius: var(--radius-md);
+}
+
+.kx-smtk__qr {
+ width: 100%;
+ height: 100%;
+ display: block;
+ image-rendering: pixelated;
+}
+
+.kx-smtk__meta {
+ display: grid;
+ gap: var(--space-2);
+ justify-items: center;
+ width: 100%;
+ max-width: 200px;
+}
+
+.kx-smtk__code {
+ font-family: var(--font-mono);
+ font-size: var(--fs-h3);
+ font-weight: var(--fw-bold);
+ letter-spacing: 0.14em;
+ color: var(--color-primary-700);
+}
+
+.kx-smtk__count {
+ width: 100%;
+ height: 6px;
+ background: var(--color-neutral-100);
+ border-radius: var(--radius-pill);
+ overflow: hidden;
+}
+
+.kx-smtk__count-bar {
+ display: block;
+ height: 100%;
+ background: var(--color-primary-600);
+ border-radius: var(--radius-pill);
+ transition: width 1s linear;
+}
+
+.kx-smtk__hint {
+ font-size: var(--fs-nano);
+ color: var(--color-text-muted);
+ text-align: center;
+}
+
+.kx-smtk__loading,
+.kx-smtk__err {
+ font-size: var(--fs-caption);
+ color: var(--color-text-muted);
+ text-align: center;
+ padding: var(--space-4);
+}
+
+.kx-smtk__err {
+ color: var(--color-error);
+}
diff --git a/src/frontend/src/screens/public/ticketApi.ts b/src/frontend/src/screens/public/ticketApi.ts
index 6e8a881..b276d9d 100644
--- a/src/frontend/src/screens/public/ticketApi.ts
+++ b/src/frontend/src/screens/public/ticketApi.ts
@@ -75,6 +75,51 @@ export interface TicketLookupResult {
tickets: IssuedTicket[];
}
+// ── F-B2 취소·환불 다구간(서버 권위) ──
+export interface RefundBracket {
+ minDaysBefore: number;
+ refundRatePercent: number;
+ label: string;
+}
+export interface RefundQuote {
+ orderNo: string;
+ status: string; // PAID/CANCELLED
+ productName: string;
+ qty: number;
+ ticketAmount: number;
+ bookingFee: number;
+ daysBefore: number;
+ refundRatePercent: number;
+ bracketLabel: string;
+ ticketRefund: number;
+ adminFee: number;
+ bookingFeeRefund: number;
+ refundAmount: number;
+ forfeitAmount: number;
+ policyVersion: string;
+ cancellable: boolean;
+ brackets: RefundBracket[];
+}
+
+// ── F-B1 스마트티켓(회전 QR) ──
+export interface SmartActivateResult {
+ ticketCode: string;
+ activated: boolean;
+ windowSeconds: number;
+}
+export interface RotatingToken {
+ ticketCode: string;
+ token: string;
+ qrPayload: string; // ticketCode|token — QR 인코딩 대상
+ windowSeconds: number;
+ remainingSeconds: number;
+}
+export interface SmartVerifyResult {
+ valid: boolean;
+ ticketCode: string | null;
+ reason: string;
+}
+
const opt = { anonymous: true } as const;
export const ticketApi = {
@@ -96,4 +141,30 @@ export const ticketApi = {
`/api/public/tickets/lookup?orderNo=${encodeURIComponent(orderNo)}&contact=${encodeURIComponent(contact)}`,
opt,
),
+
+ // ── F-B2 취소·환불 ──
+ /** 취소 전 예상 환불 산정(조회만, 서버 권위). */
+ refundQuote: (orderNo: string, contact: string) =>
+ api.post('/api/public/tickets/refund-quote', { orderNo, contact }, opt),
+ /** 실제 취소 처리(상태 전이·재고 원복·환불 스냅샷). */
+ cancel: (orderNo: string, contact: string) =>
+ api.post('/api/public/tickets/cancel', { orderNo, contact }, opt),
+
+ // ── F-B1 스마트티켓(회전 QR) ──
+ /** 스마트티켓 활성화(디바이스 바인딩) — 예매번호+연락처 본인확인. */
+ smartActivate: (orderNo: string, contact: string, ticketCode: string, deviceId: string) =>
+ api.post(
+ '/api/public/tickets/smart/activate',
+ { orderNo, contact, ticketCode, deviceId },
+ opt,
+ ),
+ /** 현재 회전 토큰(QR 페이로드) — 바인딩 기기에서만. */
+ smartToken: (ticketCode: string, deviceId: string) =>
+ api.get(
+ `/api/public/tickets/smart/token?ticketCode=${encodeURIComponent(ticketCode)}&deviceId=${encodeURIComponent(deviceId)}`,
+ opt,
+ ),
+ /** 게이트 QR 검증(회전 토큰). */
+ smartVerify: (qrPayload: string) =>
+ api.post('/api/public/tickets/smart/verify', { qrPayload }, opt),
};
diff --git a/src/frontend/src/screens/public/ticketExtras.css b/src/frontend/src/screens/public/ticketExtras.css
new file mode 100644
index 0000000..25de4a9
--- /dev/null
+++ b/src/frontend/src/screens/public/ticketExtras.css
@@ -0,0 +1,91 @@
+/*
+ * F-B1/B2 티켓 확장 UI — 자체 스타일(kx 토큰만, 하드코딩 0). 공유 public.css 무수정.
+ * Nifty 정합: 카드형 블록·중립 팔레트·라운드/그림자 토큰.
+ */
+
+/* F-B2 취소·환불 규정 구간 */
+.kxf-refund__brackets {
+ list-style: none;
+ margin: var(--space-2) 0;
+ padding: 0;
+ display: grid;
+ gap: var(--space-1);
+}
+.kxf-refund__brackets li {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--fs-caption);
+ background: var(--color-neutral-050);
+ border: 1px solid var(--color-neutral-100);
+ border-radius: var(--radius-sm);
+ color: var(--color-neutral-700);
+}
+.kxf-refund__brackets li.is-current {
+ background: var(--color-primary-050);
+ border-color: var(--color-primary-300);
+ color: var(--color-primary-800);
+ font-weight: var(--fw-semibold);
+}
+.kxf-refund__calc {
+ margin-top: var(--space-2);
+ display: grid;
+ gap: var(--space-1);
+}
+.kxf-refund__total {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ margin-top: var(--space-2);
+ padding-top: var(--space-2);
+ border-top: 1px solid var(--color-neutral-200);
+}
+.kxf-refund__total strong {
+ font-size: var(--fs-h3);
+ font-weight: var(--fw-bold);
+ color: var(--color-primary-700);
+}
+.kxf-refund__forfeit {
+ color: var(--color-text-muted);
+ font-size: var(--fs-caption);
+}
+
+/* F-B1 스마트티켓 섹션 */
+.kxf-smartsec {
+ margin-top: var(--space-4);
+ padding-top: var(--space-4);
+ border-top: 1px dashed var(--color-neutral-200);
+}
+.kxf-smartsec__title {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--fs-h3);
+ font-weight: var(--fw-semibold);
+ color: var(--color-neutral-900);
+ margin: 0 0 var(--space-1);
+}
+.kxf-smartsec__desc {
+ font-size: var(--fs-caption);
+ color: var(--color-text-muted);
+ margin: 0 0 var(--space-3);
+}
+.kxf-smartsec__list {
+ display: grid;
+ gap: var(--space-3);
+}
+.kxf-smartsec__item {
+ display: grid;
+ gap: var(--space-2);
+ justify-items: center;
+ padding: var(--space-3);
+ background: var(--color-neutral-050);
+ border: 1px solid var(--color-neutral-100);
+ border-radius: var(--radius-md);
+}
+.kxf-smartsec__code {
+ font-family: var(--font-mono);
+ font-size: var(--fs-caption);
+ color: var(--color-neutral-600);
+}
diff --git a/src/frontend/src/screens/public/usePublicMotion.ts b/src/frontend/src/screens/public/usePublicMotion.ts
index 84cdb4a..af6ed15 100644
--- a/src/frontend/src/screens/public/usePublicMotion.ts
+++ b/src/frontend/src/screens/public/usePublicMotion.ts
@@ -205,6 +205,19 @@ export function usePublicMotion(rootRef: RefObject): void {
slides[0].classList.add('is-active');
}
+ // 히어로 컨테이너 + 인디케이터 도트(전경, [data-hero-dots]) — 회전과 인덱스 동기화.
+ const hero =
+ (box.closest('.pub-hero, .kxp-hero, .kxp-vhero') as HTMLElement | null) ??
+ (box.closest('section') as HTMLElement | null) ??
+ box;
+ const dotEls = Array.from(
+ hero.querySelectorAll('[data-hero-dots] [data-dot]'),
+ );
+ const syncDots = (i: number) => {
+ dotEls.forEach((d, k) => d.classList.toggle('is-active', k === i));
+ };
+ if (dotEls.length) syncDots(idx);
+
let hovered = false;
let offscreen = false;
const isPaused = () => hovered || offscreen || document.hidden;
@@ -223,13 +236,10 @@ export function usePublicMotion(rootRef: RefObject): void {
slides[idx].classList.remove('is-active');
slides[nx].classList.add('is-active');
idx = nx;
+ if (dotEls.length) syncDots(nx);
};
const timer = window.setInterval(tick, interval);
- const hero =
- (box.closest('.pub-hero, .kxp-hero, .kxp-vhero') as HTMLElement | null) ??
- (box.closest('section') as HTMLElement | null) ??
- box;
const onEnter = () => {
hovered = true;
};
diff --git a/src/frontend/src/screens/schedule/schedule.css b/src/frontend/src/screens/schedule/schedule.css
index b800b5f..11534b0 100644
--- a/src/frontend/src/screens/schedule/schedule.css
+++ b/src/frontend/src/screens/schedule/schedule.css
@@ -33,17 +33,16 @@
.kx-sched__split {
display: grid;
- /* 소유자 피드백①: 어중간한 2:1 → 균형 50:50. 달력 7열이 반폭에서 답답하지 않게 좌측 최소폭 확보. */
- grid-template-columns: minmax(0, 1fr) 1fr;
+ /* 메인(목록/월캘린더)은 가변, 우측 aside 는 WISE/Nifty 사이드바 규격(280~340px)으로 고정.
+ · 우측 미니캘린더가 반폭까지 늘어나 과대·깨져 보이던 문제 해소(mini 캘린더는 컴팩트 유지).
+ · 두 트랙 모두 min 0 → 좁아질 때 내용이 트랙을 넘어 잘리던(짤림) 그리드 오버플로 차단. */
+ grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
gap: var(--space-4);
- /* 피드백③: 달력이 컬럼 높이를 채우도록 stretch(달력 아래 대형 빈 공간 제거). aside는 자연 높이. */
+ /* 달력이 컬럼 높이를 채우도록 stretch(달력 아래 대형 빈 공간 제거). aside 는 자연 높이. */
align-items: stretch;
flex: 1 1 auto;
min-height: 0;
}
-.kx-sched__aside {
- align-self: start;
-}
.kx-sched__list {
display: flex;
flex-direction: column;
@@ -58,9 +57,12 @@
border-radius: var(--radius-sm);
}
.kx-sched__aside {
+ align-self: start;
display: flex;
flex-direction: column;
gap: var(--space-4);
+ /* 그리드 자식 최소폭 0 — 우측 달력·목록이 트랙을 넘어 잘리지 않게. */
+ min-width: 0;
}
/* 행사 카드 */
@@ -143,11 +145,11 @@
white-space: nowrap;
}
.kx-evstatus.is-ongoing {
- background: #e6f4ee;
+ background: var(--color-success-bg);
color: var(--color-success);
}
.kx-evstatus.is-upcoming {
- background: #fff4e5;
+ background: var(--color-warning-bg);
color: var(--color-warning);
}
.kx-evstatus.is-ended {
@@ -333,11 +335,11 @@
color: #b83268;
}
.kx-mcal__chip.is-trade {
- background: #fff4e5;
+ background: var(--color-warning-bg);
color: #9a6b1f;
}
.kx-mcal__chip.is-tech {
- background: #e6f4ee;
+ background: var(--color-success-bg);
color: #1f7a54;
}
.kx-mcal__more {
diff --git a/src/frontend/src/screens/settlement/SettlementDashboardPage.tsx b/src/frontend/src/screens/settlement/SettlementDashboardPage.tsx
index 7478ee1..83e9418 100644
--- a/src/frontend/src/screens/settlement/SettlementDashboardPage.tsx
+++ b/src/frontend/src/screens/settlement/SettlementDashboardPage.tsx
@@ -11,6 +11,8 @@ import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import { useAuthStore } from '../../store/authStore';
import { settlementApi } from './settlementApi';
import type { InvoiceCategory, InvoiceStatus, ReportGroup, StatusBucket } from './settlementApi';
+import { GridPdfButton } from '../../components/GridPdfButton';
+import '../../components/data-table.css';
import './settlement.css';
/*
@@ -87,6 +89,26 @@ export function SettlementDashboardPage() {
const list = listQ.data;
const totalPages = list ? Math.max(1, Math.ceil(list.total / PAGE_SIZE)) : 1;
+ const invoicePdfColumns = [
+ { key: 'no', label: t('settlement.colNo') },
+ { key: 'exhibitor', label: t('settlement.colExhibitor') },
+ { key: 'billed', label: t('settlement.colBilled'), align: 'right' as const },
+ { key: 'paid', label: t('settlement.colPaid'), align: 'right' as const },
+ { key: 'outstanding', label: t('settlement.colOutstanding'), align: 'right' as const },
+ { key: 'due', label: t('settlement.colDue') },
+ { key: 'status', label: t('settlement.colStatus') },
+ ];
+ const invoicePdfRows = () =>
+ (list?.items ?? []).map((inv) => ({
+ no: inv.invoiceNo,
+ exhibitor: inv.exhibitorName,
+ billed: formatWon(inv.total),
+ paid: formatWon(inv.paidAmount),
+ outstanding: formatWon(inv.outstanding),
+ due: inv.dueDate ?? '-',
+ status: statusLabel(t, inv.status),
+ }));
+
return (
+
{listQ.isLoading ? (
diff --git a/src/frontend/src/screens/settlement/settlement.css b/src/frontend/src/screens/settlement/settlement.css
index 91c563a..be17650 100644
--- a/src/frontend/src/screens/settlement/settlement.css
+++ b/src/frontend/src/screens/settlement/settlement.css
@@ -47,7 +47,7 @@
font-size: 13px;
}
.kx-settle__notice--error {
- background: #fef3f2;
+ background: var(--color-error-bg);
border-color: #fecdca;
color: var(--color-error);
}
@@ -183,7 +183,7 @@
color: var(--color-success);
}
.kx-settle__pill--error {
- background: #fef3f2;
+ background: var(--color-error-bg);
color: var(--color-error);
}
@@ -357,7 +357,7 @@
white-space: nowrap;
}
.kx-settle__cattag--rental {
- background: var(--color-neutral-100, #f2f4f7);
+ background: var(--color-neutral-100, var(--color-neutral-100));
color: var(--color-neutral-600, #475467);
}
.kx-settle__cattag--auction_fee {
@@ -369,7 +369,7 @@
.kx-settle__tax {
margin-top: 16px;
padding-top: 12px;
- border-top: 1px solid var(--color-neutral-200, #e4e7ec);
+ border-top: 1px solid var(--color-neutral-200, var(--color-neutral-200));
display: grid;
gap: 8px;
}
@@ -382,9 +382,9 @@
display: grid;
gap: 4px;
padding: 10px 12px;
- border: 1px solid var(--color-neutral-200, #e4e7ec);
+ border: 1px solid var(--color-neutral-200, var(--color-neutral-200));
border-radius: 8px;
- background: var(--color-neutral-50, #f9fafb);
+ background: var(--color-neutral-50, var(--color-neutral-050));
}
.kx-settle__tax-row {
display: flex;
diff --git a/src/frontend/src/screens/styleguide/styleguide.css b/src/frontend/src/screens/styleguide/styleguide.css
index 695549c..525237f 100644
--- a/src/frontend/src/screens/styleguide/styleguide.css
+++ b/src/frontend/src/screens/styleguide/styleguide.css
@@ -146,7 +146,7 @@
font-size: var(--fs-caption);
font-weight: var(--fw-semibold);
color: var(--color-error);
- background: #fef3f2;
+ background: var(--color-error-bg);
border-radius: var(--radius-sm);
padding: 4px 10px;
}
@@ -251,7 +251,7 @@
margin-bottom: var(--space-3);
}
.kx-sg__alert {
- background: #fef3f2;
+ background: var(--color-error-bg);
color: var(--color-error);
font-size: var(--fs-body);
font-weight: var(--fw-semibold);
diff --git a/src/frontend/src/screens/utility/UtilityOrderSummaryPage.tsx b/src/frontend/src/screens/utility/UtilityOrderSummaryPage.tsx
index c72c2c3..d405bd5 100644
--- a/src/frontend/src/screens/utility/UtilityOrderSummaryPage.tsx
+++ b/src/frontend/src/screens/utility/UtilityOrderSummaryPage.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
+import { IconCheck } from '../../components/ui/icons';
import { useParams } from 'react-router-dom';
import { utilityApi } from '../../api/endpoints';
import { Button } from '../../components/ui/Button';
@@ -87,7 +88,7 @@ export function UtilityOrderSummaryPage() {