feat(public,settlement): coex-style public visuals + Nifty calendar + local poster assets + e-2026-live close-loop demo (V46,V47)

- public: motion hooks (usePublicMotion), gateway/track/shell visual refresh, stat-band white->on-accent contrast fix
- schedule: Nifty calendar restyle (neutral cell borders, today warning-bg)
- settlement: card padding fix
- i18n: +public visual keys (ko/en/zh/ja)
- backend: PublicEventDto.posterUrl (additive, non-breaking) via PublicSiteMapper/Service
- db: V46 rewires event.poster_url to local /media/posters assets (34 imgs); V47 seeds e-2026-live close-loop (invoice/payment/tax/refund, auction/bid/award, visitor/lead, edm/sponsorship)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-12 20:59:59 +09:00
parent 55e27b3309
commit 3077cb04f5
54 changed files with 1009 additions and 73 deletions

View File

@ -25,6 +25,7 @@ public interface PublicSiteMapper {
to_char(e.end_date, 'YYYY-MM-DD') AS "endDate",
e.status,
h.label AS "hallLabel",
e.poster_url AS "posterUrl",
(SELECT count(*)::int
FROM booth b
JOIN layout l ON l.id = b.layout_id
@ -63,6 +64,7 @@ public interface PublicSiteMapper {
to_char(e.end_date, 'YYYY-MM-DD') AS "endDate",
e.status,
h.label AS "hallLabel",
e.poster_url AS "posterUrl",
(SELECT count(*)::int
FROM booth b
JOIN layout l ON l.id = b.layout_id

View File

@ -106,7 +106,8 @@ public class PublicSiteService {
private PublicEventDto toEvent(Map<String, Object> m) {
return new PublicEventDto(
str(m.get("id")), str(m.get("name")), str(m.get("startDate")), str(m.get("endDate")),
str(m.get("status")), str(m.get("hallLabel")), toInt(m.get("boothCount")));
str(m.get("status")), str(m.get("hallLabel")), toInt(m.get("boothCount")),
str(m.get("posterUrl")));
}
private String generateReceiptNo() {

View File

@ -12,6 +12,7 @@ public record PublicEventDto(
String endDate,
String status,
String hallLabel,
Integer boothCount
Integer boothCount,
String posterUrl
) {
}

View File

@ -0,0 +1,111 @@
-- V46__poster_local_assets.sql
-- 목적: event.poster_url 외부 핫링크(kintex.com imageView.do — 브라우저 안 뜸)를
-- 빌드 내장 정적자산(/media/posters/kx-<atchmnflNo>-<fileseq>.jpg)으로 교체.
-- 원천: kintex.com 공개 홍보 포스터(내부 데모용). 크롤·축소(가로≤480, jpg q78, ~16~59KB).
-- 자산: src/frontend/public/media/posters/ → Vite dist 루트 → GET /media/posters/<file>.
-- 멱등: URL 자연키 WHERE 조건 매칭. 재실행 시 이미 교체된 행은 매칭 안 됨(불변).
-- 정상/타 URL 및 미크롤 event 는 손대지 않음 → 그라디언트 폴백 유지.
-- 스코프: tenant_id='KINTEX'. V32·V39·V42 가 같은 URL 공유 시 일괄 갱신됨.
UPDATE event SET poster_url = '/media/posters/kx-468958-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=468958&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-468981-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=468981&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469060-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469060&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469082-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469082&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469112-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469112&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469129-6.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469129&fileseq=6';
UPDATE event SET poster_url = '/media/posters/kx-469133-36.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469133&fileseq=36';
UPDATE event SET poster_url = '/media/posters/kx-469147-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469147&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469162-9.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469162&fileseq=9';
UPDATE event SET poster_url = '/media/posters/kx-469163-2.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469163&fileseq=2';
UPDATE event SET poster_url = '/media/posters/kx-469174-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469174&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469177-2.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469177&fileseq=2';
UPDATE event SET poster_url = '/media/posters/kx-469185-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469185&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469186-2.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469186&fileseq=2';
UPDATE event SET poster_url = '/media/posters/kx-469201-3.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469201&fileseq=3';
UPDATE event SET poster_url = '/media/posters/kx-469204-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469204&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469205-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469205&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469207-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469207&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469219-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469219&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469220-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469220&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469230-2.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469230&fileseq=2';
UPDATE event SET poster_url = '/media/posters/kx-469236-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469236&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469240-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469240&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469244-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469244&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469246-2.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469246&fileseq=2';
UPDATE event SET poster_url = '/media/posters/kx-469247-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469247&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469249-2.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469249&fileseq=2';
UPDATE event SET poster_url = '/media/posters/kx-469254-2.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469254&fileseq=2';
UPDATE event SET poster_url = '/media/posters/kx-469256-2.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469256&fileseq=2';
UPDATE event SET poster_url = '/media/posters/kx-469262-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469262&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469266-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469266&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469276-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469276&fileseq=1';
UPDATE event SET poster_url = '/media/posters/kx-469277-2.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469277&fileseq=2';
UPDATE event SET poster_url = '/media/posters/kx-469283-1.jpg'
WHERE tenant_id = 'KINTEX'
AND poster_url = 'https://www.kintex.com/imageView.do?atchmnflNo=469283&fileseq=1';

View File

@ -0,0 +1,215 @@
-- ============================================================================
-- V47 — 데모 로그인 해소 행사(e-2026-live) 폐루프 보강 (정산·옥션·관람객/리드·마케팅)
-- ============================================================================
-- 배경(근본 원인): 데모 계정(demo-organizer/exhibitor/contractor)은
-- ① e-2026-smf (V38, 8월 개막) ② e-2026-live (V39, 오늘 개막) 두 행사의 event_member.
-- 워크스페이스 정렬 = findEventRoles ORDER BY e.start_date → 오늘 개막인 e-2026-live 가 선두.
-- 프론트 useResolvedEventId = currentEventId ?? workspaces[0].eventId → 'e-2026-live'.
-- 문제: 정산(invoice/payment/tax/refund)·옥션(auction/bid/award)·관람객(visitor)·리드(lead)·
-- 마케팅(edm/sponsorship) 데모 데이터는 전부 e-2026-smf 또는 V42 bulk(bd-e-*)에만 존재 →
-- 로그인 후 해소되는 e-2026-live 스코프에서 정산·옥션·관람객 화면이 "빈 상태"로 보였다.
-- 조치: e-2026-live 를 축으로 각 도메인 폐루프를 멱등 보강(기존 시드 불변, ON CONFLICT DO NOTHING).
-- e-2026-live 참가업체(부스 assigned_company_name/booth_sale.sold_to_company):
-- (주)스마트오토메이션·델타로보틱스·글로벌테크·미래제조연구소·한국센서테크·바이오헬스랩
-- PII 는 전부 더미(홍길동·010-0000-*·*@kintex.test). tenant_id='KINTEX'.
-- ============================================================================
-- ============================================================================
-- 1) 정산 폐루프 — 청구서(상태 분포) + 수납 + 세금계산서 + 환불
-- ============================================================================
-- invoice UNIQUE(event_id, exhibitor_name) → 행사당 참가업체명 1건(멱등). 상태 분포:
-- paid×2 · invoiced(부분수납)×2 · overdue×1 · pending×2 · cancelled(환불대상)×1
INSERT INTO invoice (id, event_id, exhibitor_id, exhibitor_name, invoice_no, category, source_ref,
subtotal, vat, total, paid_amount, status, due_date, issued_at, paid_at,
cancelled_at, cancel_reason, line_items, note, created_by) VALUES
-- 완납
('inv-live-01','e-2026-live',NULL,'(주)스마트오토메이션','LIVE-2026-0001','rental',NULL,
6534000, 653400, 7187400, 7187400, 'paid', CURRENT_DATE - 2, now()-interval '18 day', now()-interval '5 day',
NULL, NULL,
'[{"category":"RENTAL","description":"부스 임대료 (18㎡ 아일랜드 · L-101)","quantity":18,"unitPrice":363000,"amount":6534000}]'::jsonb,
'완납 처리.', 'system-seed'),
('inv-live-03','e-2026-live',NULL,'글로벌테크','LIVE-2026-0003','rental',NULL,
4356000, 435600, 4791600, 4791600, 'paid', CURRENT_DATE - 1, now()-interval '10 day', now()-interval '3 day',
NULL, NULL,
'[{"category":"RENTAL","description":"부스 임대료 (18㎡ 조립부스 · L-103)","quantity":18,"unitPrice":242000,"amount":4356000}]'::jsonb,
'완납 처리.', 'system-seed'),
-- 부분수납(발행)
('inv-live-02','e-2026-live',NULL,'델타로보틱스','LIVE-2026-0002','rental',NULL,
5445000, 544500, 5989500, 2395800, 'invoiced', CURRENT_DATE + 7, now()-interval '14 day', NULL,
NULL, NULL,
'[{"category":"RENTAL","description":"부스 임대료 (18㎡ 독립부스 · L-102)","quantity":18,"unitPrice":302500,"amount":5445000}]'::jsonb,
'계약금 40% 수납.', 'system-seed'),
('inv-live-06','e-2026-live',NULL,'바이오헬스랩','LIVE-2026-0006','rental',NULL,
4356000, 435600, 4791600, 1916640, 'invoiced', CURRENT_DATE + 10, now()-interval '6 day', NULL,
NULL, NULL,
'[{"category":"RENTAL","description":"부스 임대료 (18㎡ 조립부스 · L-108)","quantity":18,"unitPrice":242000,"amount":4356000}]'::jsonb,
'계약금 40% 수납.', 'system-seed'),
-- 연체(overdue) — 대시보드 overdueCount 활성화
('inv-live-04','e-2026-live',NULL,'미래제조연구소','LIVE-2026-0004','rental',NULL,
4791600, 479160, 5270760, 0, 'overdue', CURRENT_DATE - 5, now()-interval '20 day', NULL,
NULL, NULL,
'[{"category":"RENTAL","description":"부스 임대료 (18㎡ 코너부스 · L-104)","quantity":18,"unitPrice":266200,"amount":4791600}]'::jsonb,
'납부 기한 경과 — 독촉 대상.', 'system-seed'),
-- 발행 대기(pending)
('inv-live-05','e-2026-live',NULL,'한국센서테크','LIVE-2026-0005','rental',NULL,
4356000, 435600, 4791600, 0, 'pending', CURRENT_DATE + 14, NULL, NULL,
NULL, NULL,
'[{"category":"RENTAL","description":"부스 임대료 (18㎡ 조립부스 · L-106)","quantity":18,"unitPrice":242000,"amount":4356000}]'::jsonb,
'발행 대기.', 'system-seed'),
-- 옥션 낙찰 수수료(F084) — AUC-LIVE-01 낙찰(aw-live-01, co-0001) 5% 수수료
('inv-live-07','e-2026-live',NULL,'(주)한빛부스디자인','LIVE-2026-0007','auction_fee','aw-live-01',
210000, 21000, 231000, 0, 'pending', CURRENT_DATE + 12, NULL, NULL,
NULL, NULL,
'[{"category":"AUCTION_FEE","description":"옥션 낙찰 수수료 5% (AUC-LIVE-01 · 낙찰 4,200,000원)","quantity":1,"unitPrice":210000,"amount":210000}]'::jsonb,
'옥션 낙찰 수수료 청구(발행 대기).', 'system-seed'),
-- 취소(환불 대상) — 계약금 납입 후 취소
('inv-live-08','e-2026-live',NULL,'넥스트이노베이션','LIVE-2026-0008','rental',NULL,
6000000, 600000, 6600000, 2640000, 'cancelled', CURRENT_DATE + 9, now()-interval '16 day', NULL,
now()-interval '2 day', '참가 철회(내부 사정) — 환불 규정 적용',
'[{"category":"RENTAL","description":"부스 임대료 (18㎡ · 추가배정)","quantity":18,"unitPrice":333333,"amount":6000000}]'::jsonb,
'취소 확정 — 환불 산정 대상.', 'system-seed')
ON CONFLICT (id) DO NOTHING;
-- 수납 기록(분납·완납·취소 전 계약금) --------------------------------------------
INSERT INTO invoice_payment (id, invoice_id, amount, method, memo, recorded_by, recorded_at) VALUES
('pay-live-01-1','inv-live-01', 2874960, 'transfer', '계약금 40%', 'system-seed', now()-interval '18 day'),
('pay-live-01-2','inv-live-01', 4312440, 'transfer', '잔금 60%', 'system-seed', now()-interval '5 day'),
('pay-live-03-1','inv-live-03', 4791600, 'transfer', '일시 완납', 'system-seed', now()-interval '3 day'),
('pay-live-02-1','inv-live-02', 2395800, 'transfer', '계약금 40%', 'system-seed', now()-interval '11 day'),
('pay-live-06-1','inv-live-06', 1916640, 'transfer', '계약금 40%', 'system-seed', now()-interval '5 day'),
('pay-live-08-1','inv-live-08', 2640000, 'transfer', '계약금 40%(취소 전 납입)', 'system-seed', now()-interval '13 day')
ON CONFLICT (id) DO NOTHING;
-- 세금계산서(F083) 2건 — 완납 청구(inv-live-01·inv-live-03) 기반 ----------------------
INSERT INTO tax_invoice (tenant_id, id, invoice_id, event_id, tax_invoice_no,
supplier_name, supplier_biz_no, supplier_ceo, supplier_addr,
buyer_name, supply_amount, vat, total, item_summary, status, issued_by) VALUES
('KINTEX','tax-live-0001','inv-live-01','e-2026-live','TAX-LIVE-0001',
'킨텍스(KINTEX)','128-81-00000','대표이사','경기도 고양시 일산서구 킨텍스로 217',
'(주)스마트오토메이션', 6534000, 653400, 7187400, '부스 임대료 (18㎡ · L-101)', 'issued', 'system-seed'),
('KINTEX','tax-live-0003','inv-live-03','e-2026-live','TAX-LIVE-0003',
'킨텍스(KINTEX)','128-81-00000','대표이사','경기도 고양시 일산서구 킨텍스로 217',
'글로벌테크', 4356000, 435600, 4791600, '부스 임대료 (18㎡ · L-103)', 'issued', 'system-seed')
ON CONFLICT (id) DO NOTHING;
-- 환불(F085) 1건 — 취소 청구 inv-live-08 기준. base=실납입(2,640,000), 개막 D-일 구간 환불율 -----
INSERT INTO invoice_refund (tenant_id, id, invoice_id, event_id, base_amount, refund_rate_percent,
refund_amount, forfeit_amount, days_before_event, bracket_label,
reason, ruleset_version, status, created_by) VALUES
('KINTEX','ref-live-0008','inv-live-08','e-2026-live', 2640000, 50.00,
1320000, 1320000, 0, '개막일 이후 취소(50%)',
'참가 철회 — 환불 규정(refund-v1.0) 구간 적용', 'refund-v1.0', 'recorded', 'system-seed')
ON CONFLICT (id) DO NOTHING;
-- ============================================================================
-- 2) 옥션 폐루프 — 옥션 2(낙찰 1·진행 1) + 초대 + 응찰 + 낙찰
-- ============================================================================
INSERT INTO auction (id, event_id, title, category, auction_type, award_criteria,
weight_price, weight_reputation, weight_delivery, round, deadline,
material_package_id, materials, accent, created_by) VALUES
('AUC-LIVE-01', 'e-2026-live', 'L-101 아일랜드 부스 장치공사', '전시디자인설치', 'reverse', 'comprehensive',
60, 25, 15, 1, now() - interval '2 day', NULL, 'layout,design,boq', 'primary', 'demo-organizer'),
('AUC-LIVE-02', 'e-2026-live', '메인 통로 전기·조명 설치', '전기/조명', 'reverse', 'comprehensive',
55, 25, 20, 1, now() + interval '3 day', NULL, 'layout,boq', 'success', 'demo-organizer')
ON CONFLICT (id) DO NOTHING;
INSERT INTO auction_invite (id, auction_id, company_id) VALUES
('ai-live-01-1', 'AUC-LIVE-01', 'co-0001'),
('ai-live-01-2', 'AUC-LIVE-01', 'co-0002'),
('ai-live-01-3', 'AUC-LIVE-01', 'co-0005'),
('ai-live-02-1', 'AUC-LIVE-02', 'co-0003'),
('ai-live-02-2', 'AUC-LIVE-02', 'co-0006'),
('ai-live-02-3', 'AUC-LIVE-02', 'co-0001')
ON CONFLICT (auction_id, company_id) DO NOTHING;
-- 응찰(견적) — (auction,company) 당 1행. AUC-LIVE-01 은 마감(낙찰), AUC-LIVE-02 는 진행 중.
INSERT INTO bid (id, auction_id, company_id, round, subtotal, vat, total, lead_days) VALUES
('bid-live-01-1', 'AUC-LIVE-01', 'co-0001', 1, 4200000, 420000, 4200000, 8),
('bid-live-01-2', 'AUC-LIVE-01', 'co-0002', 1, 4550000, 455000, 4550000, 7),
('bid-live-01-3', 'AUC-LIVE-01', 'co-0005', 1, 4380000, 438000, 4380000, 10),
('bid-live-02-1', 'AUC-LIVE-02', 'co-0003', 1, 3800000, 380000, 3800000, 6),
('bid-live-02-2', 'AUC-LIVE-02', 'co-0006', 1, 3650000, 365000, 3650000, 9)
ON CONFLICT (auction_id, company_id) DO NOTHING;
-- 낙찰(award) — AUC-LIVE-01 → co-0001 최저·종합 우수(수수료 청구 inv-live-07 source_ref 연동)
INSERT INTO award (id, auction_id, bid_id, reason, awarded_by, awarded_at) VALUES
('aw-live-01', 'AUC-LIVE-01', 'bid-live-01-1',
'종합평가 1위(가격 60·평판 25·납기 15) — 최저 응찰가 및 납기 준수 이력 우수.', 'demo-organizer', now()-interval '1 day')
ON CONFLICT (auction_id) DO NOTHING;
-- ============================================================================
-- 3) 관람객·리드 폐루프 — 사전등록(체크인 분포) + 부스 리드(핫리드 포함)
-- ============================================================================
-- checkin_at 은 CURRENT_DATE 기준 상대(now()) — 재배포 시점의 '오늘' 체크인으로 집계(홈 KPI).
INSERT INTO visitor_registration
(id, event_id, name, phone, email, company, visitor_type, agree_privacy, agree_marketing,
badge_code, badge_issued, checkin_state, qr_token, checkin_at, registered_at) VALUES
('vr-live-101','e-2026-live','홍길동','010-0000-3101','livevis101@kintex.test','(주)스마트오토메이션','buyer', true,true, 'KTX-L-0101',true, 'done', 'QR-LIVE0000000000000101', now()-interval '3 hour', now()-interval '2 day'),
('vr-live-102','e-2026-live','김철수','010-0000-3102','livevis102@kintex.test','델타로보틱스', 'visitor',true,true, 'KTX-L-0102',true, 'done', 'QR-LIVE0000000000000102', now()-interval '2 hour', now()-interval '2 day'),
('vr-live-103','e-2026-live','이영희','010-0000-3103','livevis103@kintex.test','한국센서테크', 'vip', true,false,'KTX-L-0103',true, 'done', 'QR-LIVE0000000000000103', now()-interval '90 minute', now()-interval '1 day'),
('vr-live-104','e-2026-live','박민수','010-0000-3104','livevis104@kintex.test',NULL, 'visitor',true,true, 'KTX-L-0104',true, 'done', 'QR-LIVE0000000000000104', now()-interval '40 minute', now()-interval '1 day'),
('vr-live-105','e-2026-live','최지우','010-0000-3105','livevis105@kintex.test','글로벌테크', 'buyer', true,true, 'KTX-L-0105',true, 'waiting','QR-LIVE0000000000000105', NULL, now()-interval '20 hour'),
('vr-live-106','e-2026-live','정하늘','010-0000-3106','livevis106@kintex.test','미래제조연구소', 'visitor',true,false,'KTX-L-0106',false,'waiting','QR-LIVE0000000000000106', NULL, now()-interval '6 hour'),
('vr-live-107','e-2026-live','강보람','010-0000-3107','livevis107@kintex.test','바이오헬스랩', 'buyer', true,true, 'KTX-L-0107',true, 'done', 'QR-LIVE0000000000000107', now()-interval '25 minute', now()-interval '5 hour')
ON CONFLICT (id) DO NOTHING;
-- 리드(SCR-32) — 부스 스코프 + demo-exhibitor 수집. 핫리드(score>=80) 2건 포함.
INSERT INTO lead
(id, event_id, booth_id, exhibitor_id, name, phone, email, role, company, product,
interest, score, ai_reasons, activity, followup_draft, ai_generated, collected_at) VALUES
('ld-live-001','e-2026-live','booth-live-02','demo-exhibitor','홍길동','010-0000-4001','lead001@kintex.test','생산기술 팀장','(주)스마트오토메이션','스마트 물류 AMR',5,92,
'["부스 체류 20분(평균 대비 320%)","사양서 다운로드 3회","구매 결정권자 직급"]'::jsonb,
'[{"text":"AMR 시연 참관","at":"오늘 13:40"},{"text":"견적 요청 폼 제출","at":"오늘 13:55"}]'::jsonb,
'안녕하세요, 홍 팀장님. 스마트 물류 AMR 커스텀 제안서를 준비했습니다. 다음 주 미팅 가능하실까요?', true, now()-interval '4 hour'),
('ld-live-002','e-2026-live','booth-live-01','demo-exhibitor','김철수','010-0000-4002','lead002@kintex.test','구매 담당','델타로보틱스','AI 비전 검사 모듈',5,85,
'["AI 비전 데모 2회 재방문","경쟁사 대비 견적 문의","도입 시기 명확(Q4)"]'::jsonb,
'[{"text":"AI 비전 모듈 상담","at":"오늘 11:20"}]'::jsonb,
'안녕하세요, 김 님. 요청하신 AI 비전 검사 모듈 견적서와 파일럿 프로그램 안내드립니다.', true, now()-interval '6 hour'),
('ld-live-003','e-2026-live','booth-live-03','demo-exhibitor','이영희','010-0000-4003','lead003@kintex.test','기술 기획','글로벌테크','협동로봇 HR-A1',3,64,
'["협동로봇 세션 참석","브로슈어 다운로드"]'::jsonb,
'[{"text":"데모 영상 시청","at":"오늘 10:10"}]'::jsonb,
'안녕하세요, 이 님. 협동로봇 HR-A1 도입 사례집을 보내드립니다.', true, now()-interval '8 hour'),
('ld-live-004','e-2026-live','booth-live-06','demo-exhibitor','박민수','010-0000-4004','lead004@kintex.test','인프라 엔지니어','한국센서테크','클라우드 모니터링',2,43,
'["짧은 부스 체류","자료 열람 없음"]'::jsonb,
'[{"text":"부스 QR 스캔","at":"오늘 09:50"}]'::jsonb,
'안녕하세요, 박 님. 클라우드 모니터링 소개 자료를 보내드립니다.', true, now()-interval '9 hour'),
('ld-live-005','e-2026-live','booth-live-02','demo-exhibitor','최지우','010-0000-4005','lead005@kintex.test','설비 담당','미래제조연구소','스마트팩토리 MES',4,71,
'["MES 상담 15분","도입 예산 문의"]'::jsonb,
'[{"text":"MES 상담","at":"오늘 12:05"}]'::jsonb,
'안녕하세요, 최 님. 스마트팩토리 MES 데모 환경 안내드립니다.', true, now()-interval '5 hour'),
('ld-live-006','e-2026-live','booth-live-01','demo-exhibitor','정하늘','010-0000-4006','lead006@kintex.test','R&D','바이오헬스랩','바이오 계측 센서',3,58,
'["센서 데모 관람","카탈로그 열람"]'::jsonb,
'[{"text":"센서 데모","at":"오늘 14:20"}]'::jsonb,
'안녕하세요, 정 님. 바이오 계측 센서 카탈로그와 견적 가이드를 보내드립니다.', true, now()-interval '2 hour')
ON CONFLICT (id) DO NOTHING;
-- ============================================================================
-- 4) 마케팅 폐루프 — EDM 캠페인 + 스폰서십 패키지/스폰서
-- ============================================================================
INSERT INTO edm_campaign (id, event_id, name, status, audience, meta, open_rate, click_rate) VALUES
('cp-live-001','e-2026-live','스마트 물류·자동화 코리아 개막 안내','done', 5400, '발송 완료: 1일 전', '48.2%', '12.6%'),
('cp-live-002','e-2026-live','VIP 바이어 상담회 초대','scheduled', 1200, '발송 예정: 오늘 18:00', '-', '-'),
('cp-live-003','e-2026-live','현장 등록 관람객 만족도 설문','draft', 3100, '최종 수정: 2시간 전', '-', '-')
ON CONFLICT (id) DO NOTHING;
INSERT INTO sponsorship_package (id, event_id, code, name, name_ko, price, accent, benefits, total_qty, sold_qty, dday, sort_order) VALUES
('sp-live-dia','e-2026-live','diamond','DIAMOND','다이아몬드', 30000000, 'primary',
'["메인 배너 노출","기조연설 세션","VIP 라운지 부스","공식 도록 전면 광고"]'::jsonb, 2, 1, 3, 0),
('sp-live-gld','e-2026-live','gold','GOLD','골드', 15000000, 'warning',
'["로비 배너","세션 슬롯 1","도록 반면 광고"]'::jsonb, 4, 2, 3, 1),
('sp-live-slv','e-2026-live','silver','SILVER','실버', 7000000, 'muted',
'["디지털 사이니지 노출","도록 로고 게재"]'::jsonb, 6, 3, 3, 2)
ON CONFLICT (id) DO NOTHING;
INSERT INTO sponsorship_sponsor (id, event_id, package_id, name, tier_label, contract_status, fulfillment) VALUES
('ss-live-001','e-2026-live','sp-live-dia','(주)스마트오토메이션','DIAMOND','signed',
'[{"item":"메인 배너","done":true},{"item":"기조연설","done":false}]'::jsonb),
('ss-live-002','e-2026-live','sp-live-gld','델타로보틱스','GOLD','signed',
'[{"item":"로비 배너","done":true}]'::jsonb),
('ss-live-003','e-2026-live','sp-live-slv','한국센서테크','SILVER','pending',
'[]'::jsonb)
ON CONFLICT (id) DO NOTHING;

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -2366,6 +2366,24 @@
"eventsCount": "{{count}} events",
"prevMonth": "Previous month",
"nextMonth": "Next month"
},
"stats": {
"aria": "KINTEX at a glance",
"exhibitions": {
"label": "Total exhibitions",
"countTo": "1200",
"suffix": "+"
},
"exhibitors": {
"label": "Exhibitors",
"countTo": "30000",
"suffix": "+"
},
"visitors": {
"label": "Annual visitors",
"countTo": "5",
"suffix": "M+"
}
}
},
"business": {

View File

@ -2366,6 +2366,24 @@
"eventsCount": "{{count}}件",
"prevMonth": "前の月",
"nextMonth": "次の月"
},
"stats": {
"aria": "KINTEXの規模",
"exhibitions": {
"label": "累計展示会",
"countTo": "1200",
"suffix": "+"
},
"exhibitors": {
"label": "出展企業",
"countTo": "30000",
"suffix": "+"
},
"visitors": {
"label": "年間来場者",
"countTo": "500",
"suffix": "万+"
}
}
},
"business": {

View File

@ -2366,6 +2366,24 @@
"eventsCount": "{{count}}건",
"prevMonth": "이전 달",
"nextMonth": "다음 달"
},
"stats": {
"aria": "킨텍스 전시 규모",
"exhibitions": {
"label": "누적 전시",
"countTo": "1200",
"suffix": "+"
},
"exhibitors": {
"label": "참가업체",
"countTo": "30000",
"suffix": "+"
},
"visitors": {
"label": "연 관람객",
"countTo": "500",
"suffix": "만+"
}
}
},
"business": {

View File

@ -2366,6 +2366,24 @@
"eventsCount": "{{count}} 场",
"prevMonth": "上个月",
"nextMonth": "下个月"
},
"stats": {
"aria": "KINTEX 规模",
"exhibitions": {
"label": "累计展会",
"countTo": "1200",
"suffix": "+"
},
"exhibitors": {
"label": "参展企业",
"countTo": "30000",
"suffix": "+"
},
"visitors": {
"label": "年度观众",
"countTo": "500",
"suffix": "万+"
}
}
},
"business": {

View File

@ -32,17 +32,17 @@ export function PublicGatewayPage() {
return (
<PublicShell active="events" cta={t('track.gateway.ctaPreReg')}>
{/* 제품 히어로 */}
<section className="kxp-hero" aria-label={t('track.gateway.heroAria')}>
{/* 제품 히어로 — 배경 실사진은 후속(별도). 현재 V-4 그라디언트+패턴 폴백(CLS 0). */}
<section className="kxp-hero pub-hero" aria-label={t('track.gateway.heroAria')}>
<div
className="kxp-hero__bg"
style={{ background: 'linear-gradient(135deg, #12233b 0%, #1f3a5f 55%, #0066b3 130%)' }}
className="kxp-hero__bg pub-fallback-gradient pub-pattern-grid"
data-parallax="0.18"
aria-hidden
/>
<div className="kxp-hero__scrim" aria-hidden />
<span className="kxp-aiwm">{t('track.gateway.aiWatermark')}</span>
<div className="kxp-hero__inner">
<div className="kxp-hero__content">
<div className="kxp-hero__content pub-hero__fg">
<span className="kxp-hero__eyebrow">
<IconSparkles width={16} height={16} /> {t('track.gateway.eyebrow')}
</span>
@ -63,18 +63,19 @@ export function PublicGatewayPage() {
{/* 3-트랙 대형 분기 카드 */}
<section className="kxp-section" id="tracks" aria-label={t('track.gateway.tracksAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div className="kxp-section__head pub-reveal">
<div>
<h2 className="kxp-section__title">{t('track.gateway.tracksTitle')}</h2>
<p className="kxp-section__sub">{t('track.gateway.tracksSub')}</p>
</div>
</div>
<div className="kxp-trackcards">
{TRACK_CARDS.map(({ key, icon: Icon, to, primary }) => (
<div className="kxp-trackcards pub-reveal pub-reveal--stagger">
{TRACK_CARDS.map(({ key, icon: Icon, to, primary }, i) => (
<article
key={key}
className={`kxp-trackcard${primary ? ' kxp-trackcard--primary' : ''}`}
className={`kxp-trackcard pub-card--lift${primary ? ' kxp-trackcard--primary' : ''}`}
style={{ '--i': i } as React.CSSProperties}
>
<span className="kxp-trackcard__ic" aria-hidden>
<Icon width={26} height={26} />
@ -94,7 +95,7 @@ export function PublicGatewayPage() {
{/* 행사 하이라이트 레일 */}
<section className="kxp-section kxp-section--alt" id="events" aria-label={t('track.gateway.eventsAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div className="kxp-section__head pub-reveal">
<div>
<h2 className="kxp-section__title">{t('track.gateway.eventsTitle')}</h2>
<p className="kxp-section__sub">{t('track.gateway.eventsSub')}</p>
@ -103,7 +104,7 @@ export function PublicGatewayPage() {
{t('track.gateway.eventsMore')} <IconArrowRight width={16} height={16} />
</Link>
</div>
<EventGrid state={events} />
<EventGrid state={events} variant="rail" />
</div>
</section>
</PublicShell>

View File

@ -8,6 +8,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { IconMenu, IconClose, IconSearch } from './publicIcons';
import { LanguageSwitcher } from '../../components/ui/LanguageSwitcher';
import { useAuthStore } from '../../store/authStore';
import { usePublicMotion } from './usePublicMotion';
import './public.css';
const GNB = [
@ -126,9 +127,12 @@ export function PublicShell({
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const ctaLabel = cta ?? t('shell.cta.register');
// §3C-V 비주얼·모션 레이어 — 공개 트랙(.kxp) 하위에만 스코프 주입.
const rootRef = useRef<HTMLDivElement>(null);
usePublicMotion(rootRef);
return (
<div className="kxp">
<div className="kxp" ref={rootRef}>
<TrackSwitcher track={track} />
<header className="kxp-header">
<div className="kxp-header__inner">

View File

@ -71,16 +71,26 @@ const JOURNEY: { key: string; icon: Ico; to: string }[] = [
{ key: 'checkin', icon: IconCheckCircle, to: '/login' },
];
function LinkOrAnchor({ to, className, children }: { to: string; className: string; children: React.ReactNode }) {
function LinkOrAnchor({
to,
className,
children,
style,
}: {
to: string;
className: string;
children: React.ReactNode;
style?: React.CSSProperties;
}) {
if (to.startsWith('#')) {
return (
<a className={className} href={to}>
<a className={className} href={to} style={style}>
{children}
</a>
);
}
return (
<Link className={className} to={to}>
<Link className={className} to={to} style={style}>
{children}
</Link>
);
@ -139,13 +149,14 @@ export function VisitorTrackPage() {
return (
<PublicShell active="visit" track="visitor" cta={t('track.visitor.ctaPreReg')}>
{/* ── 1. 풀블리드 히어로 + AI 관람 도우미(중심) ── STITCH-REPLACE:hero ── */}
<section className="kxp-vhero" id="hero" aria-label={t('track.visitor.heroAria')}>
<div className="kxp-vhero__bg" aria-hidden />
{/* 히어로 배경 실사진은 후속(별도) — 현재 V-4 그라디언트+패턴 폴백 렌더(CLS 0). */}
<section className="kxp-vhero pub-hero" id="hero" aria-label={t('track.visitor.heroAria')}>
<div className="kxp-vhero__bg pub-pattern-dots" data-parallax="0.18" aria-hidden />
<div className="kxp-vhero__scrim" aria-hidden />
<span className="kxp-vhero__wm" aria-hidden>
{t('track.visitor.aiWatermark')}
</span>
<div className="kxp-vhero__inner">
<div className="kxp-vhero__inner pub-hero__fg">
<h1 className="kxp-vhero__title">{t('track.visitor.hero.title')}</h1>
<p className="kxp-vhero__lead">{t('track.visitor.hero.subtitle')}</p>
@ -162,18 +173,38 @@ export function VisitorTrackPage() {
</div>
</section>
{/* ── 1b. 통계 밴드(카운트업) ── */}
<section className="pub-statsband" aria-label={t('track.visitor.stats.aria')}>
<div className="kxp-wrap">
<div className="pub-statsband__grid pub-reveal pub-reveal--stagger">
{(['exhibitions', 'exhibitors', 'visitors'] as const).map((k, i) => (
<div key={k} className="pub-stat" style={{ '--i': i } as React.CSSProperties}>
<span
className="pub-stat__num pub-countup"
data-count-to={t(`track.visitor.stats.${k}.countTo`)}
data-count-suffix={t(`track.visitor.stats.${k}.suffix`)}
>
0
</span>
<span className="pub-stat__label">{t(`track.visitor.stats.${k}.label`)}</span>
</div>
))}
</div>
</div>
</section>
{/* ── 2. AI 기능 쇼케이스 타일 ── STITCH-REPLACE:tiles ── */}
<section className="kxp-section" id="ai-tiles" aria-label={t('track.visitor.aiTiles.title')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div className="kxp-section__head pub-reveal">
<div>
<h2 className="kxp-section__title">{t('track.visitor.aiTiles.title')}</h2>
<p className="kxp-section__sub">{t('track.visitor.aiTiles.sub')}</p>
</div>
</div>
<div className="kxp-aitiles">
{AI_TILES.map(({ key, icon: Icon, to, soon }) => (
<LinkOrAnchor key={key} to={to} className="kxp-aitile">
<div className="kxp-aitiles pub-reveal pub-reveal--stagger">
{AI_TILES.map(({ key, icon: Icon, to, soon }, i) => (
<LinkOrAnchor key={key} to={to} className="kxp-aitile" style={{ '--i': i } as React.CSSProperties}>
<span className="kxp-aitile__top">
<AiLabel>{t('ai.assistant.label')}</AiLabel>
{soon && <span className="kxp-aitile__soon">{t('track.visitor.aiTiles.soon')}</span>}
@ -198,7 +229,7 @@ export function VisitorTrackPage() {
{/* ── 4. 진행 중·예정 전시 쇼케이스 ── */}
<section className="kxp-section" id="showcase" aria-label={t('track.visitor.eventsAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div className="kxp-section__head pub-reveal">
<div>
<h2 className="kxp-section__title">{t('track.visitor.showcaseTitle')}</h2>
<p className="kxp-section__sub">{t('track.visitor.showcaseSub')}</p>
@ -211,7 +242,7 @@ export function VisitorTrackPage() {
{/* ── 5. 관심사 맞춤 추천 레일(AI) ── */}
<section className="kxp-section kxp-section--alt" id="recommend" aria-label={t('track.visitor.recommendTitle')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div className="kxp-section__head pub-reveal">
<div>
<h2 className="kxp-section__title">
{t('track.visitor.recommendTitle')}{' '}
@ -242,7 +273,7 @@ export function VisitorTrackPage() {
{/* ── 6. 방문 가이드 허브(2×3, 라이브 우선·정적 폴백) ── */}
<section className="kxp-section" id="guide" aria-label={t('track.visitor.guideAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div className="kxp-section__head pub-reveal">
<div>
<h2 className="kxp-section__title">{t('track.visitor.guideTitle')}</h2>
<p className="kxp-section__sub">{t('track.visitor.guideSub')}</p>
@ -268,7 +299,7 @@ export function VisitorTrackPage() {
</div>
)}
<div className="kxp-guidegrid">
<div className="kxp-guidegrid pub-reveal pub-reveal--stagger">
{useLiveGuides
? guides.slice(0, 6).map((g) => {
const Icon = guideIcon(g.category, g.title);
@ -310,15 +341,15 @@ export function VisitorTrackPage() {
{/* ── 7. 관람객 여정 3-스텝 ── */}
<section className="kxp-section kxp-section--alt" id="journey" aria-label={t('track.visitor.journeyTitle')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div className="kxp-section__head pub-reveal">
<div>
<h2 className="kxp-section__title">{t('track.visitor.journeyTitle')}</h2>
<p className="kxp-section__sub">{t('track.visitor.journeySub')}</p>
</div>
</div>
<ol className="kxp-journey">
<ol className="kxp-journey pub-reveal pub-reveal--stagger">
{JOURNEY.map(({ key, icon: Icon, to }, i) => (
<li key={key} className="kxp-journey__step">
<li key={key} className="kxp-journey__step" style={{ '--i': i } as React.CSSProperties}>
<span className="kxp-journey__num" aria-hidden>
{i + 1}
</span>
@ -346,7 +377,7 @@ export function VisitorTrackPage() {
{/* ── 8. 공지·뉴스 ── */}
<section className="kxp-section" id="notices" aria-label={t('track.visitor.noticeAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div className="kxp-section__head pub-reveal">
<div>
<h2 className="kxp-section__title">{t('track.visitor.noticeTitle')}</h2>
<p className="kxp-section__sub">{t('track.visitor.noticeSub')}</p>
@ -382,7 +413,7 @@ export function VisitorTrackPage() {
{/* ── 9. 전시장 소개 밴드 ── */}
<section className="kxp-section kxp-section--alt" id="venue" aria-label={t('track.visitor.venueTitle')}>
<div className="kxp-wrap">
<div className="kxp-venue">
<div className="kxp-venue pub-reveal">
<div className="kxp-venue__intro">
<span className="kxp-venue__ic" aria-hidden>
<IconVenue width={28} height={28} />

View File

@ -7,6 +7,15 @@
.kxp {
--kxp-maxw: 1200px;
--kxp-hero-scrim: linear-gradient(90deg, rgba(16, 24, 40, 0.72) 0%, rgba(16, 24, 40, 0.15) 100%);
/* §3C-V V-2 모션 토큰(공개 트랙 루트 스코프 — 신규 색 토큰 없음) */
--motion-fast: 150ms;
--motion-base: 240ms;
--motion-slow: 400ms;
--motion-hero: 600ms;
--motion-countup: 1200ms;
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
font-family: var(--font-sans);
color: var(--color-neutral-700);
background: var(--color-white);
@ -4001,3 +4010,224 @@ a.kxp-ai__cite:hover {
.kxp-tabnum {
font-variant-numeric: tabular-nums;
}
/*
§3C-V 공개 사이트 비주얼·모션 레이어 (V-7 프론트 계약)
transform/opacity·scrollLeft만 애니메이트 · prefers-reduced-motion 전면 존중
*/
/* ── ① 스크롤 리빌 ────────────────────────────────────────────────────────── */
.pub-reveal {
opacity: 0;
transform: translateY(22px);
transition:
opacity var(--motion-slow) var(--ease-emphasized),
transform var(--motion-slow) var(--ease-emphasized);
will-change: opacity, transform;
}
.pub-reveal.is-visible {
opacity: 1;
transform: none;
will-change: auto;
}
/* 스태거 — 자식 순차 지연(--i 인덱스 * 70ms) */
.pub-reveal--stagger > * {
opacity: 0;
transform: translateY(18px);
transition:
opacity var(--motion-slow) var(--ease-emphasized),
transform var(--motion-slow) var(--ease-emphasized);
transition-delay: calc(var(--i, 0) * 70ms);
}
.pub-reveal--stagger.is-visible > * {
opacity: 1;
transform: none;
}
/* ── ② 통계 밴드 + 카운트업 ─────────────────────────────────────────────────── */
.pub-statsband {
padding: 40px 0;
background:
radial-gradient(900px 400px at 85% -30%, rgba(109, 74, 255, 0.14) 0%, transparent 60%),
linear-gradient(135deg, var(--color-primary-700) 0%, var(--color-primary-600) 100%);
color: var(--color-on-accent);
}
.pub-statsband__grid {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
text-align: center;
}
@media (min-width: 640px) {
.pub-statsband__grid {
grid-template-columns: repeat(3, 1fr);
}
}
.pub-stat {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 12px;
}
.pub-stat__num {
font-size: clamp(30px, 5vw, 46px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
color: var(--color-on-accent);
}
.pub-stat__label {
font-size: var(--fs-body);
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
}
@media (min-width: 640px) {
.pub-stat + .pub-stat {
border-left: 1px solid rgba(255, 255, 255, 0.18);
}
}
/* ── ③ 시네마틱 히어로 (파랄랙스 레이어·인트로) ─────────────────────────────── */
.pub-hero {
position: relative;
}
/* 파랄랙스 배경 레이어는 상하로 확장해 슬라이드 시 여백 노출 방지(CLS 0) */
.kxp-vhero__bg[data-parallax],
.kxp-hero__bg[data-parallax] {
top: -9%;
bottom: -9%;
will-change: transform;
}
/* 전경(콘텐츠) 인트로 리빌 — 마운트 .is-in */
.pub-hero__fg {
opacity: 0;
transform: translateY(16px);
transition:
opacity var(--motion-hero) var(--ease-emphasized),
transform var(--motion-hero) var(--ease-emphasized);
}
.pub-hero.is-in .pub-hero__fg {
opacity: 1;
transform: none;
}
/* ── ④ 포스터 카드 리프트 + 이미지 줌 ──────────────────────────────────────── */
.pub-card--lift {
position: relative;
transition: transform var(--motion-base) var(--ease-standard);
}
.pub-card--lift::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: var(--shadow-level2);
opacity: 0;
transition: opacity var(--motion-base) var(--ease-standard);
pointer-events: none;
z-index: -1;
}
.pub-card--lift:hover {
transform: translateY(-4px);
}
.pub-card--lift:hover::after,
.pub-card--lift:focus-within::after {
opacity: 1;
}
.pub-card__media {
overflow: hidden;
}
.pub-card__media img {
transition: transform var(--motion-base) var(--ease-standard);
}
.pub-card--lift:hover .pub-card__media img {
transform: scale(1.04);
}
/* ── ⑤ 오토스크롤 레일(가로) ──────────────────────────────────────────────── */
.kxp-eventgrid--rail {
display: flex;
grid-template-columns: none;
gap: 20px;
overflow-x: auto;
scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
padding-bottom: 6px;
}
.kxp-eventgrid--rail > .kxp-ecard {
flex: 0 0 300px;
max-width: 300px;
scroll-snap-align: start;
}
@media (min-width: 1024px) {
.kxp-eventgrid--rail > .kxp-ecard {
flex-basis: 340px;
max-width: 340px;
}
}
/* ── ⑥ 폴백 유틸(그라디언트 + 라인 패턴) ───────────────────────────────────── */
.pub-fallback-gradient {
background: linear-gradient(135deg, var(--color-primary-700) 0%, var(--color-primary-600) 100%);
}
.pub-pattern-grid {
background-image:
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
background-size: 28px 28px;
}
.pub-pattern-dots {
background-image: radial-gradient(rgba(255, 255, 255, 0.14) 1.4px, transparent 1.4px);
background-size: 22px 22px;
}
/* 포스터 폴백 라벨(이미지 부재/onerror 시) */
.kxp-ecard__poster {
position: absolute;
inset: 0;
}
.kxp-ecard__posterfallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
text-align: center;
font-size: 14px;
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
/* ── ⑦ prefers-reduced-motion — 장식 모션 전면 비활성 ──────────────────────── */
@media (prefers-reduced-motion: reduce) {
.kxp .pub-reveal,
.kxp .pub-reveal--stagger > *,
.kxp .pub-hero__fg {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
.kxp .kxp-vhero__bg[data-parallax],
.kxp .kxp-hero__bg[data-parallax] {
transform: none !important;
top: 0;
bottom: 0;
will-change: auto;
}
.kxp .pub-card--lift,
.kxp .pub-card__media img {
transition: none;
}
.kxp .pub-card--lift:hover {
transform: none;
}
.kxp .pub-card--lift:hover .pub-card__media img {
transform: none;
}
.kxp .kxp-eventgrid--rail {
scroll-behavior: auto;
}
}

View File

@ -14,6 +14,8 @@ export interface PublicEvent {
status: string | null;
hallLabel: string | null;
boothCount: number | null;
/** V46: 빌드 내장 로컬 포스터 경로(/media/posters/*.jpg). 부재 시 그라디언트 폴백. */
posterUrl?: string | null;
}
export interface PublicFloorplanHall {

View File

@ -57,8 +57,44 @@ export function usePublicEvents(max = 8): EventsState {
return { events, loading, error };
}
/** 행사 카드 그리드 — SCR-P1 카드 자산 재사용. 빈/로딩/에러 인라인 처리. */
export function EventGrid({ state }: { state: EventsState }) {
/**
* (/media/posters/*.jpg) , onerror·
* + (§3C-V V-4, CLS 0 · aspect-ratio ).
*/
function EventPoster({ name, posterUrl, eager }: { name: string; posterUrl?: string | null; eager?: boolean }) {
const [failed, setFailed] = useState(false);
const showImg = !!posterUrl && !failed;
return (
<>
{showImg ? (
<img
className="kxp-ecard__poster"
src={posterUrl as string}
alt=""
loading={eager ? 'eager' : 'lazy'}
decoding="async"
onError={() => setFailed(true)}
/>
) : (
<div className="kxp-ecard__poster pub-fallback-gradient pub-pattern-grid" aria-hidden>
<span className="kxp-ecard__posterfallback">{name}</span>
</div>
)}
</>
);
}
/**
* SCR-P1 . // .
* variant='rail' (§3C-V V-3, .pub-rail--auto). 'grid'().
*/
export function EventGrid({
state,
variant = 'grid',
}: {
state: EventsState;
variant?: 'grid' | 'rail';
}) {
const { t } = useTranslation();
const { events, loading, error } = state;
@ -80,22 +116,18 @@ export function EventGrid({ state }: { state: EventsState }) {
return <p className="kxp-empty">{t('home.empty')}</p>;
}
const isRail = variant === 'rail';
const gridCls = isRail ? 'kxp-eventgrid kxp-eventgrid--rail pub-rail--auto' : 'kxp-eventgrid';
return (
<div className="kxp-eventgrid">
<div className={gridCls} data-interval={isRail ? '4500' : undefined}>
{events.map((ev) => {
const status = deriveStatus(ev.startDate, ev.endDate);
const d = dday(ev.startDate);
return (
<article key={ev.id} className="kxp-ecard">
<div className="kxp-ecard__media">
<div
style={{
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, #0066b3 0%, #004c86 100%)',
}}
aria-hidden
/>
<article key={ev.id} className="kxp-ecard pub-card--lift" data-rail-item={isRail ? '' : undefined}>
<div className="kxp-ecard__media pub-card__media">
<EventPoster name={ev.name} posterUrl={ev.posterUrl} />
{status === 'ongoing' ? (
<span className="kxp-ecard__botleft kxp-dday kxp-dday--soon">{t('home.ongoing')}</span>
) : status === 'ended' ? (

View File

@ -0,0 +1,196 @@
/*
* · (design.md §3C-V V-6·V-7).
* PublicShell (.kxp) . / CSS- :
* .pub-reveal IntersectionObserver(threshold 0.15·once) .is-visible
* .pub-countup IO 1 (data-count-to·data-count-suffix)
* [data-parallax] rAF passive scroll ()
* .pub-rail--auto (hover/focus/visibility , data-interval)
* .pub-hero .is-in
* prefers-reduced-motion: 파랄랙스·· ( ).
* transform/opacity·scrollLeft만 ( ). AppShell/ .
*/
import { useEffect, type RefObject } from 'react';
function fmt(n: number): string {
return Math.round(n).toLocaleString();
}
export function usePublicMotion(rootRef: RefObject<HTMLElement | null>): void {
useEffect(() => {
const root = rootRef.current;
if (!root || typeof window === 'undefined') return;
const reduce =
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const desktop =
typeof window.matchMedia === 'function' &&
window.matchMedia('(min-width: 1024px)').matches;
const cleanups: Array<() => void> = [];
const supportsIO = typeof IntersectionObserver !== 'undefined';
// ── ① 스크롤 리빌 ────────────────────────────────────────────────────────
if (reduce || !supportsIO) {
root.querySelectorAll('.pub-reveal').forEach((n) => n.classList.add('is-visible'));
} else {
const revealIO = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('is-visible');
revealIO.unobserve(e.target);
}
});
},
{ threshold: 0.15 },
);
const observeReveals = () =>
root
.querySelectorAll('.pub-reveal:not(.is-visible)')
.forEach((n) => revealIO.observe(n));
observeReveals();
cleanups.push(() => revealIO.disconnect());
// 비동기(행사·공지 등)로 뒤늦게 마운트되는 .pub-reveal 노드 포착.
let rafScan = 0;
const mo = new MutationObserver(() => {
if (rafScan) return;
rafScan = requestAnimationFrame(() => {
rafScan = 0;
observeReveals();
});
});
mo.observe(root, { childList: true, subtree: true });
cleanups.push(() => {
if (rafScan) cancelAnimationFrame(rafScan);
mo.disconnect();
});
}
// ── ② 카운트업 ──────────────────────────────────────────────────────────
const counts = Array.from(root.querySelectorAll<HTMLElement>('.pub-countup'));
const finalize = (node: HTMLElement) => {
const to = parseFloat(node.dataset.countTo || '0');
node.textContent = fmt(to) + (node.dataset.countSuffix || '');
};
if (reduce || !supportsIO) {
counts.forEach(finalize);
} else {
const runCount = (node: HTMLElement) => {
const to = parseFloat(node.dataset.countTo || '0');
const suffix = node.dataset.countSuffix || '';
const dur = 1200;
const start = performance.now();
const tick = (now: number) => {
const p = Math.min(1, (now - start) / dur);
const eased = 1 - Math.pow(1 - p, 3); // ease-decelerate
node.textContent = fmt(to * eased) + suffix;
if (p < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
};
const countIO = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
runCount(e.target as HTMLElement);
countIO.unobserve(e.target);
}
});
},
{ threshold: 0.4 },
);
counts.forEach((n) => countIO.observe(n));
cleanups.push(() => countIO.disconnect());
}
// ── ③ 라이트 파랄랙스(데스크톱·모션 허용 시만) ──────────────────────────────
if (!reduce && desktop) {
const layers = Array.from(root.querySelectorAll<HTMLElement>('[data-parallax]'));
if (layers.length) {
let raf = 0;
const apply = () => {
raf = 0;
const vh = window.innerHeight || document.documentElement.clientHeight;
layers.forEach((l) => {
const rect = l.getBoundingClientRect();
if (rect.bottom < -200 || rect.top > vh + 200) return; // 오프스크린 무시
const factor = parseFloat(l.dataset.parallax || '0');
const y = (rect.top + rect.height / 2 - vh / 2) * factor;
l.style.transform = `translate3d(0, ${y.toFixed(1)}px, 0)`;
});
};
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(apply);
};
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll, { passive: true });
apply();
cleanups.push(() => {
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
if (raf) cancelAnimationFrame(raf);
layers.forEach((l) => {
l.style.transform = '';
});
});
}
}
// ── ④ 오토스크롤 레일 ──────────────────────────────────────────────────────
if (!reduce) {
root.querySelectorAll<HTMLElement>('.pub-rail--auto').forEach((rail) => {
const interval = Math.max(1500, parseInt(rail.dataset.interval || '4500', 10) || 4500);
let paused = false;
const step = () => {
if (paused) return;
const max = rail.scrollWidth - rail.clientWidth;
if (max <= 4) return;
const card = rail.querySelector<HTMLElement>('[data-rail-item]');
const delta = card ? card.offsetWidth + 20 : Math.round(rail.clientWidth * 0.8);
let next = rail.scrollLeft + delta;
if (next >= max - 4) next = 0;
rail.scrollTo({ left: next, behavior: 'smooth' });
};
const timer = window.setInterval(step, interval);
const pause = () => {
paused = true;
};
const resume = () => {
paused = false;
};
const onVis = () => {
paused = document.hidden;
};
rail.addEventListener('mouseenter', pause);
rail.addEventListener('mouseleave', resume);
rail.addEventListener('focusin', pause);
rail.addEventListener('focusout', resume);
document.addEventListener('visibilitychange', onVis);
cleanups.push(() => {
window.clearInterval(timer);
rail.removeEventListener('mouseenter', pause);
rail.removeEventListener('mouseleave', resume);
rail.removeEventListener('focusin', pause);
rail.removeEventListener('focusout', resume);
document.removeEventListener('visibilitychange', onVis);
});
});
}
// ── ⑤ 히어로 인트로 ────────────────────────────────────────────────────────
const heroes = Array.from(root.querySelectorAll<HTMLElement>('.pub-hero'));
if (reduce) {
heroes.forEach((h) => h.classList.add('is-in'));
} else {
const rafId = requestAnimationFrame(() =>
requestAnimationFrame(() => heroes.forEach((h) => h.classList.add('is-in'))),
);
cleanups.push(() => cancelAnimationFrame(rafId));
}
return () => cleanups.forEach((fn) => fn());
}, [rootRef]);
}

View File

@ -290,7 +290,7 @@ export default function KxCalendarView({
key={it.id}
type="button"
className={`kx-cv-event${it.statusCd === 'DONE' ? ' is-done' : ''}`}
style={it.color ? { borderLeftColor: it.color } : undefined}
style={it.color ? ({ ['--kx-bar-color']: it.color } as CSSProperties) : undefined}
title={it.title}
onClick={(e) => {
e.stopPropagation();
@ -321,7 +321,7 @@ export default function KxCalendarView({
style={{
gridColumn: `${seg.startCol + 1} / span ${seg.span}`,
gridRow: seg.lane + 1,
...(it.color ? { borderLeftColor: it.color } : {}),
...(it.color ? ({ ['--kx-bar-color']: it.color } as CSSProperties) : {}),
}}
title={it.title}
onClick={(e) => {

View File

@ -4,6 +4,13 @@
.kx-cv-root {
width: 100%;
min-width: 0;
/* Nifty(app-views/calendar) 캘린더 스코프 변수(전역 토큰만 사용).
격자선은 옅게(neutral-100), today 옅은 노랑 틴트, 이벤트 radius 3px. */
--kx-cv-line: var(--color-neutral-100); /* 셀 격자선(프레임보다 옅게) */
--kx-cv-cell-border: 1px solid var(--color-neutral-100);
--kx-cv-today-bg: var(--color-warning-bg); /* today 옅은 노랑 셀 틴트 */
--kx-cv-bar-radius: 3px;
--kx-cv-bar-default: var(--color-primary-600); /* 카테고리 미매핑 폴백 = 소프트 브랜드 */
}
/* ---- 툴바 ---- */
@ -115,7 +122,7 @@
font-weight: 600;
color: var(--color-neutral-500);
background: var(--color-neutral-050);
border-bottom: var(--border-card);
border-bottom: 1px solid var(--color-neutral-200);
}
.kx-cv-dow.is-sun {
color: var(--color-error);
@ -141,8 +148,8 @@
min-width: 0;
/* 피드백②: 이벤트 없는 날 아래 대형 공백 → 셀 높이 축소(콘텐츠 밀도 정리). */
min-height: 64px;
border-top: var(--border-card);
border-right: var(--border-card);
border-top: var(--kx-cv-cell-border);
border-right: var(--kx-cv-cell-border);
padding: var(--space-1) var(--space-2) var(--space-2);
font-size: var(--fs-caption);
background: var(--color-white);
@ -160,9 +167,17 @@
background: var(--color-neutral-050);
color: var(--color-neutral-500);
}
/* 다른 달 날짜 숫자 — 흐린 회색(muted). */
.kx-cv-cell.is-out .kx-cv-daynum {
color: var(--color-neutral-500);
font-weight: 500;
}
/* 오늘 — 진한 아웃라인/알약 제거 → Nifty식 옅은 노랑 셀 틴트 + 숫자 강조. */
.kx-cv-cell.is-today {
outline: 2px solid var(--color-neutral-500);
outline-offset: -2px;
background: var(--kx-cv-today-bg);
}
.kx-cv-cell.is-today .kx-cv-daynum {
font-weight: 800;
}
.kx-cv-cell.is-selected {
background: var(--color-primary-050);
@ -227,6 +242,8 @@
/* ---- 기간 막대(bar) ---- */
.kx-cv-bar {
/* 카테고리색은 컴포넌트가 --kx-bar-color 로 주입(미지정 시 소프트 브랜드 폴백). */
--kx-bar-color: var(--kx-cv-bar-default);
pointer-events: auto;
min-width: 0;
display: flex;
@ -235,10 +252,11 @@
margin: 0 1px;
padding: 0 6px;
border: none;
border-radius: var(--radius-sm);
background: var(--color-primary-050);
color: var(--color-primary-700);
border-left: 3px solid var(--color-primary-600);
border-radius: var(--kx-cv-bar-radius);
/* 소프트 틴트 배경 + 진한 카테고리 글자 + 얇은 좌측 액센트(Nifty 가벼운 칩). */
background: color-mix(in srgb, var(--kx-bar-color) 14%, var(--color-white));
color: color-mix(in srgb, var(--kx-bar-color) 72%, var(--color-neutral-900));
border-left: 3px solid var(--kx-bar-color);
font-size: 11px;
line-height: var(--kx-cv-bar-h);
overflow: hidden;
@ -247,8 +265,8 @@
text-align: left;
}
.kx-cv-bar:hover {
background: var(--color-primary-600);
color: #fff;
background: var(--kx-bar-color);
color: var(--color-on-accent);
}
.kx-cv-bar.is-cont-left {
border-top-left-radius: 0;
@ -261,17 +279,27 @@
border-bottom-right-radius: 0;
margin-right: 0;
}
/* 완료(DONE) — 무거운 진회색 대신 아주 옅은 회색 muted 칩. */
.kx-cv-bar.is-done {
background: var(--color-neutral-050);
color: var(--color-neutral-500);
border-left-color: var(--color-neutral-200);
}
.kx-cv-bar.is-done:hover {
background: var(--color-neutral-100);
color: var(--color-neutral-700);
}
.kx-cv-bar-badge {
flex: 0 0 auto;
margin-right: 4px;
padding: 0 4px;
border-radius: var(--radius-sm);
background: rgba(0, 0, 0, 0.08);
background: color-mix(in srgb, var(--kx-bar-color, var(--color-primary-600)) 22%, transparent);
font-weight: 600;
}
.kx-cv-bar:hover .kx-cv-bar-badge {
background: rgba(255, 255, 255, 0.25);
}
.kx-cv-bar-title {
overflow: hidden;
text-overflow: ellipsis;
@ -328,8 +356,8 @@
.kx-cv-week-cell {
min-width: 0;
min-height: 200px;
border-top: var(--border-card);
border-right: var(--border-card);
border-top: var(--kx-cv-cell-border);
border-right: var(--kx-cv-cell-border);
padding: var(--space-2);
cursor: pointer;
}
@ -337,8 +365,7 @@
border-right: none;
}
.kx-cv-week-cell.is-today {
outline: 2px solid var(--color-neutral-500);
outline-offset: -2px;
background: var(--kx-cv-today-bg);
}
.kx-cv-week-cell.is-selected {
background: var(--color-primary-050);
@ -366,27 +393,36 @@
/* ---- 단일일 항목(칩)·뱃지 ---- */
.kx-cv-event {
--kx-bar-color: var(--kx-cv-bar-default);
display: flex;
align-items: center;
max-width: 100%;
margin-top: var(--space-1);
padding: 2px 6px;
border: none;
border-radius: var(--radius-sm);
background: var(--color-primary-050);
color: var(--color-primary-700);
border-radius: var(--kx-cv-bar-radius);
/* 소프트 카테고리 틴트 칩(Nifty). 미매핑 시 소프트 브랜드 폴백 — 진회색 금지. */
background: color-mix(in srgb, var(--kx-bar-color) 14%, var(--color-white));
color: color-mix(in srgb, var(--kx-bar-color) 72%, var(--color-neutral-900));
cursor: pointer;
font-size: 11px;
line-height: 1.4;
overflow: hidden;
white-space: nowrap;
border-left: 3px solid var(--color-primary-600);
border-left: 3px solid var(--kx-bar-color);
text-align: left;
width: 100%;
}
.kx-cv-event:hover {
background: var(--color-primary-600);
color: #fff;
background: var(--kx-bar-color);
color: var(--color-on-accent);
}
.kx-cv-event.is-done {
background: var(--color-neutral-050);
border-left-color: var(--color-neutral-200);
}
.kx-cv-event.is-done:hover {
background: var(--color-neutral-100);
}
.kx-cv-event.is-done .kx-cv-event-title {
color: var(--color-neutral-500);

View File

@ -208,13 +208,14 @@
color: var(--color-error);
font-weight: 700;
}
/* today — 진남색 알약 제거 → Nifty식 옅은 노랑 틴트 + 진한 숫자(회색 테두리 유지). */
.kx-cal__cell.is-today {
background: var(--color-primary-600);
color: var(--color-on-accent);
background: var(--color-warning-bg);
color: var(--color-primary-700);
font-weight: 700;
}
.kx-cal__cell.is-today.has-event::after {
background: var(--color-white);
background: var(--color-primary-600);
}
/* 목록 건수 */

View File

@ -6,6 +6,7 @@
.kx-settle {
display: grid;
gap: 16px;
padding: var(--gutter);
}
/* 헤더 */