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>
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -12,6 +12,7 @@ public record PublicEventDto(
|
||||
String endDate,
|
||||
String status,
|
||||
String hallLabel,
|
||||
Integer boothCount
|
||||
Integer boothCount,
|
||||
String posterUrl
|
||||
) {
|
||||
}
|
||||
|
||||
@ -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';
|
||||
@ -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;
|
||||
BIN
src/frontend/public/media/posters/kx-468958-1.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
src/frontend/public/media/posters/kx-468981-1.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src/frontend/public/media/posters/kx-469060-1.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src/frontend/public/media/posters/kx-469082-1.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src/frontend/public/media/posters/kx-469112-1.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
src/frontend/public/media/posters/kx-469129-6.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src/frontend/public/media/posters/kx-469133-36.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/frontend/public/media/posters/kx-469147-1.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/frontend/public/media/posters/kx-469162-9.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/frontend/public/media/posters/kx-469163-2.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/frontend/public/media/posters/kx-469174-1.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
src/frontend/public/media/posters/kx-469177-2.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
src/frontend/public/media/posters/kx-469185-1.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/frontend/public/media/posters/kx-469186-2.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/frontend/public/media/posters/kx-469201-3.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/frontend/public/media/posters/kx-469204-1.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
src/frontend/public/media/posters/kx-469205-1.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
src/frontend/public/media/posters/kx-469207-1.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/frontend/public/media/posters/kx-469219-1.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
src/frontend/public/media/posters/kx-469220-1.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
src/frontend/public/media/posters/kx-469230-2.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/frontend/public/media/posters/kx-469236-1.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src/frontend/public/media/posters/kx-469240-1.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src/frontend/public/media/posters/kx-469244-1.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/frontend/public/media/posters/kx-469246-2.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
src/frontend/public/media/posters/kx-469247-1.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/frontend/public/media/posters/kx-469249-2.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/frontend/public/media/posters/kx-469254-2.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
src/frontend/public/media/posters/kx-469256-2.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src/frontend/public/media/posters/kx-469262-1.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
src/frontend/public/media/posters/kx-469266-1.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
src/frontend/public/media/posters/kx-469276-1.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src/frontend/public/media/posters/kx-469277-2.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
src/frontend/public/media/posters/kx-469283-1.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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' ? (
|
||||
|
||||
196
src/frontend/src/screens/public/usePublicMotion.ts
Normal 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]);
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/* 목록 건수 */
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
.kx-settle {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: var(--gutter);
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
|
||||