feat(nfr,tenant): wave 5+6 — dark theme toggle, i18n (ko/en/zh/ja), multitenancy phase 1 (V26)
- theme: dark palette remap via [data-theme], themeStore persistence, AppShell toggle, shared-layer hex -> semantic tokens, focus-ring/reduced-motion/skip-link a11y - i18n: react-i18next with browser detection + ko fallback; full extraction for public site P1-P7 and login/signup/forgot flows; LanguageSwitcher wired in public shell, login, and AppShell - multitenancy: V26 tenant master + tenant_id (event/hall/app_user, DEFAULT 'kintex'), TenantContextFilter (X-Tenant-Id -> Host -> default), catalog/hall scoping (zero behavior change), /api/admin/tenants GET/POST wired for SCR-A9 - ops(prod): kintex.wise.ai.kr live — nginx vhost on wise host proxying verified dev origin + LE TLS; full-stack split deferred (host RAM/disk constraint, see WORK_STATUS) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4049b7c222
commit
48572d261a
@ -17,7 +17,8 @@
|
|||||||
## 2. 스택·인프라 (시크릿 제외)
|
## 2. 스택·인프라 (시크릿 제외)
|
||||||
- 프론트 React 18/19(Vite·TS) / 백엔드 Spring Boot 3.2.5(Java17)+MyBatis / DB PostgreSQL+PostGIS(Flyway) / Redis / 나노바나나 Python 워커(google-genai `gemini-3.1-flash-image-preview`).
|
- 프론트 React 18/19(Vite·TS) / 백엔드 Spring Boot 3.2.5(Java17)+MyBatis / DB PostgreSQL+PostGIS(Flyway) / Redis / 나노바나나 Python 워커(google-genai `gemini-3.1-flash-image-preview`).
|
||||||
- AI 지능 = **Claude 기본**(AiTextRouter/AiConfig) + Ollama 폴백. 이미지 = 나노바나나(Gemini, 소유자 승인=G1 라이브).
|
- AI 지능 = **Claude 기본**(AiTextRouter/AiConfig) + Ollama 폴백. 이미지 = 나노바나나(Gemini, 소유자 승인=G1 라이브).
|
||||||
- 개발 배포: **https://kintex.zioinfo.co.kr**(TLS·백엔드 8021·PostGIS·Redis·워커 systemd). 운영 도메인 kintex.wise.ai.kr(후속). 서버 접속 자격증명은 **GUARDiA 공용**(env·리포 미기재).
|
- 개발 배포: **https://kintex.zioinfo.co.kr**(TLS·백엔드 8021·PostGIS·Redis·워커 systemd). 서버 접속 자격증명은 **GUARDiA 공용**(env·리포 미기재).
|
||||||
|
- **운영 도메인: https://kintex.wise.ai.kr 라이브(2026-07-12)** — wise.ai.kr 호스트(211.37.173.197, SSH 9271·dev의 `/var/lib/jenkins/.ssh/id_ed25519_prod` 키 채널)의 nginx vhost `conf.d/kintex.conf`가 dev 오리진으로 리버스 프록시 + LE TLS(자동 갱신). **push→dev 자동배포 = 운영 즉시 반영**(단일 오리진). ★운영 호스트는 RAM 2G·디스크 95%(레거시 CUBRID/톰캣 14.6G)로 **풀스택 상주 불가 판정** — 자원 확보 후 UIWS 패턴(tar-over-ssh 승격, `workspace/uiws/_workspace/prod-provision/50_cicd_prod.md`)으로 2단계 분리.
|
||||||
- CI/CD: Gitea webhook → `deploy_kintex.sh`(백엔드 `sh gradlew bootJar` + 프론트 vite 빌드 → 배포 → health → 롤백). push하면 자동배포.
|
- CI/CD: Gitea webhook → `deploy_kintex.sh`(백엔드 `sh gradlew bootJar` + 프론트 vite 빌드 → 배포 → health → 롤백). push하면 자동배포.
|
||||||
|
|
||||||
## 3. 요구사항 로그 (이번 세션 누적 — 스코프 진화)
|
## 3. 요구사항 로그 (이번 세션 누적 — 스코프 진화)
|
||||||
@ -98,6 +99,7 @@
|
|||||||
| 2026-07-12 | **Stitch 전 화면 확보 완료(64/64)** — 서비스 회복 후 수확 패스 재실행: 잔여 39화면 전부 서버측 지연 생성 완료 상태(프로젝트 27→155 화면)로 재생성 0회·다운로드 43파일(보조 변형 5 포함: P4 3단계·P7 예매확인·M15 권종선택) 실패 0. `_workspace/stitch_gen/{harvest2.py,screen_titles.txt,harvest_report.md}`. 다음: frontend/mobile dev 이식 + design.md 🔲미생성 마커 갱신(designer 경유) |
|
| 2026-07-12 | **Stitch 전 화면 확보 완료(64/64)** — 서비스 회복 후 수확 패스 재실행: 잔여 39화면 전부 서버측 지연 생성 완료 상태(프로젝트 27→155 화면)로 재생성 0회·다운로드 43파일(보조 변형 5 포함: P4 3단계·P7 예매확인·M15 권종선택) 실패 0. `_workspace/stitch_gen/{harvest2.py,screen_titles.txt,harvest_report.md}`. 다음: frontend/mobile dev 이식 + design.md 🔲미생성 마커 갱신(designer 경유) |
|
||||||
| 2026-07-12 | **실데이터·2FA 트랙 마감** — ①실데이터 집계 API 5패키지(analytics·dashboard·ops·admin·catalog + V13 인덱스) ②TOTP 2FA 완결(SecretCipher AES-256-GCM·V12·OtpChallengePanel/OtpSetupPage·OTP_ENFORCE env) ③공통 업무기능 SCR-39~48 화면(`screens/work/`) 배선 ④M2 부스 겹침 검사(BOOTH_OVERLAP·compliance-v1.1) + 단위테스트 3종. 검증: backend compileJava+test·frontend tsc 통과, QA PASS(blocker/major 0, minor 2=RIGGING_RANGE semantics 소유자 결정 대기·운영 KINTEX_OTP_ENC_KEY 주입 게이트 — `_workspace/09_qa_track_close.md`). 계약 갭 기록: `_workspace/{07_work_api_gaps,08_m2m5_contract_changes}.md` |
|
| 2026-07-12 | **실데이터·2FA 트랙 마감** — ①실데이터 집계 API 5패키지(analytics·dashboard·ops·admin·catalog + V13 인덱스) ②TOTP 2FA 완결(SecretCipher AES-256-GCM·V12·OtpChallengePanel/OtpSetupPage·OTP_ENFORCE env) ③공통 업무기능 SCR-39~48 화면(`screens/work/`) 배선 ④M2 부스 겹침 검사(BOOTH_OVERLAP·compliance-v1.1) + 단위테스트 3종. 검증: backend compileJava+test·frontend tsc 통과, QA PASS(blocker/major 0, minor 2=RIGGING_RANGE semantics 소유자 결정 대기·운영 KINTEX_OTP_ENC_KEY 주입 게이트 — `_workspace/09_qa_track_close.md`). 계약 갭 기록: `_workspace/{07_work_api_gaps,08_m2m5_contract_changes}.md` |
|
||||||
| 2026-07-12 | **Stitch 확보 화면 이식 완료(웹 24 + 공개 7단계플로우 + 모바일 1)** — 에이전트 7팀 병렬(폴더 소유권 분리·공유파일 통합자 단일 배선). ①도메인: SCR-22/23/25(서류·신고서류·도크, M6/M8 샘플)·SCR-26/27/29/38(옥션 3종+수주 대시보드, M15 샘플, 봉인입찰 마스킹·등록업체만 응찰 UI)·SCR-30/32/33/34(관람객·리드·EDM·스폰서십, M10/M12 샘플, PII 마스킹)·SCR-35/36/37(CMS 3종, M17 샘플) ②관리자: **A5 감사로그·A6 시스템설정 = 라이브 백엔드 실배선**(PageResponse·시크릿 마스킹), A8 룰셋(정적 v1.1 스냅샷)·A9 테넌트(샘플) ③공개: P1~P7 자체 PublicShell(비인증 /public/*·/tickets/*, PG위임 고지) ④모바일: M15 티켓지갑(mobile/app/tickets, QR placeholder) ⑤통합: App 라우트 18종+AppShell "도메인 모듈" 네비. tsc -b EXIT 0·QA PASS(minor 2 코스메틱: kx-field 접두, ★/✓ 글리프). 각 팀 API 계약 초안 = `_workspace/port_*.md`. 커밋 becbc55 |
|
| 2026-07-12 | **Stitch 확보 화면 이식 완료(웹 24 + 공개 7단계플로우 + 모바일 1)** — 에이전트 7팀 병렬(폴더 소유권 분리·공유파일 통합자 단일 배선). ①도메인: SCR-22/23/25(서류·신고서류·도크, M6/M8 샘플)·SCR-26/27/29/38(옥션 3종+수주 대시보드, M15 샘플, 봉인입찰 마스킹·등록업체만 응찰 UI)·SCR-30/32/33/34(관람객·리드·EDM·스폰서십, M10/M12 샘플, PII 마스킹)·SCR-35/36/37(CMS 3종, M17 샘플) ②관리자: **A5 감사로그·A6 시스템설정 = 라이브 백엔드 실배선**(PageResponse·시크릿 마스킹), A8 룰셋(정적 v1.1 스냅샷)·A9 테넌트(샘플) ③공개: P1~P7 자체 PublicShell(비인증 /public/*·/tickets/*, PG위임 고지) ④모바일: M15 티켓지갑(mobile/app/tickets, QR placeholder) ⑤통합: App 라우트 18종+AppShell "도메인 모듈" 네비. tsc -b EXIT 0·QA PASS(minor 2 코스메틱: kx-field 접두, ★/✓ 글리프). 각 팀 API 계약 초안 = `_workspace/port_*.md`. 커밋 becbc55 |
|
||||||
|
| 2026-07-12 | **5·6차 웨이브(NFR+멀티테넌시) + 운영 도메인 라이브** — ①W5-1 다크테마(global.css `--dk-*`→`[data-theme=dark]` remap·시스템 선호 폴백·themeStore(localStorage)·AppShell 토글·공유 CSS 하드코딩 hex 토큰화·focus-ring/reduced-motion/스킵링크) ②W5-2 i18n(react-i18next ko/en/zh/ja·공개 P1~P7+로그인/가입/비번찾기 전 문구·LanguageSwitcher(공개셸+로그인+AppShell 배선)·fallback ko) ③W6 멀티테넌시 1단계(V26 tenant 마스터+event/hall/app_user tenant_id 순증·TenantContextFilter(X-Tenant-Id→Host→kintex)·catalog/hall 스코핑·`/api/admin/tenants` GET/POST 실배선 — 회귀 0) ④**운영 도메인 https://kintex.wise.ai.kr 라이브**(위 §2 참조). 검증: tsc·vite·compileJava·test 전부 EXIT 0 |
|
||||||
| 2026-07-12 | **4차 웨이브(백로그 4트랙) + ★OTP 로그인 루프 라이브 장애 수정** — ①**OTP 장애**(사용자 보고 "로그인 후 메인화면 안 나옴"): `verifyOtp`/`enrollVerify`가 코드 검증 **전에** 챌린지를 소비 → 오입력 1회면 이후 정답도 "세션 만료" 루프. 수정=성공 시에만 소비+실패 5회 한도(`OtpChallengeStore.recordFailure`)+`OtpChallengeStoreTest` ②SCR-14 현장운영 실전환(혼잡=체크인 파생·전력=utility 부하, IoT는 "센서 미연동" 정직 표기) ③M9 정산 신설(`/api/settlement`·V24 invoice/invoice_payment·`/settlement` 화면, ST_Area×요율+VAT 서버 권위·PG위임) ④M1 홀배정+자동견적(`/api/halls`·V25 기간 컬럼 순증·충돌 409+상세·rates-v1.0 견적·`/halls` 화면) ⑤공유 레이어: AuthUser.roleCode 순증 노출(JWT `rc` 클레임)+AdminGuard 정밀화+AppShell 플레이스홀더 네비 전량 배선(행사 컨텍스트 계산 경로)+`/_styleguide` prod 제외. 검증: compileJava·test·tsc -b --force·vite build 전부 EXIT 0 |
|
| 2026-07-12 | **4차 웨이브(백로그 4트랙) + ★OTP 로그인 루프 라이브 장애 수정** — ①**OTP 장애**(사용자 보고 "로그인 후 메인화면 안 나옴"): `verifyOtp`/`enrollVerify`가 코드 검증 **전에** 챌린지를 소비 → 오입력 1회면 이후 정답도 "세션 만료" 루프. 수정=성공 시에만 소비+실패 5회 한도(`OtpChallengeStore.recordFailure`)+`OtpChallengeStoreTest` ②SCR-14 현장운영 실전환(혼잡=체크인 파생·전력=utility 부하, IoT는 "센서 미연동" 정직 표기) ③M9 정산 신설(`/api/settlement`·V24 invoice/invoice_payment·`/settlement` 화면, ST_Area×요율+VAT 서버 권위·PG위임) ④M1 홀배정+자동견적(`/api/halls`·V25 기간 컬럼 순증·충돌 409+상세·rates-v1.0 견적·`/halls` 화면) ⑤공유 레이어: AuthUser.roleCode 순증 노출(JWT `rc` 클레임)+AdminGuard 정밀화+AppShell 플레이스홀더 네비 전량 배선(행사 컨텍스트 계산 경로)+`/_styleguide` prod 제외. 검증: compileJava·test·tsc -b --force·vite build 전부 EXIT 0 |
|
||||||
| 2026-07-12 | **3차 웨이브(백로그 3트랙) + 배포 크래시 근본수정** — ①크래시 규명: c6f3a8d jar가 MyBatis `<script>` XML 위반(AuctionMapper `deadline < now()`·VisitorMapper `<=`)으로 부팅 크래시 루프→롤백. 매퍼 교정 + `MapperAnnotationParseTest` 회귀 가드 + `checkin.css` 누락 작성(서버 프론트 빌드 실패 원인) ②M16 BI 전역: `GET /api/analytics/overview`(행사 간 매출·홀 가동률 추이·리텐션·선형회귀 예측, 홀매니저 전용, 마이그레이션 無) + /analytics 인라인 섹션 ③M18 관리자: 사용자·역할/권한·공통코드·메뉴 CRUD 화면 4종(백엔드 system 라이브 소비, 신규 백엔드 0)+`AdminGuard`(전 /admin/* 라우트 가드 배선)+랜딩 모듈 바로가기. A8 룰셋은 기왕 실배선 확인 ④SCR-15 일정: V23(category·est_visitors 백필 1,098건)+홀 가동률/캘린더 API 2종+실 전환 ⑤QA minor: ✓글리프 SVG화(kx-field·★는 기왕 해소). 검증: compileJava·test(매퍼 파싱 포함)·tsc -b --force·vite build 전부 EXIT 0. ⚠AdminGuard 판정 한계: 전역 role_code 미노출로 hallManager 기준 — 정확 판정은 AuthUser.roleCode 노출 필요(후속) |
|
| 2026-07-12 | **3차 웨이브(백로그 3트랙) + 배포 크래시 근본수정** — ①크래시 규명: c6f3a8d jar가 MyBatis `<script>` XML 위반(AuctionMapper `deadline < now()`·VisitorMapper `<=`)으로 부팅 크래시 루프→롤백. 매퍼 교정 + `MapperAnnotationParseTest` 회귀 가드 + `checkin.css` 누락 작성(서버 프론트 빌드 실패 원인) ②M16 BI 전역: `GET /api/analytics/overview`(행사 간 매출·홀 가동률 추이·리텐션·선형회귀 예측, 홀매니저 전용, 마이그레이션 無) + /analytics 인라인 섹션 ③M18 관리자: 사용자·역할/권한·공통코드·메뉴 CRUD 화면 4종(백엔드 system 라이브 소비, 신규 백엔드 0)+`AdminGuard`(전 /admin/* 라우트 가드 배선)+랜딩 모듈 바로가기. A8 룰셋은 기왕 실배선 확인 ④SCR-15 일정: V23(category·est_visitors 백필 1,098건)+홀 가동률/캘린더 API 2종+실 전환 ⑤QA minor: ✓글리프 SVG화(kx-field·★는 기왕 해소). 검증: compileJava·test(매퍼 파싱 포함)·tsc -b --force·vite build 전부 EXIT 0. ⚠AdminGuard 판정 한계: 전역 role_code 미노출로 hallManager 기준 — 정확 판정은 AuthUser.roleCode 노출 필요(후속) |
|
||||||
| 2026-07-12 | **도메인 2차 웨이브(중단분 복구·마감)** — 중단됐던 미커밋 웨이브 완성: ①M15 옥션 실배선 고도화(`screens/auction/auctionApi.ts` 정본 + 스코어링·견적계산 단위테스트) ②M10 체크인 데스크(SCR-31, `/visitors/checkin` 라우트·네비 신규 배선, QR 토큰·checkin_at·리드 exhibitor_id/동의/메모 확장, 체크인/검색/통계/배지재발급/리드생성/CSV export API — 마스킹·RBAC·@Audited) + 룰기반 리드 스코어링 엔진(`visitor/scoring`) ③M17 CMS 버전 이력·미디어 라이브러리·공개 CMS API(`/api/public/cms`)·HtmlSanitizer ④단위테스트 7종. **★Flyway 수복: 이미 배포된 V17/V19에 append돼 있던 확장 SQL을 V21로 분리·원복**(체크섬 불일치 배포실패 예방). 검증: compileJava·test·tsc -b 전부 EXIT 0. ⚠고아 초안 `src/api/auction{Api,Types}.ts` 2파일은 화면 미사용(정본은 screens/auction 로컬) — 삭제는 소유자 확인 필요 규칙상 **미커밋·보류** |
|
| 2026-07-12 | **도메인 2차 웨이브(중단분 복구·마감)** — 중단됐던 미커밋 웨이브 완성: ①M15 옥션 실배선 고도화(`screens/auction/auctionApi.ts` 정본 + 스코어링·견적계산 단위테스트) ②M10 체크인 데스크(SCR-31, `/visitors/checkin` 라우트·네비 신규 배선, QR 토큰·checkin_at·리드 exhibitor_id/동의/메모 확장, 체크인/검색/통계/배지재발급/리드생성/CSV export API — 마스킹·RBAC·@Audited) + 룰기반 리드 스코어링 엔진(`visitor/scoring`) ③M17 CMS 버전 이력·미디어 라이브러리·공개 CMS API(`/api/public/cms`)·HtmlSanitizer ④단위테스트 7종. **★Flyway 수복: 이미 배포된 V17/V19에 append돼 있던 확장 SQL을 V21로 분리·원복**(체크섬 불일치 배포실패 예방). 검증: compileJava·test·tsc -b 전부 EXIT 0. ⚠고아 초안 `src/api/auction{Api,Types}.ts` 2파일은 화면 미사용(정본은 screens/auction 로컬) — 삭제는 소유자 확인 필요 규칙상 **미커밋·보류** |
|
||||||
|
|||||||
@ -41,6 +41,7 @@ public interface EventCatalogMapper {
|
|||||||
) pa ON true
|
) pa ON true
|
||||||
LEFT JOIN hall h ON h.id = pa.hall_id
|
LEFT JOIN hall h ON h.id = pa.hall_id
|
||||||
<where>
|
<where>
|
||||||
|
AND e.tenant_id = #{tenantId}
|
||||||
<if test="year != null and year != ''">
|
<if test="year != null and year != ''">
|
||||||
AND extract(year FROM e.start_date) = CAST(#{year} AS integer)
|
AND extract(year FROM e.start_date) = CAST(#{year} AS integer)
|
||||||
</if>
|
</if>
|
||||||
@ -55,7 +56,8 @@ public interface EventCatalogMapper {
|
|||||||
LIMIT #{limit} OFFSET #{offset}
|
LIMIT #{limit} OFFSET #{offset}
|
||||||
</script>
|
</script>
|
||||||
""")
|
""")
|
||||||
List<Map<String, Object>> findEvents(@Param("year") String year,
|
List<Map<String, Object>> findEvents(@Param("tenantId") String tenantId,
|
||||||
|
@Param("year") String year,
|
||||||
@Param("status") String status,
|
@Param("status") String status,
|
||||||
@Param("category") String category,
|
@Param("category") String category,
|
||||||
@Param("limit") int limit,
|
@Param("limit") int limit,
|
||||||
@ -79,11 +81,13 @@ public interface EventCatalogMapper {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) pa ON true
|
) pa ON true
|
||||||
LEFT JOIN hall h ON h.id = pa.hall_id
|
LEFT JOIN hall h ON h.id = pa.hall_id
|
||||||
WHERE e.start_date IS NOT NULL AND e.end_date IS NOT NULL
|
WHERE e.tenant_id = #{tenantId}
|
||||||
|
AND e.start_date IS NOT NULL AND e.end_date IS NOT NULL
|
||||||
AND e.start_date <= CAST(#{monthEnd} AS date)
|
AND e.start_date <= CAST(#{monthEnd} AS date)
|
||||||
AND e.end_date >= CAST(#{monthStart} AS date)
|
AND e.end_date >= CAST(#{monthStart} AS date)
|
||||||
""")
|
""")
|
||||||
List<Map<String, Object>> findEventsOverlappingMonth(@Param("monthStart") String monthStart,
|
List<Map<String, Object>> findEventsOverlappingMonth(@Param("tenantId") String tenantId,
|
||||||
|
@Param("monthStart") String monthStart,
|
||||||
@Param("monthEnd") String monthEnd);
|
@Param("monthEnd") String monthEnd);
|
||||||
|
|
||||||
/** 필터 조건 총 건수(페이징 메타). */
|
/** 필터 조건 총 건수(페이징 메타). */
|
||||||
@ -91,6 +95,7 @@ public interface EventCatalogMapper {
|
|||||||
<script>
|
<script>
|
||||||
SELECT count(*) FROM event e
|
SELECT count(*) FROM event e
|
||||||
<where>
|
<where>
|
||||||
|
AND e.tenant_id = #{tenantId}
|
||||||
<if test="year != null and year != ''">
|
<if test="year != null and year != ''">
|
||||||
AND extract(year FROM e.start_date) = CAST(#{year} AS integer)
|
AND extract(year FROM e.start_date) = CAST(#{year} AS integer)
|
||||||
</if>
|
</if>
|
||||||
@ -100,5 +105,6 @@ public interface EventCatalogMapper {
|
|||||||
</where>
|
</where>
|
||||||
</script>
|
</script>
|
||||||
""")
|
""")
|
||||||
long countEvents(@Param("year") String year, @Param("status") String status);
|
long countEvents(@Param("tenantId") String tenantId,
|
||||||
|
@Param("year") String year, @Param("status") String status);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.zioinfo.kintex.catalog;
|
|||||||
import com.zioinfo.kintex.catalog.dto.CalendarDayDto;
|
import com.zioinfo.kintex.catalog.dto.CalendarDayDto;
|
||||||
import com.zioinfo.kintex.catalog.dto.ExhibitionDto;
|
import com.zioinfo.kintex.catalog.dto.ExhibitionDto;
|
||||||
import com.zioinfo.kintex.catalog.dto.HallUtilizationDto;
|
import com.zioinfo.kintex.catalog.dto.HallUtilizationDto;
|
||||||
|
import com.zioinfo.kintex.tenant.TenantContext;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@ -38,7 +39,8 @@ public class EventCatalogServiceImpl implements EventCatalogService {
|
|||||||
@Override
|
@Override
|
||||||
public List<ExhibitionDto> list(String year, String status, String category, int limit, int offset) {
|
public List<ExhibitionDto> list(String year, String status, String category, int limit, int offset) {
|
||||||
List<Map<String, Object>> rows =
|
List<Map<String, Object>> rows =
|
||||||
mapper.findEvents(nullIfBlank(year), nullIfBlank(status), nullIfBlank(category), limit, offset);
|
mapper.findEvents(TenantContext.currentTenantId(),
|
||||||
|
nullIfBlank(year), nullIfBlank(status), nullIfBlank(category), limit, offset);
|
||||||
List<ExhibitionDto> out = new ArrayList<>(rows == null ? 0 : rows.size());
|
List<ExhibitionDto> out = new ArrayList<>(rows == null ? 0 : rows.size());
|
||||||
if (rows != null) {
|
if (rows != null) {
|
||||||
for (Map<String, Object> r : rows) {
|
for (Map<String, Object> r : rows) {
|
||||||
@ -54,7 +56,8 @@ public class EventCatalogServiceImpl implements EventCatalogService {
|
|||||||
@Override
|
@Override
|
||||||
public List<HallUtilizationDto> hallUtilization(int year, int month) {
|
public List<HallUtilizationDto> hallUtilization(int year, int month) {
|
||||||
MonthRange mr = MonthRange.of(year, month);
|
MonthRange mr = MonthRange.of(year, month);
|
||||||
List<Map<String, Object>> rows = mapper.findEventsOverlappingMonth(mr.startYmd(), mr.endYmd());
|
List<Map<String, Object>> rows =
|
||||||
|
mapper.findEventsOverlappingMonth(TenantContext.currentTenantId(), mr.startYmd(), mr.endYmd());
|
||||||
|
|
||||||
// 홀 그룹별 점유 일자 집합
|
// 홀 그룹별 점유 일자 집합
|
||||||
List<boolean[]> busy = new ArrayList<>();
|
List<boolean[]> busy = new ArrayList<>();
|
||||||
@ -107,7 +110,8 @@ public class EventCatalogServiceImpl implements EventCatalogService {
|
|||||||
@Override
|
@Override
|
||||||
public List<CalendarDayDto> calendar(int year, int month) {
|
public List<CalendarDayDto> calendar(int year, int month) {
|
||||||
MonthRange mr = MonthRange.of(year, month);
|
MonthRange mr = MonthRange.of(year, month);
|
||||||
List<Map<String, Object>> rows = mapper.findEventsOverlappingMonth(mr.startYmd(), mr.endYmd());
|
List<Map<String, Object>> rows =
|
||||||
|
mapper.findEventsOverlappingMonth(TenantContext.currentTenantId(), mr.startYmd(), mr.endYmd());
|
||||||
int[] counts = new int[mr.days() + 1]; // 1-based
|
int[] counts = new int[mr.days() + 1]; // 1-based
|
||||||
if (rows != null) {
|
if (rows != null) {
|
||||||
for (Map<String, Object> r : rows) {
|
for (Map<String, Object> r : rows) {
|
||||||
|
|||||||
@ -2,6 +2,8 @@ package com.zioinfo.kintex.config;
|
|||||||
|
|
||||||
import com.zioinfo.kintex.auth.JwtAuthenticationFilter;
|
import com.zioinfo.kintex.auth.JwtAuthenticationFilter;
|
||||||
import com.zioinfo.kintex.auth.JwtService;
|
import com.zioinfo.kintex.auth.JwtService;
|
||||||
|
import com.zioinfo.kintex.tenant.TenantContextFilter;
|
||||||
|
import com.zioinfo.kintex.tenant.TenantResolver;
|
||||||
import com.zioinfo.kintex.common.ApiResponse;
|
import com.zioinfo.kintex.common.ApiResponse;
|
||||||
import com.zioinfo.kintex.common.ApiResponse.ApiError;
|
import com.zioinfo.kintex.common.ApiResponse.ApiError;
|
||||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||||
@ -26,6 +28,7 @@ public class SecurityConfig {
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService,
|
public SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService,
|
||||||
|
TenantResolver tenantResolver,
|
||||||
ObjectMapper objectMapper) throws Exception {
|
ObjectMapper objectMapper) throws Exception {
|
||||||
http
|
http
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
@ -50,6 +53,9 @@ public class SecurityConfig {
|
|||||||
ApiResponse.fail(new ApiError(ErrorCode.UNAUTHORIZED.name(),
|
ApiResponse.fail(new ApiError(ErrorCode.UNAUTHORIZED.name(),
|
||||||
ErrorCode.UNAUTHORIZED.defaultMessage()))));
|
ErrorCode.UNAUTHORIZED.defaultMessage()))));
|
||||||
}))
|
}))
|
||||||
|
// 테넌트 컨텍스트 해소(§1A-3) → 이후 JWT 인증. 미해소 시 기준 테넌트 폴백(회귀 0).
|
||||||
|
.addFilterBefore(new TenantContextFilter(tenantResolver, objectMapper),
|
||||||
|
UsernamePasswordAuthenticationFilter.class)
|
||||||
.addFilterBefore(new JwtAuthenticationFilter(jwtService),
|
.addFilterBefore(new JwtAuthenticationFilter(jwtService),
|
||||||
UsernamePasswordAuthenticationFilter.class);
|
UsernamePasswordAuthenticationFilter.class);
|
||||||
return http.build();
|
return http.build();
|
||||||
|
|||||||
@ -316,7 +316,7 @@ public class FloorplanServiceImpl implements FloorplanService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private HallInfo loadHallInfo(String hallId) {
|
private HallInfo loadHallInfo(String hallId) {
|
||||||
Map<String, Object> h = hallMapper.findHall(hallId);
|
Map<String, Object> h = hallMapper.findHall(hallId, com.zioinfo.kintex.tenant.TenantContext.currentTenantId());
|
||||||
if (h == null) {
|
if (h == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,8 +15,8 @@ import java.util.Map;
|
|||||||
@Mapper
|
@Mapper
|
||||||
public interface HallMapper {
|
public interface HallMapper {
|
||||||
|
|
||||||
/** 홀 마스터 헤더(치수·천장·목표 부스 수·바닥하중·가정 트렌치 여부). 없으면 null. */
|
/** 홀 마스터 헤더(치수·천장·목표 부스 수·바닥하중·가정 트렌치 여부) — 테넌트 스코프. 없으면 null. */
|
||||||
Map<String, Object> findHall(@Param("hallId") String hallId);
|
Map<String, Object> findHall(@Param("hallId") String hallId, @Param("tenantId") String tenantId);
|
||||||
|
|
||||||
/** 홀 트렌치 좌표 목록(가정 그리드 포함) — 캔버스 오버레이·배선 참조용. */
|
/** 홀 트렌치 좌표 목록(가정 그리드 포함) — 캔버스 오버레이·배선 참조용. */
|
||||||
List<Map<String, Object>> findTrenches(@Param("hallId") String hallId);
|
List<Map<String, Object>> findTrenches(@Param("hallId") String hallId);
|
||||||
|
|||||||
@ -0,0 +1,50 @@
|
|||||||
|
package com.zioinfo.kintex.tenant;
|
||||||
|
|
||||||
|
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||||
|
import com.zioinfo.kintex.common.ApiResponse;
|
||||||
|
import com.zioinfo.kintex.common.audit.Audited;
|
||||||
|
import com.zioinfo.kintex.system.SystemAccessGuard;
|
||||||
|
import com.zioinfo.kintex.tenant.dto.TenantAdminDto;
|
||||||
|
import com.zioinfo.kintex.tenant.dto.TenantCreateRequest;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCR-A9 테넌트(전시관) 관리·온보딩 API (M18 · §1A). 플랫폼 슈퍼관리자 전용.
|
||||||
|
* <p>1단계: 목록 조회 + 등록(온보딩 위저드 1단계 = 마스터 생성). 브랜딩·마스터데이터 시드·격리 스모크는 2단계.
|
||||||
|
* 접근 가드는 {@link SystemAccessGuard#requireAdmin}(홀매니저/전역 ADMIN). 등록은 @Audited.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/tenants")
|
||||||
|
public class TenantAdminController {
|
||||||
|
|
||||||
|
private final TenantAdminService service;
|
||||||
|
private final SystemAccessGuard guard;
|
||||||
|
|
||||||
|
public TenantAdminController(TenantAdminService service, SystemAccessGuard guard) {
|
||||||
|
this.service = service;
|
||||||
|
this.guard = guard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET — 전 테넌트 목록(코드·명·도메인·상태·행사수·사용자수). */
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<List<TenantAdminDto>> list(@AuthenticationPrincipal KintexPrincipal principal) {
|
||||||
|
guard.requireAdmin(principal);
|
||||||
|
return ApiResponse.ok(service.list());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST — 테넌트 등록(온보딩 1단계). */
|
||||||
|
@Audited(action = "TENANT_CREATE", targetType = "tenant")
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<TenantAdminDto> create(@AuthenticationPrincipal KintexPrincipal principal,
|
||||||
|
@RequestBody TenantCreateRequest req) {
|
||||||
|
guard.requireAdmin(principal);
|
||||||
|
return ApiResponse.ok(service.create(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
package com.zioinfo.kintex.tenant;
|
||||||
|
|
||||||
|
import com.zioinfo.kintex.common.error.ApiException;
|
||||||
|
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||||
|
import com.zioinfo.kintex.tenant.dto.TenantAdminDto;
|
||||||
|
import com.zioinfo.kintex.tenant.dto.TenantCreateRequest;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플랫폼 슈퍼관리자 테넌트 관리·온보딩 서비스 (M18 · §1A-4).
|
||||||
|
* 1단계는 테넌트 마스터 목록 조회 + 등록(온보딩 위저드 1단계)만 제공한다. 격리 스코핑 전 스윕은 2단계.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class TenantAdminService {
|
||||||
|
|
||||||
|
/** 코드 규칙: 소문자·숫자·하이픈, 2~40자(서브도메인·tenant_id 안전). */
|
||||||
|
private static final Pattern CODE = Pattern.compile("^[a-z0-9-]{2,40}$");
|
||||||
|
|
||||||
|
private final TenantMapper mapper;
|
||||||
|
private final TenantResolver resolver;
|
||||||
|
|
||||||
|
public TenantAdminService(TenantMapper mapper, TenantResolver resolver) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
this.resolver = resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전 테넌트 목록(행사·사용자 수 포함). */
|
||||||
|
public List<TenantAdminDto> list() {
|
||||||
|
List<Map<String, Object>> rows = mapper.findAdminList();
|
||||||
|
List<TenantAdminDto> out = new ArrayList<>(rows == null ? 0 : rows.size());
|
||||||
|
if (rows != null) {
|
||||||
|
for (Map<String, Object> r : rows) {
|
||||||
|
out.add(new TenantAdminDto(
|
||||||
|
str(r.get("code")), str(r.get("name")), str(r.get("domain")),
|
||||||
|
str(r.get("status")), lng(r.get("events")), lng(r.get("users")),
|
||||||
|
str(r.get("createdAt"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 테넌트 등록(온보딩 1단계). 코드 정규화·검증·중복 차단 후 마스터 생성, 해소 캐시 무효화. */
|
||||||
|
@Transactional
|
||||||
|
public TenantAdminDto create(TenantCreateRequest req) {
|
||||||
|
if (req == null || req.code() == null || req.code().isBlank()) {
|
||||||
|
throw new ApiException(ErrorCode.VALIDATION, "테넌트 코드는 필수입니다.");
|
||||||
|
}
|
||||||
|
String code = req.code().trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (!CODE.matcher(code).matches()) {
|
||||||
|
throw new ApiException(ErrorCode.VALIDATION, "테넌트 코드는 소문자·숫자·하이픈 2~40자여야 합니다.");
|
||||||
|
}
|
||||||
|
if (req.name() == null || req.name().isBlank()) {
|
||||||
|
throw new ApiException(ErrorCode.VALIDATION, "표시명은 필수입니다.");
|
||||||
|
}
|
||||||
|
if (mapper.existsById(code) > 0) {
|
||||||
|
throw new ApiException(ErrorCode.CONFLICT, "이미 존재하는 테넌트 코드입니다.");
|
||||||
|
}
|
||||||
|
String domain = req.domain() == null ? null : req.domain().trim().toLowerCase(Locale.ROOT);
|
||||||
|
String status = (req.status() == null || req.status().isBlank()) ? "onboarding" : req.status().trim();
|
||||||
|
mapper.insert(code, req.name().trim(), domain, status);
|
||||||
|
resolver.invalidate();
|
||||||
|
return new TenantAdminDto(code, req.name().trim(), domain, status, 0L, 0L, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(Object o) {
|
||||||
|
return o == null ? null : String.valueOf(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long lng(Object o) {
|
||||||
|
if (o instanceof Number n) {
|
||||||
|
return n.longValue();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return o == null ? 0L : Long.parseLong(String.valueOf(o));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package com.zioinfo.kintex.tenant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청별 테넌트(전시관) 컨텍스트 — ThreadLocal (PLANNING §1A-3 · §8-2).
|
||||||
|
* <p>{@link TenantContextFilter}가 요청 처리 전에 해소한 {@code tenant_id}를 주입하고, 처리 후 정리한다.
|
||||||
|
* 서비스/매퍼는 클라이언트 파라미터가 아닌 <b>이 컨텍스트</b>를 신뢰 원천으로 스코프한다(위조 차단, §8-2).
|
||||||
|
*
|
||||||
|
* <p>미설정(비 HTTP·테스트 등) 시 {@link #DEFAULT_TENANT}('kintex' = 테넌트 #1)로 폴백 →
|
||||||
|
* 단일 테넌트 동작과 완전히 동일(회귀 0). 2단계에서 fail-closed 강제 전환은 설계 노트 참조.
|
||||||
|
*/
|
||||||
|
public final class TenantContext {
|
||||||
|
|
||||||
|
/** 기준 테넌트 #1(KINTEX). 스키마 DEFAULT 'kintex'와 일치. */
|
||||||
|
public static final String DEFAULT_TENANT = "kintex";
|
||||||
|
|
||||||
|
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
|
||||||
|
|
||||||
|
private TenantContext() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 해소된 테넌트 주입(필터 전용). */
|
||||||
|
public static void set(String tenantId) {
|
||||||
|
HOLDER.set(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재 테넌트 — 미설정 시 기준 테넌트('kintex')로 폴백(회귀 0). */
|
||||||
|
public static String currentTenantId() {
|
||||||
|
String v = HOLDER.get();
|
||||||
|
return (v == null || v.isBlank()) ? DEFAULT_TENANT : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 명시적으로 설정된 값(폴백 없음) — null 이면 미해소. 진단용. */
|
||||||
|
public static String rawTenantId() {
|
||||||
|
return HOLDER.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 요청 종료 시 정리(스레드 풀 누수 방지). */
|
||||||
|
public static void clear() {
|
||||||
|
HOLDER.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
package com.zioinfo.kintex.tenant;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.zioinfo.kintex.common.ApiResponse;
|
||||||
|
import com.zioinfo.kintex.common.ApiResponse.ApiError;
|
||||||
|
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테넌트 컨텍스트 해소 필터 (PLANNING §1A-3 · §8-2 필터 계층).
|
||||||
|
* <p>해소 우선순위: ① {@code X-Tenant-Id} 헤더 → ② Host 도메인 매칭 → ③ 기준 테넌트('kintex').
|
||||||
|
* 명시적 {@code X-Tenant-Id}가 존재하지 않는 테넌트를 가리키면 <b>400</b>(오접속·위조 차단).
|
||||||
|
* 그 외(헤더 없음·도메인 미매칭)는 기준 테넌트로 폴백해 기존 동작을 완전히 보존한다(회귀 0, 1단계).
|
||||||
|
* <p>2단계에서 fail-closed(미해소 거부)·JWT 테넌트 클레임 검증으로 강화 — 설계 노트 참조.
|
||||||
|
*/
|
||||||
|
public class TenantContextFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final String HEADER = "X-Tenant-Id";
|
||||||
|
|
||||||
|
private final TenantResolver resolver;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public TenantContextFilter(TenantResolver resolver, ObjectMapper objectMapper) {
|
||||||
|
this.resolver = resolver;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
FilterChain chain) throws ServletException, IOException {
|
||||||
|
String tenantId;
|
||||||
|
String headerTenant = request.getHeader(HEADER);
|
||||||
|
if (headerTenant != null && !headerTenant.isBlank()) {
|
||||||
|
String candidate = headerTenant.trim();
|
||||||
|
if (!resolver.exists(candidate)) {
|
||||||
|
writeBadTenant(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tenantId = candidate;
|
||||||
|
} else {
|
||||||
|
// 도메인 매칭(§1A-3) → 폴백 기준 테넌트.
|
||||||
|
String byDomain = resolver.resolveByDomain(request.getHeader("Host"));
|
||||||
|
tenantId = (byDomain != null) ? byDomain : TenantContext.DEFAULT_TENANT;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
TenantContext.set(tenantId);
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
} finally {
|
||||||
|
TenantContext.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeBadTenant(HttpServletResponse response) throws IOException {
|
||||||
|
response.setStatus(ErrorCode.VALIDATION.status().value());
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.setCharacterEncoding("UTF-8");
|
||||||
|
response.getWriter().write(objectMapper.writeValueAsString(
|
||||||
|
ApiResponse.fail(new ApiError(ErrorCode.VALIDATION.name(),
|
||||||
|
"알 수 없는 테넌트입니다."))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package com.zioinfo.kintex.tenant;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테넌트 마스터 매퍼 (V26 {@code tenant}) — 컨텍스트 해소 + 플랫폼 관리자 온보딩용.
|
||||||
|
* <p>★ PG 소문자 폴딩 회피: Map 반환 카멜케이스 별칭은 반드시 {@code AS "camelCase"} 쌍따옴표.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface TenantMapper {
|
||||||
|
|
||||||
|
/** 컨텍스트 해소용 (id, domain, status) 전량 — 소규모 마스터(캐시 대상). */
|
||||||
|
@Select("""
|
||||||
|
SELECT id AS "id",
|
||||||
|
domain AS "domain",
|
||||||
|
status AS "status"
|
||||||
|
FROM tenant
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> findAllForResolve();
|
||||||
|
|
||||||
|
/** 존재 여부(상태 무관) — 등록 API 중복 검사·해소 검증. */
|
||||||
|
@Select("SELECT count(*) FROM tenant WHERE id = #{id}")
|
||||||
|
long existsById(@Param("id") String id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 테넌트 목록 — 행사/사용자 수 포함(프론트 SCR-A9 계약: code·name·domain·status·events·users).
|
||||||
|
* code=id 로 매핑(불변 코드). 카운트는 tenant_id 상관 서브쿼리(1단계 핵심 테이블 기준).
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
SELECT t.id AS "code",
|
||||||
|
t.name AS "name",
|
||||||
|
t.domain AS "domain",
|
||||||
|
t.status AS "status",
|
||||||
|
(SELECT count(*) FROM event e WHERE e.tenant_id = t.id) AS "events",
|
||||||
|
(SELECT count(*) FROM app_user u WHERE u.tenant_id = t.id) AS "users",
|
||||||
|
to_char(t.created_at, 'YYYY-MM-DD') AS "createdAt"
|
||||||
|
FROM tenant t
|
||||||
|
ORDER BY t.created_at ASC, t.id ASC
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> findAdminList();
|
||||||
|
|
||||||
|
/** 테넌트 등록(온보딩 1단계 — 마스터 생성). status 기본 onboarding. */
|
||||||
|
@org.apache.ibatis.annotations.Insert("""
|
||||||
|
INSERT INTO tenant (id, name, domain, status)
|
||||||
|
VALUES (#{id}, #{name}, #{domain}, #{status})
|
||||||
|
""")
|
||||||
|
int insert(@Param("id") String id, @Param("name") String name,
|
||||||
|
@Param("domain") String domain, @Param("status") String status);
|
||||||
|
}
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
package com.zioinfo.kintex.tenant;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테넌트 해소 캐시 — {@code tenant} 마스터를 메모리에 적재해 요청당 DB 조회를 피한다(소규모 마스터).
|
||||||
|
* <p>해소 우선순위(§1A-3): ① {@code X-Tenant-Id} 헤더 → ② Host 도메인 매칭 → ③ 기준 테넌트('kintex').
|
||||||
|
* 캐시 미스(신규 온보딩 직후) 시 1회 재적재 후 재판정. 등록 API는 {@link #invalidate()}로 무효화한다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class TenantResolver {
|
||||||
|
|
||||||
|
private final TenantMapper mapper;
|
||||||
|
|
||||||
|
/** id → 존재 여부(status 무관, 격리 스코프 존재 판정). */
|
||||||
|
private volatile Map<String, Boolean> idSet = Map.of();
|
||||||
|
/** 소문자 domain → tenant id. */
|
||||||
|
private volatile Map<String, String> domainToId = Map.of();
|
||||||
|
|
||||||
|
public TenantResolver(TenantMapper mapper) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 알려진 테넌트인지(캐시 미스 시 1회 재적재). */
|
||||||
|
public boolean exists(String tenantId) {
|
||||||
|
if (tenantId == null || tenantId.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (idSet.containsKey(tenantId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
reload();
|
||||||
|
return idSet.containsKey(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host 헤더(도메인)로 테넌트 id 해소 — 없으면 null. */
|
||||||
|
public String resolveByDomain(String host) {
|
||||||
|
if (host == null || host.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String h = normalizeHost(host);
|
||||||
|
String id = domainToId.get(h);
|
||||||
|
if (id == null) {
|
||||||
|
reload();
|
||||||
|
id = domainToId.get(h);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 마스터 변경(온보딩 등록) 후 캐시 무효화. */
|
||||||
|
public void invalidate() {
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void reload() {
|
||||||
|
List<Map<String, Object>> rows = mapper.findAllForResolve();
|
||||||
|
Map<String, Boolean> ids = new HashMap<>();
|
||||||
|
Map<String, String> domains = new HashMap<>();
|
||||||
|
if (rows != null) {
|
||||||
|
for (Map<String, Object> r : rows) {
|
||||||
|
String id = str(r.get("id"));
|
||||||
|
if (id == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ids.put(id, Boolean.TRUE);
|
||||||
|
String domain = str(r.get("domain"));
|
||||||
|
if (domain != null && !domain.isBlank()) {
|
||||||
|
domains.put(normalizeHost(domain), id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.idSet = Map.copyOf(ids);
|
||||||
|
this.domainToId = Map.copyOf(domains);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 포트 제거 + 소문자화(호스트/도메인 정규화). */
|
||||||
|
private static String normalizeHost(String host) {
|
||||||
|
String h = host.trim().toLowerCase();
|
||||||
|
int colon = h.indexOf(':');
|
||||||
|
return colon >= 0 ? h.substring(0, colon) : h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(Object o) {
|
||||||
|
return o == null ? null : String.valueOf(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.zioinfo.kintex.tenant.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 테넌트 목록 행 — 프론트 SCR-A9(TenantAdminPage) 샘플이 소비하던 계약과 정합.
|
||||||
|
* (code·name·domain·status·events·users). {@code code}=tenant.id(불변 코드).
|
||||||
|
*/
|
||||||
|
public record TenantAdminDto(
|
||||||
|
String code,
|
||||||
|
String name,
|
||||||
|
String domain,
|
||||||
|
String status,
|
||||||
|
long events,
|
||||||
|
long users,
|
||||||
|
String createdAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.zioinfo.kintex.tenant.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테넌트 등록(온보딩 1단계 — 마스터 생성) 요청. 플랫폼 슈퍼관리자 전용.
|
||||||
|
*
|
||||||
|
* @param code 불변 테넌트 코드(소문자 slug, tenant_id 전파 값). 예: 'coex'
|
||||||
|
* @param name 표시명. 예: 'COEX'
|
||||||
|
* @param domain 접속 서브도메인(컨텍스트 해소 키, §1A-3). 예: 'coex.wise.ai.kr'
|
||||||
|
* @param status 초기 상태(생략 시 'onboarding').
|
||||||
|
*/
|
||||||
|
public record TenantCreateRequest(
|
||||||
|
String code,
|
||||||
|
String name,
|
||||||
|
String domain,
|
||||||
|
String status
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
-- V26 멀티테넌시(다중 전시관 SaaS) 1단계 — 기반 스키마 (PLANNING §1A · §8-2)
|
||||||
|
-- 목표: 테넌트 마스터 신설 + 핵심 테이블 tenant_id 순증(공유 스키마 + tenant_id 논리 격리).
|
||||||
|
-- 규칙(불변): V1~V25 불변. 전부 멱등(CREATE/ALTER ... IF NOT EXISTS + DEFAULT 백필)으로 재실행 안전.
|
||||||
|
-- 기존 데이터는 DEFAULT 'kintex'(테넌트 #1)로 자동 백필 → 단일 테넌트 운영 무중단·회귀 0.
|
||||||
|
-- 범위(1단계): 테넌트 마스터 + 핵심 경로 테이블(event·hall·app_user). 나머지 도메인 매퍼 스윕은 2단계.
|
||||||
|
|
||||||
|
-- 1) 테넌트 마스터 = 전시관(Venue) (§1A-1) --------------------------------------
|
||||||
|
-- id = tenant_id 값(불변 코드, 소문자 slug) / name = 표시 코드·명칭 / domain = 컨텍스트 해소 키(§1A-3).
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant (
|
||||||
|
id varchar(40) PRIMARY KEY, -- 'kintex','coex' … (tenant_id 전파 값)
|
||||||
|
name varchar(120) NOT NULL, -- 'KINTEX','COEX' 등 표시명
|
||||||
|
domain varchar(200), -- 접속 서브도메인(호스트 매칭 키)
|
||||||
|
status varchar(20) NOT NULL DEFAULT 'active', -- active | onboarding | suspended
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE tenant IS '전시관(Venue) 테넌트 마스터 — 최상위 격리 스코프(§1A-1). KINTEX=테넌트 #1.';
|
||||||
|
COMMENT ON COLUMN tenant.id IS '테넌트 식별 코드(불변) — 전 도메인 tenant_id 전파 값';
|
||||||
|
COMMENT ON COLUMN tenant.domain IS '컨텍스트 해소용 접속 도메인(§1A-3, 호스트 헤더 매칭)';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tenant_domain ON tenant (domain);
|
||||||
|
|
||||||
|
-- 기준 테넌트 #1 시드(멱등). 기존 데이터의 DEFAULT 'kintex' 백필 대상과 일치.
|
||||||
|
INSERT INTO tenant (id, name, domain, status)
|
||||||
|
VALUES ('kintex', 'KINTEX', 'kintex.zioinfo.co.kr', 'active')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 2) 핵심 테이블 tenant_id 순증 (§1A-2 · §8-2 공유 스키마) -------------------------
|
||||||
|
-- NOT NULL DEFAULT 'kintex' → 기존 전 행이 테넌트 #1로 안전 백필(회귀 0).
|
||||||
|
ALTER TABLE event ADD COLUMN IF NOT EXISTS tenant_id varchar(40) NOT NULL DEFAULT 'kintex';
|
||||||
|
ALTER TABLE hall ADD COLUMN IF NOT EXISTS tenant_id varchar(40) NOT NULL DEFAULT 'kintex';
|
||||||
|
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS tenant_id varchar(40) NOT NULL DEFAULT 'kintex';
|
||||||
|
|
||||||
|
-- 3) 테넌트 선두 복합 인덱스 (§8-2 성능 — 격리 쿼리는 tenant_id 선행 조건) ----------
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_tenant ON event (tenant_id, start_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hall_tenant ON hall (tenant_id, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_app_user_tenant ON app_user (tenant_id);
|
||||||
@ -20,6 +20,7 @@
|
|||||||
is_assumed_trench AS "assumedTrench"
|
is_assumed_trench AS "assumedTrench"
|
||||||
FROM hall
|
FROM hall
|
||||||
WHERE id = #{hallId}
|
WHERE id = #{hallId}
|
||||||
|
AND tenant_id = #{tenantId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- 홀 트렌치 좌표(가정 그리드 포함). -->
|
<!-- 홀 트렌치 좌표(가정 그리드 포함). -->
|
||||||
|
|||||||
93
src/frontend/package-lock.json
generated
93
src/frontend/package-lock.json
generated
@ -10,8 +10,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stomp/stompjs": "^7.0.0",
|
"@stomp/stompjs": "^7.0.0",
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
|
"i18next": "^23.16.8",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^15.7.4",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"recharts": "^3.9.2",
|
"recharts": "^3.9.2",
|
||||||
"sockjs-client": "^1.6.1",
|
"sockjs-client": "^1.6.1",
|
||||||
@ -262,6 +265,15 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
@ -1731,18 +1743,59 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-parse-stringify": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"void-elements": "3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-parser-js": {
|
"node_modules/http-parser-js": {
|
||||||
"version": "0.5.10",
|
"version": "0.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
|
||||||
"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==",
|
"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "23.16.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz",
|
||||||
|
"integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://locize.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://locize.com/i18next.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.23.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/i18next-browser-languagedetector": {
|
||||||
|
"version": "8.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||||
|
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.23.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/immer": {
|
"node_modules/immer": {
|
||||||
"version": "11.1.11",
|
"version": "11.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
|
||||||
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
|
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/immer"
|
"url": "https://opencollective.com/immer"
|
||||||
@ -1921,6 +1974,32 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-i18next": {
|
||||||
|
"version": "15.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
|
||||||
|
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.27.6",
|
||||||
|
"html-parse-stringify": "^3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"i18next": ">= 23.4.0",
|
||||||
|
"react": ">= 16.8.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
||||||
@ -2184,8 +2263,9 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -2334,6 +2414,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/void-elements": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/websocket-driver": {
|
"node_modules/websocket-driver": {
|
||||||
"version": "0.7.5",
|
"version": "0.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz",
|
||||||
|
|||||||
@ -14,8 +14,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stomp/stompjs": "^7.0.0",
|
"@stomp/stompjs": "^7.0.0",
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
|
"i18next": "^23.16.8",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^15.7.4",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"recharts": "^3.9.2",
|
"recharts": "^3.9.2",
|
||||||
"sockjs-client": "^1.6.1",
|
"sockjs-client": "^1.6.1",
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useThemeStore } from '../../store/themeStore';
|
||||||
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
||||||
import { DdayChip } from '../ui/Badge';
|
import { DdayChip } from '../ui/Badge';
|
||||||
|
import { LanguageSwitcher } from '../ui/LanguageSwitcher';
|
||||||
import {
|
import {
|
||||||
IconAnalytics,
|
IconAnalytics,
|
||||||
IconBell,
|
IconBell,
|
||||||
@ -12,10 +14,12 @@ import {
|
|||||||
IconDocument,
|
IconDocument,
|
||||||
IconExhibitors,
|
IconExhibitors,
|
||||||
IconFloorplan,
|
IconFloorplan,
|
||||||
|
IconMoon,
|
||||||
IconOperations,
|
IconOperations,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconSettlement,
|
IconSettlement,
|
||||||
|
IconSun,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
type IconProps,
|
type IconProps,
|
||||||
} from '../ui/icons';
|
} from '../ui/icons';
|
||||||
@ -92,8 +96,11 @@ export function AppShell() {
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const workspace = useAuthStore((s) => s.currentWorkspace());
|
const workspace = useAuthStore((s) => s.currentWorkspace());
|
||||||
const logout = useAuthStore((s) => s.logout);
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const themeResolved = useThemeStore((s) => s.resolved);
|
||||||
|
const toggleTheme = useThemeStore((s) => s.toggle);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const resolvedEventId = useResolvedEventId();
|
const resolvedEventId = useResolvedEventId();
|
||||||
|
const isDark = themeResolved === 'dark';
|
||||||
|
|
||||||
/** 행사 스코프 항목 진입 — 대상 행사 해소 시 계산 경로로, 미해소 시 행사 선택(/login)으로. */
|
/** 행사 스코프 항목 진입 — 대상 행사 해소 시 계산 경로로, 미해소 시 행사 선택(/login)으로. */
|
||||||
function goScoped(key: string) {
|
function goScoped(key: string) {
|
||||||
@ -107,6 +114,9 @@ export function AppShell() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kx-shell">
|
<div className="kx-shell">
|
||||||
|
<a className="kx-skip-link" href="#kx-main-content">
|
||||||
|
본문 바로가기
|
||||||
|
</a>
|
||||||
<nav className="kx-shell__sidebar" aria-label="주 메뉴">
|
<nav className="kx-shell__sidebar" aria-label="주 메뉴">
|
||||||
<div className="kx-shell__brand">
|
<div className="kx-shell__brand">
|
||||||
<span className="kx-shell__brand-mark" aria-hidden="true">K</span>
|
<span className="kx-shell__brand-mark" aria-hidden="true">K</span>
|
||||||
@ -196,6 +206,17 @@ export function AppShell() {
|
|||||||
>
|
>
|
||||||
{user?.displayName ?? '사용자'}
|
{user?.displayName ?? '사용자'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="kx-shell__theme-toggle"
|
||||||
|
type="button"
|
||||||
|
aria-label={isDark ? '라이트 모드로 전환' : '다크 모드로 전환'}
|
||||||
|
aria-pressed={isDark}
|
||||||
|
title={isDark ? '라이트 모드로 전환' : '다크 모드로 전환'}
|
||||||
|
onClick={toggleTheme}
|
||||||
|
>
|
||||||
|
{isDark ? <IconSun size={18} /> : <IconMoon size={18} />}
|
||||||
|
</button>
|
||||||
|
<LanguageSwitcher variant="dark" />
|
||||||
<button className="kx-shell__logout" type="button" onClick={logout}>
|
<button className="kx-shell__logout" type="button" onClick={logout}>
|
||||||
로그아웃
|
로그아웃
|
||||||
</button>
|
</button>
|
||||||
@ -222,7 +243,7 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="kx-shell__content">
|
<main className="kx-shell__content" id="kx-main-content">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -137,6 +137,24 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-neutral-700);
|
color: var(--color-neutral-700);
|
||||||
}
|
}
|
||||||
|
.kx-shell__theme-toggle {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: var(--border-card);
|
||||||
|
background: var(--color-neutral-050);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.kx-shell__theme-toggle:hover {
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
}
|
||||||
|
|
||||||
.kx-shell__logout {
|
.kx-shell__logout {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
101
src/frontend/src/components/ui/LanguageSwitcher.tsx
Normal file
101
src/frontend/src/components/ui/LanguageSwitcher.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* LanguageSwitcher — 다국어 전환 셀렉트(선 SVG 글로브 아이콘 자체 정의, 이모지 금지).
|
||||||
|
* i18next 언어를 읽고/변경하며, 선택은 languagedetector가 localStorage에 지속화한다.
|
||||||
|
* 자체 완결형(외부 CSS 비의존) — 공개 셸·로그인·AppShell 어디에나 배선 가능.
|
||||||
|
*
|
||||||
|
* AppShell 배선은 통합자 몫 — 여기서는 export만 제공한다.
|
||||||
|
*/
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { SUPPORTED_LANGS, LANG_LABELS } from '../../i18n';
|
||||||
|
|
||||||
|
/** 선 스타일 글로브 아이콘(fill:none·stroke:currentColor). */
|
||||||
|
function GlobeIcon({ size = 18 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M3 12h18M12 3c2.5 2.5 2.5 15 0 18M12 3c-2.5 2.5-2.5 15 0 18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LanguageSwitcherProps {
|
||||||
|
/** 밝은 헤더(light) 또는 어두운 배경(dark) 대비 — 텍스트 색 결정. */
|
||||||
|
variant?: 'light' | 'dark';
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageSwitcher({ variant = 'light', className, style }: LanguageSwitcherProps) {
|
||||||
|
const { i18n, t } = useTranslation();
|
||||||
|
// resolvedLanguage 는 지역 변형을 축약한 실제 적용 언어.
|
||||||
|
const current = (i18n.resolvedLanguage ?? i18n.language ?? 'ko').split('-')[0];
|
||||||
|
const color = variant === 'dark' ? '#ffffff' : 'var(--color-neutral-700, #344054)';
|
||||||
|
|
||||||
|
const wrapStyle: CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
color,
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectStyle: CSSProperties = {
|
||||||
|
appearance: 'none',
|
||||||
|
WebkitAppearance: 'none',
|
||||||
|
MozAppearance: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
font: 'inherit',
|
||||||
|
fontSize: 'var(--fs-caption, 13px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '4px 2px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
outline: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className={className} style={wrapStyle}>
|
||||||
|
<GlobeIcon />
|
||||||
|
<span className="sr-only" style={srOnly}>
|
||||||
|
{t('lang.select')}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
aria-label={t('lang.select')}
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => void i18n.changeLanguage(e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
{SUPPORTED_LANGS.map((code) => (
|
||||||
|
<option key={code} value={code} style={{ color: '#1a1a1a' }}>
|
||||||
|
{LANG_LABELS[code]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const srOnly: CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
padding: 0,
|
||||||
|
margin: -1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
clip: 'rect(0,0,0,0)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
border: 0,
|
||||||
|
};
|
||||||
@ -31,7 +31,7 @@
|
|||||||
font-size: var(--fs-caption);
|
font-size: var(--fs-caption);
|
||||||
}
|
}
|
||||||
.kx-aiimg__skeleton {
|
.kx-aiimg__skeleton {
|
||||||
background: linear-gradient(100deg, #ece9fb 30%, #f5f3ff 50%, #ece9fb 70%);
|
background: linear-gradient(100deg, var(--ai-skeleton-a) 30%, var(--ai-skeleton-b) 50%, var(--ai-skeleton-a) 70%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: kx-shimmer 1.4s infinite;
|
animation: kx-shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
@ -52,7 +52,7 @@
|
|||||||
}
|
}
|
||||||
.kx-aiimg__state--failed {
|
.kx-aiimg__state--failed {
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
background: #fef3f2;
|
background: var(--color-error-bg);
|
||||||
}
|
}
|
||||||
.kx-aiimg__retry {
|
.kx-aiimg__retry {
|
||||||
border: 1px solid var(--color-error);
|
border: 1px solid var(--color-error);
|
||||||
@ -121,7 +121,7 @@
|
|||||||
box-shadow: var(--shadow-level2);
|
box-shadow: var(--shadow-level2);
|
||||||
}
|
}
|
||||||
.kx-aiimg__gen:hover {
|
.kx-aiimg__gen:hover {
|
||||||
background: #5b3aeb;
|
background: var(--color-ai-accent-strong);
|
||||||
}
|
}
|
||||||
.kx-aiimg__notice {
|
.kx-aiimg__notice {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@ -24,15 +24,15 @@
|
|||||||
color: var(--color-ai-accent);
|
color: var(--color-ai-accent);
|
||||||
}
|
}
|
||||||
.kx-badge--status-approved {
|
.kx-badge--status-approved {
|
||||||
background: #e6f4ee;
|
background: var(--color-success-bg);
|
||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
.kx-badge--status-rejected {
|
.kx-badge--status-rejected {
|
||||||
background: #fef3f2;
|
background: var(--color-error-bg);
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
.kx-badge--status-building {
|
.kx-badge--status-building {
|
||||||
background: #ede9fe;
|
background: var(--color-ai-bg);
|
||||||
color: var(--status-building);
|
color: var(--status-building);
|
||||||
}
|
}
|
||||||
.kx-badge--status-inspected {
|
.kx-badge--status-inspected {
|
||||||
@ -51,11 +51,11 @@
|
|||||||
color: var(--color-neutral-700);
|
color: var(--color-neutral-700);
|
||||||
}
|
}
|
||||||
.kx-badge--dday-warn {
|
.kx-badge--dday-warn {
|
||||||
background: #fff4e5;
|
background: var(--color-warning-bg);
|
||||||
color: var(--color-warning);
|
color: var(--color-warning);
|
||||||
}
|
}
|
||||||
.kx-badge--dday-over {
|
.kx-badge--dday-over {
|
||||||
background: #fef3f2;
|
background: var(--color-error-bg);
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -45,7 +45,7 @@
|
|||||||
.kx-btn--ai {
|
.kx-btn--ai {
|
||||||
background: var(--color-ai-accent);
|
background: var(--color-ai-accent);
|
||||||
color: var(--color-white);
|
color: var(--color-white);
|
||||||
box-shadow: 0 0 12px rgba(109, 74, 255, 0.3);
|
box-shadow: 0 0 12px var(--ai-glow);
|
||||||
}
|
}
|
||||||
.kx-btn--ai:not(:disabled):hover {
|
.kx-btn--ai:not(:disabled):hover {
|
||||||
opacity: 0.92;
|
opacity: 0.92;
|
||||||
@ -65,5 +65,5 @@
|
|||||||
border-color: var(--color-error);
|
border-color: var(--color-error);
|
||||||
}
|
}
|
||||||
.kx-btn--danger:not(:disabled):hover {
|
.kx-btn--danger:not(:disabled):hover {
|
||||||
background: #fef3f2;
|
background: var(--color-error-bg);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -431,6 +431,32 @@ export function IconGrid(p: IconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ☀ 해(라이트 테마). */
|
||||||
|
export function IconSun(p: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...p}>
|
||||||
|
<circle cx="12" cy="12" r="4" {...S} />
|
||||||
|
<path d="M12 2V4" {...SR} />
|
||||||
|
<path d="M12 20V22" {...SR} />
|
||||||
|
<path d="M4.9 4.9L6.3 6.3" {...SR} />
|
||||||
|
<path d="M17.7 17.7L19.1 19.1" {...SR} />
|
||||||
|
<path d="M2 12H4" {...SR} />
|
||||||
|
<path d="M20 12H22" {...SR} />
|
||||||
|
<path d="M4.9 19.1L6.3 17.7" {...SR} />
|
||||||
|
<path d="M17.7 6.3L19.1 4.9" {...SR} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ☾ 달(다크 테마). */
|
||||||
|
export function IconMoon(p: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M21 12.8C20.5 17 16.9 20.2 12.6 20.2C7.9 20.2 4 16.3 4 11.6C4 7.3 7.2 3.7 11.4 3.2C10.3 4.6 9.6 6.4 9.6 8.3C9.6 12.9 13.4 16.6 18 16.6C19 16.6 20 16.4 21 16Z" {...SRJ} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** ● 오프라인(연결 끊김). */
|
/** ● 오프라인(연결 끊김). */
|
||||||
export function IconWifiOff(p: IconProps) {
|
export function IconWifiOff(p: IconProps) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
.kx-skeleton {
|
.kx-skeleton {
|
||||||
display: block;
|
display: block;
|
||||||
background: linear-gradient(100deg, #eceff3 30%, #f6f8fa 50%, #eceff3 70%);
|
background: linear-gradient(100deg, var(--skeleton-a) 30%, var(--skeleton-b) 50%, var(--skeleton-a) 70%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: kx-skel 1.4s infinite;
|
animation: kx-skel 1.4s infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/frontend/src/i18n/index.ts
Normal file
72
src/frontend/src/i18n/index.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* i18n 초기화 (react-i18next) — 킨텍스 자동전시시스템 다국어 프레임워크.
|
||||||
|
* 지원 언어: 한국어(ko, 기본/fallback)·English(en)·中文 간체(zh)·日本語(ja).
|
||||||
|
* 감지 우선순위: localStorage(kintex_lang) → 브라우저(navigator) → fallback ko.
|
||||||
|
* 선택 언어는 languagedetector가 localStorage에 지속화한다(별도 store 불필요).
|
||||||
|
*
|
||||||
|
* 적용 패턴(다른 화면 팀 참고):
|
||||||
|
* import { useTranslation } from 'react-i18next';
|
||||||
|
* const { t } = useTranslation();
|
||||||
|
* <h1>{t('public.home.heroTitle')}</h1>
|
||||||
|
* 키 네이밍: `<영역>.<화면>.<요소>` (예: public.home.heroTitle, login.title, common.actions.next).
|
||||||
|
*/
|
||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
|
||||||
|
import ko from './locales/ko.json';
|
||||||
|
import en from './locales/en.json';
|
||||||
|
import zh from './locales/zh.json';
|
||||||
|
import ja from './locales/ja.json';
|
||||||
|
|
||||||
|
/** 지원 언어 코드(순서 = 스위처 노출 순서). */
|
||||||
|
export const SUPPORTED_LANGS = ['ko', 'en', 'zh', 'ja'] as const;
|
||||||
|
export type LangCode = (typeof SUPPORTED_LANGS)[number];
|
||||||
|
|
||||||
|
/** 스위처 표시용 각 언어의 자국어 라벨. */
|
||||||
|
export const LANG_LABELS: Record<LangCode, string> = {
|
||||||
|
ko: '한국어',
|
||||||
|
en: 'English',
|
||||||
|
zh: '中文',
|
||||||
|
ja: '日本語',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** localStorage 키(languagedetector cache) — WISE 컨벤션에 맞춘 접두사. */
|
||||||
|
export const LANG_STORAGE_KEY = 'kintex_lang';
|
||||||
|
|
||||||
|
void i18n
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources: {
|
||||||
|
ko: { translation: ko },
|
||||||
|
en: { translation: en },
|
||||||
|
zh: { translation: zh },
|
||||||
|
ja: { translation: ja },
|
||||||
|
},
|
||||||
|
fallbackLng: 'ko',
|
||||||
|
supportedLngs: SUPPORTED_LANGS as unknown as string[],
|
||||||
|
// 지역 변형(en-US 등)은 기본 언어(en)로 축약.
|
||||||
|
load: 'languageOnly',
|
||||||
|
nonExplicitSupportedLngs: true,
|
||||||
|
detection: {
|
||||||
|
order: ['localStorage', 'navigator', 'htmlTag'],
|
||||||
|
lookupLocalStorage: LANG_STORAGE_KEY,
|
||||||
|
caches: ['localStorage'],
|
||||||
|
},
|
||||||
|
interpolation: {
|
||||||
|
// React가 XSS 이스케이프를 담당하므로 비활성.
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
returnEmptyString: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 문서 lang 속성 동기화(접근성/SEO) — 초기값 + 변경 시.
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.lang = i18n.resolvedLanguage ?? 'ko';
|
||||||
|
i18n.on('languageChanged', (lng) => {
|
||||||
|
document.documentElement.lang = lng;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
521
src/frontend/src/i18n/locales/en.json
Normal file
521
src/frontend/src/i18n/locales/en.json
Normal file
@ -0,0 +1,521 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"brand": "KINTEX",
|
||||||
|
"appSuffix": "AI Exhibition Management",
|
||||||
|
"actions": {
|
||||||
|
"prev": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"close": "Close",
|
||||||
|
"home": "Go to Home",
|
||||||
|
"viewMore": "View Details",
|
||||||
|
"confirm": "Confirm"
|
||||||
|
},
|
||||||
|
"states": {
|
||||||
|
"loading": "Loading…",
|
||||||
|
"empty": "Nothing to display."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"select": "Select language"
|
||||||
|
},
|
||||||
|
"shell": {
|
||||||
|
"nav": {
|
||||||
|
"events": "Events",
|
||||||
|
"exhibit": "Exhibitors",
|
||||||
|
"visit": "Visit",
|
||||||
|
"transport": "Directions"
|
||||||
|
},
|
||||||
|
"searchPlaceholder": "Search events",
|
||||||
|
"cta": { "register": "Visitor Pre-registration" },
|
||||||
|
"menuOpen": "Open menu",
|
||||||
|
"menuClose": "Close menu",
|
||||||
|
"homeAria": "{{brand}} home",
|
||||||
|
"primaryMenu": "Main menu",
|
||||||
|
"mobileMenu": "Mobile menu",
|
||||||
|
"footer": {
|
||||||
|
"brandDesc": "Korea's largest exhibition and convention center, connecting innovation in business and culture.",
|
||||||
|
"quickTitle": "Quick Links",
|
||||||
|
"quickSchedule": "Event Schedule",
|
||||||
|
"quickExhibit": "Exhibitor Info",
|
||||||
|
"quickDirections": "Directions",
|
||||||
|
"quickSitemap": "Sitemap",
|
||||||
|
"supportTitle": "Support",
|
||||||
|
"supportPrivacy": "Privacy Policy",
|
||||||
|
"supportTerms": "Terms of Use",
|
||||||
|
"supportContact": "Contact Us",
|
||||||
|
"contactTitle": "Contact",
|
||||||
|
"contactAddress": "217-60 Kintex-ro, Ilsanseo-gu, Goyang-si, Gyeonggi-do",
|
||||||
|
"contactTel": "Main line 1899-1001",
|
||||||
|
"contactEmail": "exhibition@kintex.com",
|
||||||
|
"bottom": "© KINTEX. All Rights Reserved. (10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"heroAria": "About KINTEX",
|
||||||
|
"eyebrow": "Global Exhibition Platform",
|
||||||
|
"heroTitleLine1": "Exhibitions that connect the world,",
|
||||||
|
"heroTitleEm": "KINTEX",
|
||||||
|
"heroLead": "Experience new innovation in business and culture unfolding at KINTEX, Korea's largest exhibition and convention center.",
|
||||||
|
"ctaRegister": "Visitor Pre-registration",
|
||||||
|
"ctaFacility": "View Facilities",
|
||||||
|
"eventsAria": "Ongoing and upcoming events",
|
||||||
|
"eventsTitle": "Ongoing · Upcoming Events",
|
||||||
|
"eventsSub": "Check out the latest happenings at KINTEX right now.",
|
||||||
|
"eventsMore": "View Full Schedule",
|
||||||
|
"loading": "Loading event information…",
|
||||||
|
"empty": "There are no ongoing or upcoming events at this time.",
|
||||||
|
"tagExhibition": "EXHIBITION",
|
||||||
|
"booth": "{{count}} booths",
|
||||||
|
"aiOptimized": "AI Optimized",
|
||||||
|
"ongoing": "Ongoing",
|
||||||
|
"ended": "Ended",
|
||||||
|
"dday": "D-{{n}}",
|
||||||
|
"ddayZero": "D-DAY",
|
||||||
|
"register": "Pre-register",
|
||||||
|
"hallTbd": "Hall to be assigned",
|
||||||
|
"transportAria": "Directions",
|
||||||
|
"transportTitle": "Directions",
|
||||||
|
"transportSub": "Reach KINTEX with even faster accessibility.",
|
||||||
|
"gtxTitle": "GTX-A KINTEX Station Now Open",
|
||||||
|
"gtxDesc": "A 3-minute walk from KINTEX Station",
|
||||||
|
"transit": "Public Transit",
|
||||||
|
"transitDesc": "Metropolitan buses and subway",
|
||||||
|
"parking": "Parking",
|
||||||
|
"parkingDesc": "4,000 vehicles simultaneously",
|
||||||
|
"shuttle": "Shuttle Bus",
|
||||||
|
"shuttleDesc": "Circulating major hubs",
|
||||||
|
"mapAria": "KINTEX location map",
|
||||||
|
"mapAddress": "217-60 Kintex-ro, Ilsanseo-gu, Goyang-si",
|
||||||
|
"mapPlace": "KINTEX Exhibition Center 1 & 2",
|
||||||
|
"mapViewAria": "View map"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"tabs": {
|
||||||
|
"intro": "Overview",
|
||||||
|
"program": "Program",
|
||||||
|
"speakers": "Speakers",
|
||||||
|
"exhibitors": "Exhibitors",
|
||||||
|
"transport": "Directions",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"eyebrow": "KINTEX Exhibition",
|
||||||
|
"loadingTitle": "Loading event information…",
|
||||||
|
"fallbackTitle": "Event Details",
|
||||||
|
"dateTbd": "Dates to be confirmed",
|
||||||
|
"placeTbd": "Hall to be assigned",
|
||||||
|
"lead": "Check the exhibition details at KINTEX and pre-register.",
|
||||||
|
"factDate": "Date",
|
||||||
|
"factPlace": "Venue",
|
||||||
|
"ctaRegister": "Pre-register",
|
||||||
|
"ctaFloorplan": "View Floor Plan",
|
||||||
|
"navAria": "Event detail navigation",
|
||||||
|
"shareAria": "Share",
|
||||||
|
"share": "Share",
|
||||||
|
"copyLink": "Copy link",
|
||||||
|
"overviewAria": "Exhibition overview",
|
||||||
|
"overviewTitle": "Exhibition Overview",
|
||||||
|
"overviewLead": "Smart Factory Korea 2026 is the culmination of next-generation manufacturing technology. Discover the latest solutions combining AI, robotics, IoT, and digital twin technology in one place, with over 500 leading companies from around the world participating.",
|
||||||
|
"statBooth": "Booths",
|
||||||
|
"statVisitors": "Expected Visitors",
|
||||||
|
"statVisitorsValue": "30,000+",
|
||||||
|
"themesTitle": "Key Exhibition Themes",
|
||||||
|
"theme1Title": "AI & Autonomous Robots",
|
||||||
|
"theme1Desc": "Smart robot solutions that learn and decide on their own",
|
||||||
|
"theme2Title": "Digital Twin & IoT",
|
||||||
|
"theme2Desc": "Real-time sync between virtual worlds and real manufacturing sites",
|
||||||
|
"theme3Title": "Carbon-Neutral Smart Manufacturing",
|
||||||
|
"theme3Desc": "Eco-friendly processes maximizing energy efficiency",
|
||||||
|
"programAria": "Program schedule",
|
||||||
|
"programTitle": "Program Schedule",
|
||||||
|
"day1": "Sep 5 (Day 1)",
|
||||||
|
"day2": "Sep 6 (Day 2)",
|
||||||
|
"agenda1Title": "The Future of Manufacturing: AI-Driven Productivity Revolution",
|
||||||
|
"agenda1Desc": "2030 manufacturing strategies and real-world case studies proposed by global AI scholars",
|
||||||
|
"agenda1Room": "Main Conference Hall",
|
||||||
|
"agenda2Title": "Combining Industrial 5G and Edge Computing",
|
||||||
|
"agenda2Desc": "Real-time process control technology demonstration via lag-free data transmission",
|
||||||
|
"agenda2Room": "Seminar Room A",
|
||||||
|
"agenda3Title": "Global Buyers Matching Day",
|
||||||
|
"agenda3Desc": "One-on-one business meetings between promising overseas companies and domestic suppliers",
|
||||||
|
"agenda3Room": "Business Lounge",
|
||||||
|
"speakersTitle": "Keynote Speakers",
|
||||||
|
"speaker1Role": "AI Research Director | TechCorp",
|
||||||
|
"speaker2Role": "CTO | Automatech",
|
||||||
|
"speaker3Role": "Chair Professor of Robotics | Korea Univ.",
|
||||||
|
"speaker4Role": "Global VP | Digitron",
|
||||||
|
"profileAria": "{{name}} profile",
|
||||||
|
"faqAria": "Frequently asked questions",
|
||||||
|
"faqTitle": "Frequently Asked Questions",
|
||||||
|
"faq1Q": "How do I pre-register?",
|
||||||
|
"faq1A": "Click the \"Pre-register\" button at the top, choose your type, and enter your information to receive a mobile badge.",
|
||||||
|
"faq2Q": "Is there an entry fee?",
|
||||||
|
"faq2A": "General visitors are free with pre-registration. On-site registration may be charged.",
|
||||||
|
"faq3Q": "Is parking available?",
|
||||||
|
"faq3A": "Up to 4,000 vehicles can park simultaneously at KINTEX Exhibition Centers 1 and 2.",
|
||||||
|
"fabCta": "Go to Pre-registration"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"steps": { "type": "Type", "info": "Info", "interest": "Interests", "done": "Done" },
|
||||||
|
"stepsAria": "Pre-registration steps",
|
||||||
|
"type": {
|
||||||
|
"general": "General Visitor",
|
||||||
|
"generalDesc": "General visitors able to attend exhibitions and open sessions",
|
||||||
|
"buyer": "Buyer",
|
||||||
|
"buyerDesc": "Corporate representatives for B2B matching and business consultations",
|
||||||
|
"vip": "VIP",
|
||||||
|
"vipDesc": "Invited guests with exclusive lounge and full session access"
|
||||||
|
},
|
||||||
|
"step0Title": "Select Participant Type",
|
||||||
|
"step0Sub": "Please choose your eligibility to participate in the KINTEX exhibition.",
|
||||||
|
"typeGroupAria": "Participant type",
|
||||||
|
"step1Title": "Enter Basic Information",
|
||||||
|
"step1Sub": "Please enter your information for contact and badge issuance.",
|
||||||
|
"name": "Name",
|
||||||
|
"namePh": "Enter your name",
|
||||||
|
"phone": "Phone (Mobile)",
|
||||||
|
"email": "Email",
|
||||||
|
"emailPh": "example@domain.com",
|
||||||
|
"org": "Organization (Company/Institution)",
|
||||||
|
"orgPh": "Company or school name",
|
||||||
|
"step2Title": "Select Interests",
|
||||||
|
"step2Sub": "Select your interests and we'll provide tailored information.",
|
||||||
|
"interestsAria": "Interests",
|
||||||
|
"interest": {
|
||||||
|
"ai": "AI Solutions",
|
||||||
|
"factory": "Smart Factory",
|
||||||
|
"robotics": "Robotics",
|
||||||
|
"bigdata": "Big Data",
|
||||||
|
"cloud": "Cloud"
|
||||||
|
},
|
||||||
|
"consentPrivacy": "Consent to collection and use of personal information",
|
||||||
|
"required": "(required)",
|
||||||
|
"consentMarketing": "Consent to marketing communications (optional)",
|
||||||
|
"consentMore": "View details",
|
||||||
|
"errPrivacy": "Please check consent to collection and use of personal information (required).",
|
||||||
|
"errName": "Please enter your name.",
|
||||||
|
"errEvent": "Event information could not be verified.",
|
||||||
|
"doneTitle": "Your pre-registration has been received",
|
||||||
|
"doneSub": "{{name}}, your pre-registration is complete. Use your mobile badge at the entrance kiosk.",
|
||||||
|
"visitorDefault": "Visitor",
|
||||||
|
"badgeAria": "Mobile badge preview",
|
||||||
|
"badgeName": "John Doe",
|
||||||
|
"badgeCodeLabel": "Badge Code",
|
||||||
|
"doneNote": "Scan the QR at the entrance kiosk to print a physical badge.",
|
||||||
|
"regNo": "Registration No.: {{id}}",
|
||||||
|
"submitting": "Registering…",
|
||||||
|
"submit": "Register",
|
||||||
|
"eventBrowse": "Browse Events",
|
||||||
|
"floorplan": "View Floor Plan"
|
||||||
|
},
|
||||||
|
"floorplan": {
|
||||||
|
"sideAria": "Booth search and list",
|
||||||
|
"filtersTitle": "Industry · Zone · Keyword",
|
||||||
|
"searchPh": "Search exhibitor or booth number",
|
||||||
|
"searchAria": "Search booths",
|
||||||
|
"typeFilter": "Booth type filter",
|
||||||
|
"hallSelect": "Select hall",
|
||||||
|
"allHalls": "All halls",
|
||||||
|
"listAria": "Booth list (non-visual alternative)",
|
||||||
|
"loading": "Loading booth information…",
|
||||||
|
"searchResults": "Search results ({{count}})",
|
||||||
|
"noMatch": "No matching booths.",
|
||||||
|
"reservedBooth": "Reserved booth",
|
||||||
|
"availableBooth": "Available",
|
||||||
|
"type": {
|
||||||
|
"assembled": "Shell Scheme",
|
||||||
|
"independent": "Raw Space",
|
||||||
|
"corner": "Corner Booth",
|
||||||
|
"island": "Island",
|
||||||
|
"default": "Booth"
|
||||||
|
},
|
||||||
|
"stageAria": "Interactive hall map",
|
||||||
|
"boothLayoutAria": "Booth layout",
|
||||||
|
"boothAria": "Booth {{no}} {{name}}",
|
||||||
|
"loadFail": "Failed to load the floor plan.",
|
||||||
|
"noBooth": "No booths to display.",
|
||||||
|
"popInfoAria": "{{name}} information",
|
||||||
|
"popDetail": "Booth details",
|
||||||
|
"directions": "Directions",
|
||||||
|
"zoomIn": "Zoom in",
|
||||||
|
"zoomOut": "Zoom out",
|
||||||
|
"reset": "Reset",
|
||||||
|
"myLocation": "My location",
|
||||||
|
"legendAria": "Legend",
|
||||||
|
"legendAvailable": "Available",
|
||||||
|
"legendReserved": "Reserved",
|
||||||
|
"legendPublic": "Public"
|
||||||
|
},
|
||||||
|
"inquiry": {
|
||||||
|
"heroAria": "Exhibitor inquiry",
|
||||||
|
"heroTitle": "Exhibitor Inquiry",
|
||||||
|
"heroLead": "Expand your business to global markets with KINTEX. Our specialist consultants will propose the optimal exhibition space.",
|
||||||
|
"cta": "Exhibitor Inquiry",
|
||||||
|
"guideAria": "Exhibitor guide",
|
||||||
|
"guideTitle": "Exhibitor Guide",
|
||||||
|
"booth1Tier": "Entry Tier",
|
||||||
|
"booth1Title": "Shell Scheme",
|
||||||
|
"booth1Desc": "The most economical choice with basic walls and flooring provided. Recommended for small businesses.",
|
||||||
|
"booth1Price": "₩ 2,500,000 / unit",
|
||||||
|
"booth2Tier": "Design Freedom",
|
||||||
|
"booth2Title": "Raw Space",
|
||||||
|
"booth2Desc": "Only space is provided, allowing free design tailored to your brand.",
|
||||||
|
"booth2Price": "₩ 2,000,000 / unit",
|
||||||
|
"booth3Tier": "Full Solution",
|
||||||
|
"booth3Title": "Premium",
|
||||||
|
"booth3Desc": "An all-in-one solution including AI-optimized traffic flow design and a custom branding package.",
|
||||||
|
"booth3Price": "Inquire separately",
|
||||||
|
"boothExampleAria": "{{title}} example",
|
||||||
|
"formAria": "Contact us",
|
||||||
|
"formTitle": "Contact Us",
|
||||||
|
"formSub": "Leave any questions about participation or the exhibition. Our staff will reply within 24 business hours.",
|
||||||
|
"contactPhone": "Phone Inquiry",
|
||||||
|
"contactPhoneVal": "+82-31-810-8114",
|
||||||
|
"contactEmail": "Email Inquiry",
|
||||||
|
"contactEmailVal": "exhibit@kintex.com",
|
||||||
|
"contactLoc": "Location",
|
||||||
|
"contactLocVal": "217-60 Kintex-ro, Ilsanseo-gu, Goyang-si, Gyeonggi-do",
|
||||||
|
"loginHintQ": "Already have an account?",
|
||||||
|
"loginHintA": "Log in for a faster inquiry",
|
||||||
|
"company": "Company",
|
||||||
|
"companyPh": "Enter company name",
|
||||||
|
"name": "Contact Person",
|
||||||
|
"namePh": "Enter your name",
|
||||||
|
"contact": "Contact (Email or Phone)",
|
||||||
|
"contactPh": "Enter reachable contact information",
|
||||||
|
"scale": "Estimated Size",
|
||||||
|
"scaleSelect": "Please select",
|
||||||
|
"scale1": "1 booth (9sqm)",
|
||||||
|
"scale2": "2-4 booths (18-36sqm)",
|
||||||
|
"scale3": "5-9 booths (45-81sqm)",
|
||||||
|
"scale4": "10 or more",
|
||||||
|
"hall": "Preferred Hall",
|
||||||
|
"hallSelect": "Select hall",
|
||||||
|
"message": "Message",
|
||||||
|
"messagePh": "Let us know your participation goals or any special requests.",
|
||||||
|
"agree": "I agree to the collection and use of personal information.",
|
||||||
|
"required": "(required)",
|
||||||
|
"errAgree": "Please agree to the collection and use of personal information.",
|
||||||
|
"errName": "Please enter the contact person's name.",
|
||||||
|
"errContact": "Please enter a reachable email or phone number.",
|
||||||
|
"errMessage": "Please enter your message.",
|
||||||
|
"success": "Your inquiry has been received. Reference No. {{no}}",
|
||||||
|
"submitting": "Sending…",
|
||||||
|
"submit": "Send Inquiry"
|
||||||
|
},
|
||||||
|
"microsite": {
|
||||||
|
"introAria": "About {{brand}}",
|
||||||
|
"eyebrow": "KINTEX Exhibitor",
|
||||||
|
"ctaMeeting": "Book a Meeting",
|
||||||
|
"ctaBrochure": "Company Brochure",
|
||||||
|
"defaultBrand": "Hanbit Robotics Inc.",
|
||||||
|
"defaultTagline": "Intelligent robotics solutions that move the future",
|
||||||
|
"defaultBoothLoc": "Booth A-102 · Hall 7",
|
||||||
|
"boothUndetermined": "TBD",
|
||||||
|
"boothPrefix": "Booth",
|
||||||
|
"boothLocLabel": "KINTEX exhibition booth location",
|
||||||
|
"viewMap": "View Floor Plan",
|
||||||
|
"productsAria": "Core product lineup",
|
||||||
|
"productsTitle": "Core Product Lineup",
|
||||||
|
"productsSub": "Featured products you can meet on site.",
|
||||||
|
"product": "Product",
|
||||||
|
"product1Tag": "Industrial COBOT",
|
||||||
|
"product1Title": "HR-A1 Collaborative Robot",
|
||||||
|
"product1Desc": "An industrial collaborative robot with high precision and safety, designed with human collaboration as the top priority.",
|
||||||
|
"product2Tag": "Smart Logistics",
|
||||||
|
"product2Title": "HR-M3 Autonomous AMR",
|
||||||
|
"product2Desc": "An autonomous mobile robot maximizing smart factory logistics efficiency, with real-time obstacle avoidance and route optimization.",
|
||||||
|
"product3Tag": "AI Vision",
|
||||||
|
"product3Title": "HR-Q Vision Scanner",
|
||||||
|
"product3Desc": "Flawless quality inspection based on AI deep learning. Ultra-precise 3D scanning that catches even the tiniest defects.",
|
||||||
|
"productImgAria": "{{title}} image",
|
||||||
|
"previewAria": "Booth preview",
|
||||||
|
"previewTitle": "Booth Preview",
|
||||||
|
"previewSub": "AI-generated preview booth images. Actual construction results may differ.",
|
||||||
|
"galleryMain": "Main Exhibition Zone",
|
||||||
|
"galleryVip": "VIP Lounge",
|
||||||
|
"galleryAria": "{{label}} AI-generated preview image",
|
||||||
|
"watermark": "AI-generated preview image — actual construction results may differ",
|
||||||
|
"meetingAria": "Book a meeting",
|
||||||
|
"ctaBanner": "We propose customized robotics solutions tailored to your business.",
|
||||||
|
"ctaBannerBtn": "Book a Meeting Now"
|
||||||
|
},
|
||||||
|
"ticket": {
|
||||||
|
"viewPurchase": "Buy Tickets",
|
||||||
|
"viewManage": "Manage · Cancel",
|
||||||
|
"viewSwitchAria": "Switch ticket view",
|
||||||
|
"type": {
|
||||||
|
"general": "General",
|
||||||
|
"generalDesc": "General visitors and individual participants",
|
||||||
|
"generalPrice": "₩0 (pre-registration discount)",
|
||||||
|
"buyer": "Buyer",
|
||||||
|
"buyerDesc": "For industry professionals and buyers only",
|
||||||
|
"buyerPrice": "Free · Screening required",
|
||||||
|
"group": "Group (10+ tickets)",
|
||||||
|
"groupDesc": "For corporate group visits only",
|
||||||
|
"groupPrice": "₩9,000 / person",
|
||||||
|
"vip": "VIP",
|
||||||
|
"vipDesc": "Exhibition all-pass + VIP lounge access",
|
||||||
|
"vipPrice": "₩50,000"
|
||||||
|
},
|
||||||
|
"steps": { "s1": "Event · Type", "s2": "Buyer", "s3": "Payment", "s4": "Done" },
|
||||||
|
"stepsAria": "Booking steps",
|
||||||
|
"bannerTitle": "Smart Factory Korea 2026",
|
||||||
|
"bannerDate": "2026.09.05 – 09.08",
|
||||||
|
"bannerPlace": "Exhibition Center 2, Hall 7",
|
||||||
|
"posterAria": "Event poster",
|
||||||
|
"loginBanner": "Log in to automatically save your booking history.",
|
||||||
|
"loginLink": "Log in",
|
||||||
|
"sample": "Sample data",
|
||||||
|
"h2Type": "Select Ticket Type",
|
||||||
|
"aiOptimized": "AI OPTIMIZED",
|
||||||
|
"off10": "10% OFF",
|
||||||
|
"soldout": "Sold Out",
|
||||||
|
"qtyAria": "{{name}} quantity",
|
||||||
|
"decrease": "Decrease",
|
||||||
|
"increase": "Increase",
|
||||||
|
"h2Booker": "Booker Information",
|
||||||
|
"bookerName": "Name",
|
||||||
|
"bookerNamePh": "Full name",
|
||||||
|
"bookerTel": "Phone",
|
||||||
|
"bookerEmail": "Email (ticket delivery)",
|
||||||
|
"consentPrivacy": "Consent to collection and use of personal information",
|
||||||
|
"required": "(required)",
|
||||||
|
"consentMarketing": "Consent to marketing communications (optional)",
|
||||||
|
"h2Pay": "Payment Method",
|
||||||
|
"payCard": "Credit Card",
|
||||||
|
"payEasy": "Easy Pay",
|
||||||
|
"payBank": "Bank Transfer",
|
||||||
|
"payGroupAria": "Payment method",
|
||||||
|
"pgNote": "Payment is processed on the PG provider's secure page, and card information is not stored on this site. All transactions are protected by 256-bit SSL encryption. (PG not integrated — demo flow)",
|
||||||
|
"doneTitle": "Your booking has been received",
|
||||||
|
"doneNo": "KTX-2026-0000-DEMO",
|
||||||
|
"doneSub": "We'll email you the ticket (upon live integration). Total {{count}} tickets · Payment {{total}}",
|
||||||
|
"sampleReady": "Sample data · Payment coming soon",
|
||||||
|
"walletBtn": "Open Mobile Ticket Wallet",
|
||||||
|
"manageBtn": "Manage / Cancel Booking",
|
||||||
|
"summaryAria": "Booking summary",
|
||||||
|
"summaryTitle": "Booking Summary",
|
||||||
|
"summaryCount": "Selected quantity",
|
||||||
|
"count": "{{n}} tickets",
|
||||||
|
"subtotal": "Subtotal",
|
||||||
|
"discount": "Discount",
|
||||||
|
"finalTotal": "Final payment amount",
|
||||||
|
"nextStep": "Continue to next step",
|
||||||
|
"freeDone": "Complete Free Booking",
|
||||||
|
"pay": "Pay",
|
||||||
|
"payProceed": "Proceed to Payment",
|
||||||
|
"customerCenter": "Customer center 1588-0000 (weekdays 09–18)",
|
||||||
|
"manageTitle": "Manage · Cancel Booking",
|
||||||
|
"manageSub": "You can look up and cancel non-member bookings.",
|
||||||
|
"lookNo": "Booking number",
|
||||||
|
"lookNoPh": "KTX-YYYY-XXXXXX",
|
||||||
|
"lookEmail": "Email",
|
||||||
|
"lookEmailPh": "example@email.com",
|
||||||
|
"lookBtn": "Look up",
|
||||||
|
"loginToView": "Log in to view my bookings",
|
||||||
|
"notLooked": "Enter your booking number and email to look up. (Sample: enter any value and look up)",
|
||||||
|
"booked": "Booking complete",
|
||||||
|
"bookNoLabel": "Booking No.",
|
||||||
|
"paidAt": "Paid at",
|
||||||
|
"colType": "Type",
|
||||||
|
"colQty": "Qty",
|
||||||
|
"colAmount": "Amount",
|
||||||
|
"bookerInfo": "Booker info",
|
||||||
|
"totalPaid": "Total paid",
|
||||||
|
"refundTitle": "Cancellation · Refund Policy",
|
||||||
|
"refundD7": "7 days before visit (D-7)",
|
||||||
|
"refundD7Val": "100% refund",
|
||||||
|
"refundD3": "3 days before visit (D-3)",
|
||||||
|
"refundD3Val": "50% refund",
|
||||||
|
"refundDay": "Day of visit",
|
||||||
|
"refundDayVal": "0% (non-refundable)",
|
||||||
|
"refundEst": "Current estimated refund",
|
||||||
|
"cancelBooking": "Cancel Booking",
|
||||||
|
"refundNote": "Refunds are processed to the original payment method via the PG provider.",
|
||||||
|
"aiHelper": "AI Settlement Assistant",
|
||||||
|
"aiHelperDesc": "For credit card installment payments, restoring your limit through the card company may take about 3–5 business days after cancellation.",
|
||||||
|
"cancelModalAria": "Confirm booking cancellation",
|
||||||
|
"cancelModalTitle": "Cancel this booking?",
|
||||||
|
"cancelModalDesc": "A 100% refund (₩40,000) applies based on D-15. Refunds are processed to the original payment method via the PG provider, with actual refunds upon live integration.",
|
||||||
|
"cancelConfirm": "Cancel"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Log In",
|
||||||
|
"sub": "Welcome to the KINTEX Exhibition Management System.",
|
||||||
|
"email": "Email",
|
||||||
|
"emailPh": "name@company.com",
|
||||||
|
"password": "Password",
|
||||||
|
"remember": "Remember me",
|
||||||
|
"forgot": "Forgot password",
|
||||||
|
"errResponse": "We couldn't process the login response. Please try again.",
|
||||||
|
"errLocked": "Login attempts are temporarily restricted. Please try again shortly.",
|
||||||
|
"errNotImpl": "The login service is being prepared. Please try again shortly.",
|
||||||
|
"errGeneric": "An error occurred while logging in.",
|
||||||
|
"submitting": "Logging in…",
|
||||||
|
"submit": "Log In",
|
||||||
|
"signupPrompt": "Don't have an account yet?",
|
||||||
|
"signup": "Sign Up",
|
||||||
|
"or": "or",
|
||||||
|
"internalLogin": "Log in with KINTEX internal account",
|
||||||
|
"inviteLink": "Join with an invite code →",
|
||||||
|
"copyright": "© 2026 KINTEX. All rights reserved.",
|
||||||
|
"wsTitle": "Event Workspace",
|
||||||
|
"wsSub": "Select an event you are participating in.",
|
||||||
|
"wsEmptyTitle": "No events joined",
|
||||||
|
"wsEmptyDesc": "Check your invite link or join with an invite code.",
|
||||||
|
"wsInvite": "Join with an invite code"
|
||||||
|
},
|
||||||
|
"signup": {
|
||||||
|
"title": "Sign Up",
|
||||||
|
"desc": "Create a KINTEX AI Exhibition Management account. Company name and invite code are optional.",
|
||||||
|
"email": "Email",
|
||||||
|
"emailPh": "name@company.com",
|
||||||
|
"name": "Name",
|
||||||
|
"namePh": "John Doe",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordPh": "8+ characters",
|
||||||
|
"password2": "Confirm Password",
|
||||||
|
"password2Ph": "Re-enter password",
|
||||||
|
"company": "Company (optional)",
|
||||||
|
"companyPh": "KINTEX Inc.",
|
||||||
|
"invite": "Invite Code (optional)",
|
||||||
|
"invitePh": "INV-XXXX",
|
||||||
|
"mismatch": "Passwords do not match.",
|
||||||
|
"errNotImpl": "The sign-up service is being prepared. Please try again shortly.",
|
||||||
|
"errConflict": "This email is already registered.",
|
||||||
|
"errGeneric": "An error occurred while signing up.",
|
||||||
|
"noticePending": "Your account has been created. You can log in after administrator approval.",
|
||||||
|
"noticeActive": "Your account has been created. Please log in now.",
|
||||||
|
"doneTitle": "Sign-up Complete",
|
||||||
|
"goLogin": "Go to Login",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"submitting": "Signing up…",
|
||||||
|
"submit": "Sign Up"
|
||||||
|
},
|
||||||
|
"forgot": {
|
||||||
|
"requestTitle": "Forgot Password",
|
||||||
|
"requestDesc": "We'll send a verification code to your registered email. If the account exists, an email will be sent.",
|
||||||
|
"email": "Email",
|
||||||
|
"emailPh": "name@company.com",
|
||||||
|
"sending": "Sending…",
|
||||||
|
"requestBtn": "Get Verification Code",
|
||||||
|
"resetTitle": "Reset Password",
|
||||||
|
"resetDesc": "Enter the verification code sent to <0>{{email}}</0> and your new password.",
|
||||||
|
"code": "Verification Code",
|
||||||
|
"codePh": "Code received by email",
|
||||||
|
"newpw": "New Password",
|
||||||
|
"newpwPh": "8+ characters",
|
||||||
|
"prev": "Back",
|
||||||
|
"changing": "Changing…",
|
||||||
|
"changeBtn": "Change Password",
|
||||||
|
"doneTitle": "Password Changed",
|
||||||
|
"doneDesc": "Please log in with your new password.",
|
||||||
|
"goLogin": "Go to Login",
|
||||||
|
"errInvalid": "The verification code is incorrect or has expired.",
|
||||||
|
"errNotImpl": "The password reset service is being prepared. Please try again shortly.",
|
||||||
|
"errRequest": "An error occurred during the request.",
|
||||||
|
"errReset": "An error occurred during the reset.",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
}
|
||||||
|
}
|
||||||
521
src/frontend/src/i18n/locales/ja.json
Normal file
521
src/frontend/src/i18n/locales/ja.json
Normal file
@ -0,0 +1,521 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"brand": "KINTEX",
|
||||||
|
"appSuffix": "AI 展示会管理",
|
||||||
|
"actions": {
|
||||||
|
"prev": "戻る",
|
||||||
|
"next": "次へ",
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"close": "閉じる",
|
||||||
|
"home": "ホームへ",
|
||||||
|
"viewMore": "詳細を見る",
|
||||||
|
"confirm": "確認"
|
||||||
|
},
|
||||||
|
"states": {
|
||||||
|
"loading": "読み込み中…",
|
||||||
|
"empty": "表示する内容がありません。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"select": "言語を選択"
|
||||||
|
},
|
||||||
|
"shell": {
|
||||||
|
"nav": {
|
||||||
|
"events": "イベント",
|
||||||
|
"exhibit": "出展案内",
|
||||||
|
"visit": "来場案内",
|
||||||
|
"transport": "交通"
|
||||||
|
},
|
||||||
|
"searchPlaceholder": "イベントを検索",
|
||||||
|
"cta": { "register": "来場事前登録" },
|
||||||
|
"menuOpen": "メニューを開く",
|
||||||
|
"menuClose": "メニューを閉じる",
|
||||||
|
"homeAria": "{{brand}} ホーム",
|
||||||
|
"primaryMenu": "メインメニュー",
|
||||||
|
"mobileMenu": "モバイルメニュー",
|
||||||
|
"footer": {
|
||||||
|
"brandDesc": "韓国最大規模の展示コンベンションセンター。ビジネスと文化の革新をつなぎます。",
|
||||||
|
"quickTitle": "クイックリンク",
|
||||||
|
"quickSchedule": "イベント日程",
|
||||||
|
"quickExhibit": "出展案内",
|
||||||
|
"quickDirections": "アクセス",
|
||||||
|
"quickSitemap": "サイトマップ",
|
||||||
|
"supportTitle": "カスタマーサポート",
|
||||||
|
"supportPrivacy": "プライバシーポリシー",
|
||||||
|
"supportTerms": "利用規約",
|
||||||
|
"supportContact": "お問い合わせ",
|
||||||
|
"contactTitle": "連絡先",
|
||||||
|
"contactAddress": "京畿道高陽市一山西区KINTEXロ217-60",
|
||||||
|
"contactTel": "代表番号 1899-1001",
|
||||||
|
"contactEmail": "exhibition@kintex.com",
|
||||||
|
"bottom": "© KINTEX. All Rights Reserved. (10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"heroAria": "KINTEX について",
|
||||||
|
"eyebrow": "Global Exhibition Platform",
|
||||||
|
"heroTitleLine1": "世界へつながる展示会、",
|
||||||
|
"heroTitleEm": "KINTEX",
|
||||||
|
"heroLead": "韓国最大規模の展示コンベンションセンター KINTEX で繰り広げられる、ビジネスと文化の新たな革新を体験してください。",
|
||||||
|
"ctaRegister": "来場事前登録",
|
||||||
|
"ctaFacility": "施設案内を見る",
|
||||||
|
"eventsAria": "開催中・開催予定のイベント",
|
||||||
|
"eventsTitle": "開催中 · 開催予定",
|
||||||
|
"eventsSub": "今 KINTEX で開催中の注目情報をご確認ください。",
|
||||||
|
"eventsMore": "全日程を見る",
|
||||||
|
"loading": "イベント情報を読み込み中…",
|
||||||
|
"empty": "現在、開催中または開催予定のイベントはありません。",
|
||||||
|
"tagExhibition": "EXHIBITION",
|
||||||
|
"booth": "ブース {{count}}",
|
||||||
|
"aiOptimized": "AI 最適化",
|
||||||
|
"ongoing": "開催中",
|
||||||
|
"ended": "終了",
|
||||||
|
"dday": "D-{{n}}",
|
||||||
|
"ddayZero": "D-DAY",
|
||||||
|
"register": "事前登録",
|
||||||
|
"hallTbd": "ホール割当予定",
|
||||||
|
"transportAria": "アクセス",
|
||||||
|
"transportTitle": "アクセス",
|
||||||
|
"transportSub": "さらに便利になったアクセスで KINTEX へ。",
|
||||||
|
"gtxTitle": "GTX-A KINTEX 駅 開通",
|
||||||
|
"gtxDesc": "KINTEX 駅から徒歩3分",
|
||||||
|
"transit": "公共交通",
|
||||||
|
"transitDesc": "広域バスおよび地下鉄",
|
||||||
|
"parking": "駐車案内",
|
||||||
|
"parkingDesc": "4,000台同時駐車",
|
||||||
|
"shuttle": "シャトルバス",
|
||||||
|
"shuttleDesc": "主要拠点を循環",
|
||||||
|
"mapAria": "KINTEX 位置地図",
|
||||||
|
"mapAddress": "高陽市一山西区KINTEXロ217-60",
|
||||||
|
"mapPlace": "KINTEX 第1・2展示場",
|
||||||
|
"mapViewAria": "地図を見る"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"tabs": {
|
||||||
|
"intro": "紹介",
|
||||||
|
"program": "プログラム",
|
||||||
|
"speakers": "登壇者",
|
||||||
|
"exhibitors": "出展企業",
|
||||||
|
"transport": "交通",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"eyebrow": "KINTEX Exhibition",
|
||||||
|
"loadingTitle": "イベント情報を読み込み中…",
|
||||||
|
"fallbackTitle": "イベント詳細",
|
||||||
|
"dateTbd": "日程確認中",
|
||||||
|
"placeTbd": "ホール割当予定",
|
||||||
|
"lead": "KINTEX で開催される展示会情報を確認し、事前登録しましょう。",
|
||||||
|
"factDate": "日時",
|
||||||
|
"factPlace": "場所",
|
||||||
|
"ctaRegister": "事前登録する",
|
||||||
|
"ctaFloorplan": "会場フロアマップを見る",
|
||||||
|
"navAria": "イベント詳細ナビゲーション",
|
||||||
|
"shareAria": "共有",
|
||||||
|
"share": "共有する",
|
||||||
|
"copyLink": "リンクをコピー",
|
||||||
|
"overviewAria": "展示会概要",
|
||||||
|
"overviewTitle": "展示会概要",
|
||||||
|
"overviewLead": "スマートファクトリー・コリア 2026 は次世代製造技術の集大成です。AI、ロボティクス、IoT、そしてデジタルツイン技術を融合した最新ソリューションを一堂に。世界500社以上のリーディング企業が参加します。",
|
||||||
|
"statBooth": "出展ブース",
|
||||||
|
"statVisitors": "予想来場者数",
|
||||||
|
"statVisitorsValue": "30,000+",
|
||||||
|
"themesTitle": "主要展示テーマ",
|
||||||
|
"theme1Title": "AI & 自律ロボット",
|
||||||
|
"theme1Desc": "自ら学習し判断するスマートロボットソリューション",
|
||||||
|
"theme2Title": "デジタルツイン & IoT",
|
||||||
|
"theme2Desc": "仮想世界と現実の製造現場のリアルタイム同期",
|
||||||
|
"theme3Title": "カーボンニュートラル・スマート製造",
|
||||||
|
"theme3Desc": "エネルギー効率を最大化する環境配慮型プロセス技術",
|
||||||
|
"programAria": "プログラム日程",
|
||||||
|
"programTitle": "プログラム日程",
|
||||||
|
"day1": "9月5日 (Day 1)",
|
||||||
|
"day2": "9月6日 (Day 2)",
|
||||||
|
"agenda1Title": "製造業の未来:AI が導く生産性革命",
|
||||||
|
"agenda1Desc": "AI のグローバル権威が提案する2030製造戦略と実際の導入事例分析",
|
||||||
|
"agenda1Room": "メインカンファレンスホール",
|
||||||
|
"agenda2Title": "産業用5Gとエッジコンピューティングの融合",
|
||||||
|
"agenda2Desc": "遅延のないデータ伝送によるリアルタイム工程制御技術の実演",
|
||||||
|
"agenda2Room": "セミナールーム A",
|
||||||
|
"agenda3Title": "Global Buyers Matching Day",
|
||||||
|
"agenda3Desc": "海外有望企業と国内サプライヤーによる1対1のビジネスミーティング",
|
||||||
|
"agenda3Room": "ビジネスラウンジ",
|
||||||
|
"speakersTitle": "主要登壇者",
|
||||||
|
"speaker1Role": "AI 研究所長 | TechCorp",
|
||||||
|
"speaker2Role": "CTO | Automatech",
|
||||||
|
"speaker3Role": "ロボット工学 碩座教授 | Korea Univ.",
|
||||||
|
"speaker4Role": "Global VP | Digitron",
|
||||||
|
"profileAria": "{{name}} プロフィール",
|
||||||
|
"faqAria": "よくある質問",
|
||||||
|
"faqTitle": "よくある質問",
|
||||||
|
"faq1Q": "事前登録はどうすればよいですか?",
|
||||||
|
"faq1A": "上部の「事前登録する」ボタンからタイプを選択し情報を入力すると、モバイルバッジが発行されます。",
|
||||||
|
"faq2Q": "入場料はかかりますか?",
|
||||||
|
"faq2A": "一般来場者は事前登録で無料です。当日登録は有料の場合があります。",
|
||||||
|
"faq3Q": "駐車場はありますか?",
|
||||||
|
"faq3A": "KINTEX 第1・2展示場で最大4,000台の同時駐車が可能です。",
|
||||||
|
"fabCta": "事前登録へ"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"steps": { "type": "タイプ", "info": "情報", "interest": "関心分野", "done": "完了" },
|
||||||
|
"stepsAria": "事前登録ステップ",
|
||||||
|
"type": {
|
||||||
|
"general": "一般来場者",
|
||||||
|
"generalDesc": "展示および公開セッションを参観できる一般来場者",
|
||||||
|
"buyer": "バイヤー",
|
||||||
|
"buyerDesc": "B2Bマッチングおよびビジネス商談のための企業担当者",
|
||||||
|
"vip": "VIP",
|
||||||
|
"vipDesc": "招待者専用ラウンジおよび全セッション入場の来賓"
|
||||||
|
},
|
||||||
|
"step0Title": "参加タイプの選択",
|
||||||
|
"step0Sub": "KINTEX 展示会にご参加いただく資格を選択してください。",
|
||||||
|
"typeGroupAria": "参加タイプ",
|
||||||
|
"step1Title": "基本情報の入力",
|
||||||
|
"step1Sub": "連絡および入場証発行のための情報を入力してください。",
|
||||||
|
"name": "氏名",
|
||||||
|
"namePh": "お名前を入力",
|
||||||
|
"phone": "連絡先 (携帯)",
|
||||||
|
"email": "メール",
|
||||||
|
"emailPh": "example@domain.com",
|
||||||
|
"org": "所属 (会社/機関)",
|
||||||
|
"orgPh": "会社名または学校名",
|
||||||
|
"step2Title": "関心分野の選択",
|
||||||
|
"step2Sub": "関心分野を選択いただくと、最適な情報をご提供します。",
|
||||||
|
"interestsAria": "関心分野",
|
||||||
|
"interest": {
|
||||||
|
"ai": "AI ソリューション",
|
||||||
|
"factory": "スマートファクトリー",
|
||||||
|
"robotics": "ロボティクス",
|
||||||
|
"bigdata": "ビッグデータ",
|
||||||
|
"cloud": "クラウド"
|
||||||
|
},
|
||||||
|
"consentPrivacy": "個人情報の収集・利用に同意",
|
||||||
|
"required": "(必須)",
|
||||||
|
"consentMarketing": "マーケティング配信に同意 (任意)",
|
||||||
|
"consentMore": "詳細を見る",
|
||||||
|
"errPrivacy": "個人情報の収集・利用への同意(必須)にチェックしてください。",
|
||||||
|
"errName": "お名前を入力してください。",
|
||||||
|
"errEvent": "イベント情報を確認できません。",
|
||||||
|
"doneTitle": "事前登録を受け付けました",
|
||||||
|
"doneSub": "{{name}} 様の事前登録が完了しました。入口キオスクでモバイルバッジをご利用ください。",
|
||||||
|
"visitorDefault": "来場者",
|
||||||
|
"badgeAria": "モバイルバッジプレビュー",
|
||||||
|
"badgeName": "山田太郎",
|
||||||
|
"badgeCodeLabel": "Badge Code",
|
||||||
|
"doneNote": "入口キオスクで QR を読み取ると実物バッジを印刷できます。",
|
||||||
|
"regNo": "登録番号:{{id}}",
|
||||||
|
"submitting": "登録中…",
|
||||||
|
"submit": "登録する",
|
||||||
|
"eventBrowse": "イベントを見る",
|
||||||
|
"floorplan": "会場フロアマップを見る"
|
||||||
|
},
|
||||||
|
"floorplan": {
|
||||||
|
"sideAria": "ブース検索および一覧",
|
||||||
|
"filtersTitle": "業種 · ゾーン · キーワード",
|
||||||
|
"searchPh": "出展企業またはブース番号を検索",
|
||||||
|
"searchAria": "ブース検索",
|
||||||
|
"typeFilter": "ブースタイプフィルター",
|
||||||
|
"hallSelect": "展示場を選択",
|
||||||
|
"allHalls": "全ホール",
|
||||||
|
"listAria": "ブース一覧 (非ビジュアル代替)",
|
||||||
|
"loading": "ブース情報を読み込み中…",
|
||||||
|
"searchResults": "検索結果 ({{count}})",
|
||||||
|
"noMatch": "一致するブースがありません。",
|
||||||
|
"reservedBooth": "予約済みブース",
|
||||||
|
"availableBooth": "分譲可能",
|
||||||
|
"type": {
|
||||||
|
"assembled": "パッケージブース",
|
||||||
|
"independent": "スペースのみ",
|
||||||
|
"corner": "コーナーブース",
|
||||||
|
"island": "アイランド",
|
||||||
|
"default": "ブース"
|
||||||
|
},
|
||||||
|
"stageAria": "インタラクティブホールマップ",
|
||||||
|
"boothLayoutAria": "ブース配置",
|
||||||
|
"boothAria": "ブース {{no}} {{name}}",
|
||||||
|
"loadFail": "フロアマップを読み込めませんでした。",
|
||||||
|
"noBooth": "表示するブースがありません。",
|
||||||
|
"popInfoAria": "{{name}} 情報",
|
||||||
|
"popDetail": "ブース詳細",
|
||||||
|
"directions": "経路案内",
|
||||||
|
"zoomIn": "拡大",
|
||||||
|
"zoomOut": "縮小",
|
||||||
|
"reset": "リセット",
|
||||||
|
"myLocation": "現在地",
|
||||||
|
"legendAria": "凡例",
|
||||||
|
"legendAvailable": "販売 (Available)",
|
||||||
|
"legendReserved": "予約 (Reserved)",
|
||||||
|
"legendPublic": "共用部 (Public)"
|
||||||
|
},
|
||||||
|
"inquiry": {
|
||||||
|
"heroAria": "出展のお問い合わせ",
|
||||||
|
"heroTitle": "出展のお問い合わせ",
|
||||||
|
"heroLead": "KINTEX とともに、貴社のビジネスをグローバル市場へ拡大しましょう。専門コンサルタントが最適な展示スペースをご提案します。",
|
||||||
|
"cta": "出展のお問い合わせ",
|
||||||
|
"guideAria": "出展案内",
|
||||||
|
"guideTitle": "出展案内",
|
||||||
|
"booth1Tier": "Entry Tier",
|
||||||
|
"booth1Title": "パッケージブース (Shell Scheme)",
|
||||||
|
"booth1Desc": "基本の壁体と床が提供される最も経済的な選択。小規模企業におすすめです。",
|
||||||
|
"booth1Price": "₩ 2,500,000 / unit",
|
||||||
|
"booth2Tier": "Design Freedom",
|
||||||
|
"booth2Title": "スペースのみ (Raw Space)",
|
||||||
|
"booth2Desc": "スペースのみが提供され、ブランドに合わせた自由な設計が可能です。",
|
||||||
|
"booth2Price": "₩ 2,000,000 / unit",
|
||||||
|
"booth3Tier": "Full Solution",
|
||||||
|
"booth3Title": "プレミアム (Premium)",
|
||||||
|
"booth3Desc": "AI 最適化された動線設計とカスタムブランディングパッケージを含むオールインワンソリューションです。",
|
||||||
|
"booth3Price": "別途お問い合わせ",
|
||||||
|
"boothExampleAria": "{{title}} 例",
|
||||||
|
"formAria": "お問い合わせ",
|
||||||
|
"formTitle": "お問い合わせ",
|
||||||
|
"formSub": "出展申込や展示に関するご質問をお残しください。担当者が営業日24時間以内に回答します。",
|
||||||
|
"contactPhone": "電話でのお問い合わせ",
|
||||||
|
"contactPhoneVal": "+82-31-810-8114",
|
||||||
|
"contactEmail": "メールでのお問い合わせ",
|
||||||
|
"contactEmailVal": "exhibit@kintex.com",
|
||||||
|
"contactLoc": "所在地",
|
||||||
|
"contactLocVal": "京畿道高陽市一山西区KINTEXロ217-60",
|
||||||
|
"loginHintQ": "すでにアカウントをお持ちですか?",
|
||||||
|
"loginHintA": "ログインして素早くお問い合わせ",
|
||||||
|
"company": "会社名",
|
||||||
|
"companyPh": "会社名を入力",
|
||||||
|
"name": "担当者",
|
||||||
|
"namePh": "お名前を入力",
|
||||||
|
"contact": "連絡先 (メールまたは電話番号)",
|
||||||
|
"contactPh": "連絡可能な情報を入力",
|
||||||
|
"scale": "予想規模",
|
||||||
|
"scaleSelect": "選択してください",
|
||||||
|
"scale1": "1ブース (9sqm)",
|
||||||
|
"scale2": "2-4ブース (18-36sqm)",
|
||||||
|
"scale3": "5-9ブース (45-81sqm)",
|
||||||
|
"scale4": "10以上",
|
||||||
|
"hall": "希望ホール",
|
||||||
|
"hallSelect": "ホールを選択",
|
||||||
|
"message": "お問い合わせ内容",
|
||||||
|
"messagePh": "出展目的や特別なご要望があればお残しください。",
|
||||||
|
"agree": "個人情報の収集および利用に同意します。",
|
||||||
|
"required": "(必須)",
|
||||||
|
"errAgree": "個人情報の収集および利用に同意してください。",
|
||||||
|
"errName": "担当者名を入力してください。",
|
||||||
|
"errContact": "連絡可能なメールまたは電話番号を入力してください。",
|
||||||
|
"errMessage": "お問い合わせ内容を入力してください。",
|
||||||
|
"success": "お問い合わせを受け付けました。受付番号 {{no}}",
|
||||||
|
"submitting": "送信中…",
|
||||||
|
"submit": "問い合わせを送信"
|
||||||
|
},
|
||||||
|
"microsite": {
|
||||||
|
"introAria": "{{brand}} について",
|
||||||
|
"eyebrow": "KINTEX Exhibitor",
|
||||||
|
"ctaMeeting": "ミーティング予約",
|
||||||
|
"ctaBrochure": "会社案内",
|
||||||
|
"defaultBrand": "ハンビットロボティクス株式会社",
|
||||||
|
"defaultTagline": "未来を動かすインテリジェント・ロボティクスソリューション",
|
||||||
|
"defaultBoothLoc": "ブース A-102 · ホール7",
|
||||||
|
"boothUndetermined": "未定",
|
||||||
|
"boothPrefix": "ブース",
|
||||||
|
"boothLocLabel": "KINTEX 展示場ブース位置",
|
||||||
|
"viewMap": "会場フロアマップを見る",
|
||||||
|
"productsAria": "主力製品ラインナップ",
|
||||||
|
"productsTitle": "主力製品ラインナップ",
|
||||||
|
"productsSub": "現場でご覧いただける代表製品です。",
|
||||||
|
"product": "製品",
|
||||||
|
"product1Tag": "Industrial COBOT",
|
||||||
|
"product1Title": "HR-A1 協働ロボット",
|
||||||
|
"product1Desc": "高い精度と安全性を備えた産業用協働ロボット。人との協働を最優先に設計しました。",
|
||||||
|
"product2Tag": "Smart Logistics",
|
||||||
|
"product2Title": "HR-M3 自律走行 AMR",
|
||||||
|
"product2Desc": "スマートファクトリーの物流効率を最大化する自律移動ロボット。リアルタイムの障害物回避と経路最適化。",
|
||||||
|
"product3Tag": "AI Vision",
|
||||||
|
"product3Title": "HR-Q Vision スキャナー",
|
||||||
|
"product3Desc": "AI ディープラーニングによる完璧な品質検査。微細な欠陥も見逃さない超精密3Dスキャニング。",
|
||||||
|
"productImgAria": "{{title}} 画像",
|
||||||
|
"previewAria": "ブースプレビュー",
|
||||||
|
"previewTitle": "ブースプレビュー",
|
||||||
|
"previewSub": "AI が生成した予想ブース画像です。実際の施工結果と異なる場合があります。",
|
||||||
|
"galleryMain": "メイン展示ゾーン",
|
||||||
|
"galleryVip": "VIP ラウンジ",
|
||||||
|
"galleryAria": "{{label}} AI 生成の予想画像",
|
||||||
|
"watermark": "AI 生成の予想画像 — 実際の施工結果と異なる場合があります",
|
||||||
|
"meetingAria": "ミーティング予約",
|
||||||
|
"ctaBanner": "貴社のビジネスに合わせたカスタムロボットソリューションをご提案します。",
|
||||||
|
"ctaBannerBtn": "今すぐミーティング予約"
|
||||||
|
},
|
||||||
|
"ticket": {
|
||||||
|
"viewPurchase": "チケット購入",
|
||||||
|
"viewManage": "確認 · キャンセル",
|
||||||
|
"viewSwitchAria": "チケット画面切替",
|
||||||
|
"type": {
|
||||||
|
"general": "一般券",
|
||||||
|
"generalDesc": "一般来場者および個人参加者",
|
||||||
|
"generalPrice": "₩0 (事前登録割引価格)",
|
||||||
|
"buyer": "バイヤー券",
|
||||||
|
"buyerDesc": "関連企業従事者およびバイヤー専用",
|
||||||
|
"buyerPrice": "無料 · 資格審査必要",
|
||||||
|
"group": "団体券 (10枚以上)",
|
||||||
|
"groupDesc": "企業団体来場専用",
|
||||||
|
"groupPrice": "₩9,000 / 1人",
|
||||||
|
"vip": "VIP 券",
|
||||||
|
"vipDesc": "展示会オールパス + VIP ラウンジ利用券",
|
||||||
|
"vipPrice": "₩50,000"
|
||||||
|
},
|
||||||
|
"steps": { "s1": "イベント·券種", "s2": "予約者", "s3": "決済", "s4": "完了" },
|
||||||
|
"stepsAria": "予約ステップ",
|
||||||
|
"bannerTitle": "スマートファクトリー・コリア 2026",
|
||||||
|
"bannerDate": "2026.09.05 – 09.08",
|
||||||
|
"bannerPlace": "第2展示場 ホール7",
|
||||||
|
"posterAria": "イベントポスター",
|
||||||
|
"loginBanner": "ログインすると予約内容が自動保存されます。",
|
||||||
|
"loginLink": "ログイン",
|
||||||
|
"sample": "サンプルデータ",
|
||||||
|
"h2Type": "チケット券種の選択",
|
||||||
|
"aiOptimized": "AI OPTIMIZED",
|
||||||
|
"off10": "10% OFF",
|
||||||
|
"soldout": "完売",
|
||||||
|
"qtyAria": "{{name}} 数量",
|
||||||
|
"decrease": "減らす",
|
||||||
|
"increase": "増やす",
|
||||||
|
"h2Booker": "予約者情報",
|
||||||
|
"bookerName": "氏名",
|
||||||
|
"bookerNamePh": "お名前",
|
||||||
|
"bookerTel": "連絡先",
|
||||||
|
"bookerEmail": "メール (チケット送付)",
|
||||||
|
"consentPrivacy": "個人情報の収集・利用に同意",
|
||||||
|
"required": "(必須)",
|
||||||
|
"consentMarketing": "マーケティング配信に同意 (任意)",
|
||||||
|
"h2Pay": "決済方法",
|
||||||
|
"payCard": "クレジットカード",
|
||||||
|
"payEasy": "簡単決済",
|
||||||
|
"payBank": "口座振込",
|
||||||
|
"payGroupAria": "決済方法",
|
||||||
|
"pgNote": "決済は PG 社のセキュアページで行われ、カード情報は本サイトに保存されません。すべての取引は256ビット SSL 暗号化で保護されます。(PG 未連携 — デモフロー)",
|
||||||
|
"doneTitle": "予約を受け付けました",
|
||||||
|
"doneNo": "KTX-2026-0000-DEMO",
|
||||||
|
"doneSub": "メールでチケットをお送りします。(本サービス連携時) 合計 {{count}} 枚 · 決済 {{total}}",
|
||||||
|
"sampleReady": "サンプルデータ · 決済準備中",
|
||||||
|
"walletBtn": "モバイルチケットウォレットを開く",
|
||||||
|
"manageBtn": "予約確認 / キャンセル",
|
||||||
|
"summaryAria": "予約内容の概要",
|
||||||
|
"summaryTitle": "予約内容の概要",
|
||||||
|
"summaryCount": "選択枚数",
|
||||||
|
"count": "{{n}} 枚",
|
||||||
|
"subtotal": "小計",
|
||||||
|
"discount": "割引",
|
||||||
|
"finalTotal": "最終決済金額",
|
||||||
|
"nextStep": "次のステップへ",
|
||||||
|
"freeDone": "無料予約を完了",
|
||||||
|
"pay": "決済する",
|
||||||
|
"payProceed": "決済へ進む",
|
||||||
|
"customerCenter": "カスタマーセンター 1588-0000 (平日 09–18時)",
|
||||||
|
"manageTitle": "予約確認 · キャンセル",
|
||||||
|
"manageSub": "非会員の予約内容を照会・キャンセルできます。",
|
||||||
|
"lookNo": "予約番号",
|
||||||
|
"lookNoPh": "KTX-YYYY-XXXXXX",
|
||||||
|
"lookEmail": "メール",
|
||||||
|
"lookEmailPh": "example@email.com",
|
||||||
|
"lookBtn": "照会",
|
||||||
|
"loginToView": "ログインして予約を確認",
|
||||||
|
"notLooked": "予約番号とメールを入力して照会してください。(サンプル:任意の値を入力して照会)",
|
||||||
|
"booked": "予約完了",
|
||||||
|
"bookNoLabel": "予約番号",
|
||||||
|
"paidAt": "決済日時",
|
||||||
|
"colType": "券種",
|
||||||
|
"colQty": "数量",
|
||||||
|
"colAmount": "金額",
|
||||||
|
"bookerInfo": "予約者情報",
|
||||||
|
"totalPaid": "総決済金額",
|
||||||
|
"refundTitle": "キャンセル·返金ポリシー",
|
||||||
|
"refundD7": "来場7日前 (D-7)",
|
||||||
|
"refundD7Val": "100% 返金",
|
||||||
|
"refundD3": "来場3日前 (D-3)",
|
||||||
|
"refundD3Val": "50% 返金",
|
||||||
|
"refundDay": "来場当日",
|
||||||
|
"refundDayVal": "0% (返金不可)",
|
||||||
|
"refundEst": "現在の予想返金額",
|
||||||
|
"cancelBooking": "予約キャンセル",
|
||||||
|
"refundNote": "返金は PG 社を通じて元の決済手段へ処理されます。",
|
||||||
|
"aiHelper": "AI 精算アシスタント",
|
||||||
|
"aiHelperDesc": "クレジットカード分割払いの場合、キャンセル後カード会社を通じた限度復元まで約3–5営業日かかることがあります。",
|
||||||
|
"cancelModalAria": "予約キャンセルの確認",
|
||||||
|
"cancelModalTitle": "予約をキャンセルしますか?",
|
||||||
|
"cancelModalDesc": "D-15 基準で100%返金(₩40,000)が適用されます。返金は元の決済手段へ PG 社を通じて処理され、本サービス連携時に実際の返金が行われます。",
|
||||||
|
"cancelConfirm": "キャンセルする"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "ログイン",
|
||||||
|
"sub": "KINTEX 展示会管理システムへようこそ。",
|
||||||
|
"email": "メール",
|
||||||
|
"emailPh": "name@company.com",
|
||||||
|
"password": "パスワード",
|
||||||
|
"remember": "ID を記憶",
|
||||||
|
"forgot": "パスワードを探す",
|
||||||
|
"errResponse": "ログイン応答を処理できませんでした。もう一度お試しください。",
|
||||||
|
"errLocked": "ログイン試行が一時的に制限されています。しばらくしてからお試しください。",
|
||||||
|
"errNotImpl": "ログインサービスは準備中です。しばらくしてからお試しください。",
|
||||||
|
"errGeneric": "ログイン中にエラーが発生しました。",
|
||||||
|
"submitting": "ログイン中…",
|
||||||
|
"submit": "ログイン",
|
||||||
|
"signupPrompt": "アカウントをお持ちでないですか?",
|
||||||
|
"signup": "新規登録",
|
||||||
|
"or": "または",
|
||||||
|
"internalLogin": "KINTEX 内部アカウントでログイン",
|
||||||
|
"inviteLink": "招待コードで参加 →",
|
||||||
|
"copyright": "© 2026 KINTEX. All rights reserved.",
|
||||||
|
"wsTitle": "イベントワークスペース",
|
||||||
|
"wsSub": "参加中のイベントを選択してください。",
|
||||||
|
"wsEmptyTitle": "参加中のイベントがありません",
|
||||||
|
"wsEmptyDesc": "招待リンクを確認するか、招待コードで参加してください。",
|
||||||
|
"wsInvite": "招待コードで参加"
|
||||||
|
},
|
||||||
|
"signup": {
|
||||||
|
"title": "新規登録",
|
||||||
|
"desc": "KINTEX AI 展示会管理アカウントを作成します。会社名・招待コードは任意です。",
|
||||||
|
"email": "メール",
|
||||||
|
"emailPh": "name@company.com",
|
||||||
|
"name": "氏名",
|
||||||
|
"namePh": "山田太郎",
|
||||||
|
"password": "パスワード",
|
||||||
|
"passwordPh": "8文字以上",
|
||||||
|
"password2": "パスワード確認",
|
||||||
|
"password2Ph": "パスワード再入力",
|
||||||
|
"company": "会社名 (任意)",
|
||||||
|
"companyPh": "KINTEX 株式会社",
|
||||||
|
"invite": "招待コード (任意)",
|
||||||
|
"invitePh": "INV-XXXX",
|
||||||
|
"mismatch": "パスワードが一致しません。",
|
||||||
|
"errNotImpl": "新規登録サービスは準備中です。しばらくしてからお試しください。",
|
||||||
|
"errConflict": "すでに登録済みのメールです。",
|
||||||
|
"errGeneric": "新規登録中にエラーが発生しました。",
|
||||||
|
"noticePending": "アカウントが作成されました。管理者の承認後にログインできます。",
|
||||||
|
"noticeActive": "アカウントが作成されました。今すぐログインしてください。",
|
||||||
|
"doneTitle": "新規登録完了",
|
||||||
|
"goLogin": "ログインへ",
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"submitting": "登録中…",
|
||||||
|
"submit": "登録する"
|
||||||
|
},
|
||||||
|
"forgot": {
|
||||||
|
"requestTitle": "パスワードを探す",
|
||||||
|
"requestDesc": "登録済みのメールに認証コードをお送りします。登録済みアカウントの場合、メールが送信されます。",
|
||||||
|
"email": "メール",
|
||||||
|
"emailPh": "name@company.com",
|
||||||
|
"sending": "送信中…",
|
||||||
|
"requestBtn": "認証コードを受け取る",
|
||||||
|
"resetTitle": "パスワード再設定",
|
||||||
|
"resetDesc": "<0>{{email}}</0> に届いた認証コードと新しいパスワードを入力してください。",
|
||||||
|
"code": "認証コード",
|
||||||
|
"codePh": "メールで受け取ったコード",
|
||||||
|
"newpw": "新しいパスワード",
|
||||||
|
"newpwPh": "8文字以上",
|
||||||
|
"prev": "戻る",
|
||||||
|
"changing": "変更中…",
|
||||||
|
"changeBtn": "パスワード変更",
|
||||||
|
"doneTitle": "パスワード変更完了",
|
||||||
|
"doneDesc": "新しいパスワードでログインしてください。",
|
||||||
|
"goLogin": "ログインへ",
|
||||||
|
"errInvalid": "認証コードが正しくないか、期限切れです。",
|
||||||
|
"errNotImpl": "パスワード再設定サービスは準備中です。しばらくしてからお試しください。",
|
||||||
|
"errRequest": "リクエスト中にエラーが発生しました。",
|
||||||
|
"errReset": "再設定中にエラーが発生しました。",
|
||||||
|
"cancel": "キャンセル"
|
||||||
|
}
|
||||||
|
}
|
||||||
521
src/frontend/src/i18n/locales/ko.json
Normal file
521
src/frontend/src/i18n/locales/ko.json
Normal file
@ -0,0 +1,521 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"brand": "KINTEX",
|
||||||
|
"appSuffix": "AI 전시관리",
|
||||||
|
"actions": {
|
||||||
|
"prev": "이전",
|
||||||
|
"next": "다음",
|
||||||
|
"cancel": "취소",
|
||||||
|
"close": "닫기",
|
||||||
|
"home": "홈으로 이동",
|
||||||
|
"viewMore": "자세히 보기",
|
||||||
|
"confirm": "확인"
|
||||||
|
},
|
||||||
|
"states": {
|
||||||
|
"loading": "불러오는 중입니다…",
|
||||||
|
"empty": "표시할 내용이 없습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"select": "언어 선택"
|
||||||
|
},
|
||||||
|
"shell": {
|
||||||
|
"nav": {
|
||||||
|
"events": "행사",
|
||||||
|
"exhibit": "참가안내",
|
||||||
|
"visit": "관람안내",
|
||||||
|
"transport": "교통"
|
||||||
|
},
|
||||||
|
"searchPlaceholder": "행사 검색",
|
||||||
|
"cta": { "register": "관람 사전등록" },
|
||||||
|
"menuOpen": "메뉴 열기",
|
||||||
|
"menuClose": "메뉴 닫기",
|
||||||
|
"homeAria": "{{brand}} 홈",
|
||||||
|
"primaryMenu": "주요 메뉴",
|
||||||
|
"mobileMenu": "모바일 메뉴",
|
||||||
|
"footer": {
|
||||||
|
"brandDesc": "대한민국 최대 규모의 전시 컨벤션 센터. 비즈니스와 문화의 혁신을 잇습니다.",
|
||||||
|
"quickTitle": "바로가기",
|
||||||
|
"quickSchedule": "행사 일정",
|
||||||
|
"quickExhibit": "참가 안내",
|
||||||
|
"quickDirections": "오시는 길",
|
||||||
|
"quickSitemap": "사이트맵",
|
||||||
|
"supportTitle": "고객지원",
|
||||||
|
"supportPrivacy": "개인정보처리방침",
|
||||||
|
"supportTerms": "이용약관",
|
||||||
|
"supportContact": "문의하기",
|
||||||
|
"contactTitle": "연락처",
|
||||||
|
"contactAddress": "경기도 고양시 일산서구 킨텍스로 217-60",
|
||||||
|
"contactTel": "대표번호 1899-1001",
|
||||||
|
"contactEmail": "exhibition@kintex.com",
|
||||||
|
"bottom": "© KINTEX. All Rights Reserved. (10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"heroAria": "킨텍스 소개",
|
||||||
|
"eyebrow": "Global Exhibition Platform",
|
||||||
|
"heroTitleLine1": "세계로 통하는 전시,",
|
||||||
|
"heroTitleEm": "킨텍스",
|
||||||
|
"heroLead": "대한민국 최대 규모의 전시 컨벤션 센터 KINTEX에서 펼쳐지는 비즈니스와 문화의 새로운 혁신을 경험하세요.",
|
||||||
|
"ctaRegister": "관람 사전등록",
|
||||||
|
"ctaFacility": "시설 안내 보기",
|
||||||
|
"eventsAria": "진행중 예정 행사",
|
||||||
|
"eventsTitle": "진행중 · 예정 행사",
|
||||||
|
"eventsSub": "지금 킨텍스에서 열리는 주요 소식을 확인하세요.",
|
||||||
|
"eventsMore": "전체 일정 보기",
|
||||||
|
"loading": "행사 정보를 불러오는 중입니다…",
|
||||||
|
"empty": "현재 진행 중이거나 예정된 행사가 없습니다.",
|
||||||
|
"tagExhibition": "EXHIBITION",
|
||||||
|
"booth": "부스 {{count}}",
|
||||||
|
"aiOptimized": "AI 최적화",
|
||||||
|
"ongoing": "진행중",
|
||||||
|
"ended": "종료",
|
||||||
|
"dday": "D-{{n}}",
|
||||||
|
"ddayZero": "D-DAY",
|
||||||
|
"register": "사전등록",
|
||||||
|
"hallTbd": "홀 배정 예정",
|
||||||
|
"transportAria": "오시는 길",
|
||||||
|
"transportTitle": "오시는 길",
|
||||||
|
"transportSub": "더욱 빨라진 접근성으로 킨텍스를 만나보세요.",
|
||||||
|
"gtxTitle": "GTX-A 킨텍스역 개통",
|
||||||
|
"gtxDesc": "킨텍스역에서 도보 3분 거리",
|
||||||
|
"transit": "대중교통",
|
||||||
|
"transitDesc": "광역버스 및 지하철",
|
||||||
|
"parking": "주차안내",
|
||||||
|
"parkingDesc": "4,000대 동시 주차",
|
||||||
|
"shuttle": "셔틀버스",
|
||||||
|
"shuttleDesc": "주요 거점 순환",
|
||||||
|
"mapAria": "킨텍스 위치 지도",
|
||||||
|
"mapAddress": "고양시 일산서구 킨텍스로 217-60",
|
||||||
|
"mapPlace": "KINTEX 제1, 2전시장",
|
||||||
|
"mapViewAria": "지도 보기"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"tabs": {
|
||||||
|
"intro": "소개",
|
||||||
|
"program": "프로그램",
|
||||||
|
"speakers": "연사",
|
||||||
|
"exhibitors": "참가업체",
|
||||||
|
"transport": "교통",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"eyebrow": "KINTEX Exhibition",
|
||||||
|
"loadingTitle": "행사 정보를 불러오는 중…",
|
||||||
|
"fallbackTitle": "행사 상세",
|
||||||
|
"dateTbd": "일정 확인 중",
|
||||||
|
"placeTbd": "홀 배정 예정",
|
||||||
|
"lead": "킨텍스에서 열리는 전시회 정보를 확인하고 사전등록하세요.",
|
||||||
|
"factDate": "일시",
|
||||||
|
"factPlace": "장소",
|
||||||
|
"ctaRegister": "사전등록하기",
|
||||||
|
"ctaFloorplan": "전시장 도면 보기",
|
||||||
|
"navAria": "행사 상세 내비게이션",
|
||||||
|
"shareAria": "공유",
|
||||||
|
"share": "공유하기",
|
||||||
|
"copyLink": "링크 복사",
|
||||||
|
"overviewAria": "전시회 개요",
|
||||||
|
"overviewTitle": "전시회 개요",
|
||||||
|
"overviewLead": "스마트팩토리 코리아 2026은 차세대 제조 기술의 집약체입니다. AI, 로보틱스, IoT, 그리고 디지털 트윈 기술이 결합된 최신 솔루션을 한자리에서 확인하십시오. 전 세계 500개 이상의 선도 기업이 참여합니다.",
|
||||||
|
"statBooth": "참가 부스",
|
||||||
|
"statVisitors": "예상 참관객",
|
||||||
|
"statVisitorsValue": "30,000+",
|
||||||
|
"themesTitle": "주요 전시 테마",
|
||||||
|
"theme1Title": "AI & 자율 로봇",
|
||||||
|
"theme1Desc": "스스로 학습하고 판단하는 스마트 로봇 솔루션",
|
||||||
|
"theme2Title": "디지털 트윈 & IoT",
|
||||||
|
"theme2Desc": "가상 세계와 현실 제조 현장의 실시간 동기화",
|
||||||
|
"theme3Title": "탄소중립 스마트 제조",
|
||||||
|
"theme3Desc": "에너지 효율 극대화를 위한 친환경 공정 기술",
|
||||||
|
"programAria": "프로그램 일정",
|
||||||
|
"programTitle": "프로그램 일정",
|
||||||
|
"day1": "9월 5일 (Day 1)",
|
||||||
|
"day2": "9월 6일 (Day 2)",
|
||||||
|
"agenda1Title": "제조업의 미래: AI가 이끄는 생산성 혁명",
|
||||||
|
"agenda1Desc": "AI 글로벌 석학들이 제안하는 2030 제조 전략과 실제 구현 사례 분석",
|
||||||
|
"agenda1Room": "메인 컨퍼런스 홀",
|
||||||
|
"agenda2Title": "산업용 5G와 엣지 컴퓨팅의 결합",
|
||||||
|
"agenda2Desc": "지연 없는 데이터 전송을 통한 실시간 공정 제어 기술 시연",
|
||||||
|
"agenda2Room": "세미나실 A",
|
||||||
|
"agenda3Title": "Global Buyers Matching Day",
|
||||||
|
"agenda3Desc": "해외 유망 기업과 국내 공급사 간의 1:1 비즈니스 미팅",
|
||||||
|
"agenda3Room": "비즈니스 라운지",
|
||||||
|
"speakersTitle": "주요 연사",
|
||||||
|
"speaker1Role": "AI 연구소장 | TechCorp",
|
||||||
|
"speaker2Role": "CTO | Automatech",
|
||||||
|
"speaker3Role": "로봇공학 석좌교수 | Korea Univ.",
|
||||||
|
"speaker4Role": "Global VP | Digitron",
|
||||||
|
"profileAria": "{{name}} 프로필",
|
||||||
|
"faqAria": "자주 묻는 질문",
|
||||||
|
"faqTitle": "자주 묻는 질문",
|
||||||
|
"faq1Q": "사전등록은 어떻게 하나요?",
|
||||||
|
"faq1A": "상단 \"사전등록하기\" 버튼에서 유형 선택 후 정보를 입력하면 모바일 배지가 발급됩니다.",
|
||||||
|
"faq2Q": "입장료가 있나요?",
|
||||||
|
"faq2A": "일반 관람객은 사전등록 시 무료입니다. 현장 등록은 유료로 운영될 수 있습니다.",
|
||||||
|
"faq3Q": "주차 공간이 있나요?",
|
||||||
|
"faq3A": "킨텍스 제1·2전시장에 최대 4,000대 동시 주차가 가능합니다.",
|
||||||
|
"fabCta": "사전등록 바로가기"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"steps": { "type": "유형", "info": "정보", "interest": "관심분야", "done": "완료" },
|
||||||
|
"stepsAria": "사전등록 단계",
|
||||||
|
"type": {
|
||||||
|
"general": "일반 관람객",
|
||||||
|
"generalDesc": "전시 및 공개 세션 참관이 가능한 일반 방문객",
|
||||||
|
"buyer": "바이어",
|
||||||
|
"buyerDesc": "B2B 매칭 및 비즈니스 상담을 위한 기업 담당자",
|
||||||
|
"vip": "VIP",
|
||||||
|
"vipDesc": "초청 인사 전용 라운지 및 전 세션 입장 귀빈"
|
||||||
|
},
|
||||||
|
"step0Title": "참가 유형 선택",
|
||||||
|
"step0Sub": "KINTEX 전시회에 참여하실 자격을 선택해 주세요.",
|
||||||
|
"typeGroupAria": "참가 유형",
|
||||||
|
"step1Title": "기본 정보 입력",
|
||||||
|
"step1Sub": "연락 및 출입증 발급을 위한 정보를 입력해 주세요.",
|
||||||
|
"name": "이름",
|
||||||
|
"namePh": "성함을 입력하세요",
|
||||||
|
"phone": "연락처 (Mobile)",
|
||||||
|
"email": "이메일",
|
||||||
|
"emailPh": "example@domain.com",
|
||||||
|
"org": "소속 (회사/기관)",
|
||||||
|
"orgPh": "회사명 또는 학교명",
|
||||||
|
"step2Title": "관심 분야 선택",
|
||||||
|
"step2Sub": "관심 분야를 선택하시면 맞춤형 정보를 제공해 드립니다.",
|
||||||
|
"interestsAria": "관심 분야",
|
||||||
|
"interest": {
|
||||||
|
"ai": "AI 솔루션",
|
||||||
|
"factory": "스마트 팩토리",
|
||||||
|
"robotics": "로보틱스",
|
||||||
|
"bigdata": "빅데이터",
|
||||||
|
"cloud": "클라우드"
|
||||||
|
},
|
||||||
|
"consentPrivacy": "개인정보 수집·이용 동의",
|
||||||
|
"required": "(필수)",
|
||||||
|
"consentMarketing": "마케팅 수신 동의 (선택)",
|
||||||
|
"consentMore": "상세보기",
|
||||||
|
"errPrivacy": "개인정보 수집·이용 동의(필수)에 체크해 주세요.",
|
||||||
|
"errName": "이름을 입력해 주세요.",
|
||||||
|
"errEvent": "행사 정보를 확인할 수 없습니다.",
|
||||||
|
"doneTitle": "사전등록이 접수되었습니다",
|
||||||
|
"doneSub": "{{name}}님의 사전등록이 완료되었습니다. 모바일 배지를 입구 키오스크에서 사용하세요.",
|
||||||
|
"visitorDefault": "관람객",
|
||||||
|
"badgeAria": "모바일 배지 미리보기",
|
||||||
|
"badgeName": "홍길동",
|
||||||
|
"badgeCodeLabel": "Badge Code",
|
||||||
|
"doneNote": "입구 키오스크에서 QR을 인식해 실물 배지를 출력할 수 있습니다.",
|
||||||
|
"regNo": "등록번호: {{id}}",
|
||||||
|
"submitting": "등록 중…",
|
||||||
|
"submit": "등록하기",
|
||||||
|
"eventBrowse": "행사 둘러보기",
|
||||||
|
"floorplan": "전시장 도면 보기"
|
||||||
|
},
|
||||||
|
"floorplan": {
|
||||||
|
"sideAria": "부스 검색 및 목록",
|
||||||
|
"filtersTitle": "업종 · 존 · 키워드",
|
||||||
|
"searchPh": "참가업체 또는 부스번호 검색",
|
||||||
|
"searchAria": "부스 검색",
|
||||||
|
"typeFilter": "부스 유형 필터",
|
||||||
|
"hallSelect": "전시장 선택",
|
||||||
|
"allHalls": "전체 홀",
|
||||||
|
"listAria": "부스 목록 (비시각 대안)",
|
||||||
|
"loading": "부스 정보를 불러오는 중…",
|
||||||
|
"searchResults": "검색 결과 ({{count}})",
|
||||||
|
"noMatch": "일치하는 부스가 없습니다.",
|
||||||
|
"reservedBooth": "예약 부스",
|
||||||
|
"availableBooth": "분양 가능",
|
||||||
|
"type": {
|
||||||
|
"assembled": "조립부스",
|
||||||
|
"independent": "독립부스",
|
||||||
|
"corner": "코너부스",
|
||||||
|
"island": "아일랜드",
|
||||||
|
"default": "부스"
|
||||||
|
},
|
||||||
|
"stageAria": "인터랙티브 홀 지도",
|
||||||
|
"boothLayoutAria": "부스 배치",
|
||||||
|
"boothAria": "부스 {{no}} {{name}}",
|
||||||
|
"loadFail": "도면을 불러오지 못했습니다.",
|
||||||
|
"noBooth": "표시할 부스가 없습니다.",
|
||||||
|
"popInfoAria": "{{name}} 정보",
|
||||||
|
"popDetail": "부스 상세",
|
||||||
|
"directions": "길찾기",
|
||||||
|
"zoomIn": "확대",
|
||||||
|
"zoomOut": "축소",
|
||||||
|
"reset": "초기화",
|
||||||
|
"myLocation": "내 위치",
|
||||||
|
"legendAria": "범례",
|
||||||
|
"legendAvailable": "판매 (Available)",
|
||||||
|
"legendReserved": "예약 (Reserved)",
|
||||||
|
"legendPublic": "공용부 (Public)"
|
||||||
|
},
|
||||||
|
"inquiry": {
|
||||||
|
"heroAria": "참가 문의",
|
||||||
|
"heroTitle": "참가 문의",
|
||||||
|
"heroLead": "KINTEX와 함께 귀사의 비즈니스를 글로벌 시장으로 확장하세요. 전문 컨설턴트가 최적의 전시 공간을 제안해 드립니다.",
|
||||||
|
"cta": "참가 문의",
|
||||||
|
"guideAria": "참가 안내",
|
||||||
|
"guideTitle": "참가 안내",
|
||||||
|
"booth1Tier": "Entry Tier",
|
||||||
|
"booth1Title": "조립부스 (Shell Scheme)",
|
||||||
|
"booth1Desc": "기본 벽체와 바닥이 제공되는 가장 경제적인 선택. 소규모 기업에 추천합니다.",
|
||||||
|
"booth1Price": "₩ 2,500,000 / unit",
|
||||||
|
"booth2Tier": "Design Freedom",
|
||||||
|
"booth2Title": "독립부스 (Raw Space)",
|
||||||
|
"booth2Desc": "공간만 제공되어 브랜드에 맞는 자유로운 디자인 설계가 가능합니다.",
|
||||||
|
"booth2Price": "₩ 2,000,000 / unit",
|
||||||
|
"booth3Tier": "Full Solution",
|
||||||
|
"booth3Title": "프리미엄 (Premium)",
|
||||||
|
"booth3Desc": "AI 최적화 동선 설계 및 맞춤형 브랜딩 패키지가 포함된 올인원 솔루션입니다.",
|
||||||
|
"booth3Price": "별도 문의",
|
||||||
|
"boothExampleAria": "{{title}} 예시",
|
||||||
|
"formAria": "문의하기",
|
||||||
|
"formTitle": "문의하기",
|
||||||
|
"formSub": "참가 신청 및 전시 관련 궁금한 점을 남겨주세요. 담당자가 영업일 기준 24시간 이내에 답변드립니다.",
|
||||||
|
"contactPhone": "전화 문의",
|
||||||
|
"contactPhoneVal": "+82-31-810-8114",
|
||||||
|
"contactEmail": "이메일 문의",
|
||||||
|
"contactEmailVal": "exhibit@kintex.com",
|
||||||
|
"contactLoc": "위치 안내",
|
||||||
|
"contactLocVal": "경기도 고양시 일산서구 킨텍스로 217-60",
|
||||||
|
"loginHintQ": "이미 계정이 있으신가요?",
|
||||||
|
"loginHintA": "로그인하여 빠른 문의하기",
|
||||||
|
"company": "회사명",
|
||||||
|
"companyPh": "회사명을 입력하세요",
|
||||||
|
"name": "담당자",
|
||||||
|
"namePh": "성함을 입력하세요",
|
||||||
|
"contact": "연락처 (이메일 또는 전화번호)",
|
||||||
|
"contactPh": "연락 가능한 정보를 입력하세요",
|
||||||
|
"scale": "예상 규모",
|
||||||
|
"scaleSelect": "선택하세요",
|
||||||
|
"scale1": "1개 부스 (9sqm)",
|
||||||
|
"scale2": "2-4개 부스 (18-36sqm)",
|
||||||
|
"scale3": "5-9개 부스 (45-81sqm)",
|
||||||
|
"scale4": "10개 이상",
|
||||||
|
"hall": "희망 홀",
|
||||||
|
"hallSelect": "홀 선택",
|
||||||
|
"message": "문의 내용",
|
||||||
|
"messagePh": "참가 목적이나 특별 요청사항이 있다면 남겨주세요.",
|
||||||
|
"agree": "개인정보 수집 및 이용에 동의합니다.",
|
||||||
|
"required": "(필수)",
|
||||||
|
"errAgree": "개인정보 수집 및 이용에 동의해 주세요.",
|
||||||
|
"errName": "담당자 이름을 입력해 주세요.",
|
||||||
|
"errContact": "연락 가능한 이메일 또는 전화번호를 입력해 주세요.",
|
||||||
|
"errMessage": "문의 내용을 입력해 주세요.",
|
||||||
|
"success": "문의가 접수되었습니다. 접수번호 {{no}}",
|
||||||
|
"submitting": "전송 중…",
|
||||||
|
"submit": "문의 보내기"
|
||||||
|
},
|
||||||
|
"microsite": {
|
||||||
|
"introAria": "{{brand}} 소개",
|
||||||
|
"eyebrow": "KINTEX Exhibitor",
|
||||||
|
"ctaMeeting": "미팅 예약",
|
||||||
|
"ctaBrochure": "기업 소개서",
|
||||||
|
"defaultBrand": "(주)한빛로보틱스",
|
||||||
|
"defaultTagline": "미래를 움직이는 지능형 로보틱스 솔루션",
|
||||||
|
"defaultBoothLoc": "부스 A-102 · 홀7",
|
||||||
|
"boothUndetermined": "미정",
|
||||||
|
"boothPrefix": "부스",
|
||||||
|
"boothLocLabel": "KINTEX 전시장 부스 위치",
|
||||||
|
"viewMap": "전시장 도면 보기",
|
||||||
|
"productsAria": "핵심 제품 라인업",
|
||||||
|
"productsTitle": "핵심 제품 라인업",
|
||||||
|
"productsSub": "현장에서 만나보실 대표 제품입니다.",
|
||||||
|
"product": "제품",
|
||||||
|
"product1Tag": "Industrial COBOT",
|
||||||
|
"product1Title": "HR-A1 협동 로봇",
|
||||||
|
"product1Desc": "고도의 정밀도와 안전성을 갖춘 산업용 협동 로봇. 인간과의 협업을 최우선으로 설계했습니다.",
|
||||||
|
"product2Tag": "Smart Logistics",
|
||||||
|
"product2Title": "HR-M3 자율주행 AMR",
|
||||||
|
"product2Desc": "스마트 팩토리 물류 효율을 극대화하는 자율 이동 로봇. 실시간 장애물 회피와 경로 최적화.",
|
||||||
|
"product3Tag": "AI Vision",
|
||||||
|
"product3Title": "HR-Q Vision 스캐너",
|
||||||
|
"product3Desc": "AI 딥러닝 기반 완벽한 품질 검수. 미세 결함도 놓치지 않는 초정밀 3D 스캐닝.",
|
||||||
|
"productImgAria": "{{title}} 이미지",
|
||||||
|
"previewAria": "부스 미리보기",
|
||||||
|
"previewTitle": "부스 미리보기",
|
||||||
|
"previewSub": "AI가 생성한 예상 부스 이미지입니다. 실제 시공 결과와 다를 수 있습니다.",
|
||||||
|
"galleryMain": "메인 전시 존",
|
||||||
|
"galleryVip": "VIP 라운지",
|
||||||
|
"galleryAria": "{{label}} AI 생성 예상 이미지",
|
||||||
|
"watermark": "AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음",
|
||||||
|
"meetingAria": "미팅 예약",
|
||||||
|
"ctaBanner": "귀사의 비즈니스에 맞는 맞춤형 로봇 솔루션을 제안해 드립니다.",
|
||||||
|
"ctaBannerBtn": "지금 미팅 예약하기"
|
||||||
|
},
|
||||||
|
"ticket": {
|
||||||
|
"viewPurchase": "입장권 예매",
|
||||||
|
"viewManage": "예매 확인 · 취소",
|
||||||
|
"viewSwitchAria": "티켓 화면 전환",
|
||||||
|
"type": {
|
||||||
|
"general": "일반권",
|
||||||
|
"generalDesc": "일반 관람객 및 개인 참가자",
|
||||||
|
"generalPrice": "₩0 (사전등록 할인가)",
|
||||||
|
"buyer": "바이어권",
|
||||||
|
"buyerDesc": "유관 기업 종사자 및 바이어 전용",
|
||||||
|
"buyerPrice": "무료 · 자격심사 필요",
|
||||||
|
"group": "단체권 (10매 이상)",
|
||||||
|
"groupDesc": "기업 단체 관람 전용",
|
||||||
|
"groupPrice": "₩9,000 / 1인",
|
||||||
|
"vip": "VIP권",
|
||||||
|
"vipDesc": "전시회 올패스 + VIP 라운지 이용권",
|
||||||
|
"vipPrice": "₩50,000"
|
||||||
|
},
|
||||||
|
"steps": { "s1": "행사·권종", "s2": "예매자", "s3": "결제", "s4": "완료" },
|
||||||
|
"stepsAria": "예매 단계",
|
||||||
|
"bannerTitle": "스마트팩토리 코리아 2026",
|
||||||
|
"bannerDate": "2026.09.05 – 09.08",
|
||||||
|
"bannerPlace": "제2전시장 홀7",
|
||||||
|
"posterAria": "행사 포스터",
|
||||||
|
"loginBanner": "로그인하면 예매 내역이 자동 저장됩니다.",
|
||||||
|
"loginLink": "로그인하기",
|
||||||
|
"sample": "샘플 데이터",
|
||||||
|
"h2Type": "티켓 권종 선택",
|
||||||
|
"aiOptimized": "AI OPTIMIZED",
|
||||||
|
"off10": "10% OFF",
|
||||||
|
"soldout": "매진",
|
||||||
|
"qtyAria": "{{name}} 수량",
|
||||||
|
"decrease": "감소",
|
||||||
|
"increase": "증가",
|
||||||
|
"h2Booker": "예매자 정보",
|
||||||
|
"bookerName": "이름",
|
||||||
|
"bookerNamePh": "성함",
|
||||||
|
"bookerTel": "연락처",
|
||||||
|
"bookerEmail": "이메일 (티켓 발송)",
|
||||||
|
"consentPrivacy": "개인정보 수집·이용 동의",
|
||||||
|
"required": "(필수)",
|
||||||
|
"consentMarketing": "마케팅 수신 동의 (선택)",
|
||||||
|
"h2Pay": "결제 수단",
|
||||||
|
"payCard": "신용카드",
|
||||||
|
"payEasy": "간편결제",
|
||||||
|
"payBank": "계좌이체",
|
||||||
|
"payGroupAria": "결제 수단",
|
||||||
|
"pgNote": "결제는 PG사 보안 페이지에서 진행되며, 카드정보는 본 사이트에 저장되지 않습니다. 모든 거래는 256비트 SSL 암호화로 보호됩니다. (PG 미연동 — 데모 플로우)",
|
||||||
|
"doneTitle": "예매가 접수되었습니다",
|
||||||
|
"doneNo": "KTX-2026-0000-DEMO",
|
||||||
|
"doneSub": "이메일로 티켓을 보내드립니다. (실서비스 연동 시) 총 {{count}}매 · 결제 {{total}}",
|
||||||
|
"sampleReady": "샘플 데이터 · 결제 준비 중",
|
||||||
|
"walletBtn": "모바일 티켓 지갑 열기",
|
||||||
|
"manageBtn": "예매 확인 / 취소",
|
||||||
|
"summaryAria": "예매 내역 요약",
|
||||||
|
"summaryTitle": "예매 내역 요약",
|
||||||
|
"summaryCount": "선택 매수",
|
||||||
|
"count": "{{n}}매",
|
||||||
|
"subtotal": "소계",
|
||||||
|
"discount": "할인",
|
||||||
|
"finalTotal": "최종 결제 금액",
|
||||||
|
"nextStep": "다음 단계로 이동",
|
||||||
|
"freeDone": "무료 예매 완료",
|
||||||
|
"pay": "결제하기",
|
||||||
|
"payProceed": "결제 진행",
|
||||||
|
"customerCenter": "고객센터 1588-0000 (평일 09–18시)",
|
||||||
|
"manageTitle": "예매 확인 · 취소",
|
||||||
|
"manageSub": "비회원 예매 내역을 조회하고 취소하실 수 있습니다.",
|
||||||
|
"lookNo": "예매번호",
|
||||||
|
"lookNoPh": "KTX-YYYY-XXXXXX",
|
||||||
|
"lookEmail": "이메일",
|
||||||
|
"lookEmailPh": "example@email.com",
|
||||||
|
"lookBtn": "조회하기",
|
||||||
|
"loginToView": "로그인하여 내 예매 보기",
|
||||||
|
"notLooked": "예매번호와 이메일을 입력해 조회하세요. (샘플: 아무 값이나 입력 후 조회)",
|
||||||
|
"booked": "예매완료",
|
||||||
|
"bookNoLabel": "예매번호",
|
||||||
|
"paidAt": "결제일시",
|
||||||
|
"colType": "권종",
|
||||||
|
"colQty": "수량",
|
||||||
|
"colAmount": "금액",
|
||||||
|
"bookerInfo": "예매자 정보",
|
||||||
|
"totalPaid": "총 결제 금액",
|
||||||
|
"refundTitle": "취소·환불 정책",
|
||||||
|
"refundD7": "방문 7일 전 (D-7)",
|
||||||
|
"refundD7Val": "100% 환불",
|
||||||
|
"refundD3": "방문 3일 전 (D-3)",
|
||||||
|
"refundD3Val": "50% 환불",
|
||||||
|
"refundDay": "방문 당일",
|
||||||
|
"refundDayVal": "0% (환불불가)",
|
||||||
|
"refundEst": "현재 예상 환불액",
|
||||||
|
"cancelBooking": "예매 취소",
|
||||||
|
"refundNote": "환불은 PG사를 통해 원결제수단으로 처리됩니다.",
|
||||||
|
"aiHelper": "AI 정산 도우미",
|
||||||
|
"aiHelperDesc": "신용카드 할부 결제 건은 취소 시 카드사를 통해 한도 복구까지 약 3–5영업일이 소요될 수 있습니다.",
|
||||||
|
"cancelModalAria": "예매 취소 확인",
|
||||||
|
"cancelModalTitle": "예매를 취소하시겠습니까?",
|
||||||
|
"cancelModalDesc": "D-15 기준 100% 환불(₩40,000)이 적용됩니다. 환불은 원결제수단으로 PG사를 통해 처리되며, 실서비스 연동 시 실제 환불이 진행됩니다.",
|
||||||
|
"cancelConfirm": "취소하기"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "로그인",
|
||||||
|
"sub": "KINTEX 전시 관리 시스템에 오신 것을 환영합니다.",
|
||||||
|
"email": "이메일",
|
||||||
|
"emailPh": "name@company.com",
|
||||||
|
"password": "비밀번호",
|
||||||
|
"remember": "아이디 기억",
|
||||||
|
"forgot": "비밀번호 찾기",
|
||||||
|
"errResponse": "로그인 응답을 처리하지 못했습니다. 다시 시도해 주세요.",
|
||||||
|
"errLocked": "로그인 시도가 일시적으로 제한되었습니다. 잠시 후 다시 시도해 주세요.",
|
||||||
|
"errNotImpl": "로그인 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.",
|
||||||
|
"errGeneric": "로그인 중 오류가 발생했습니다.",
|
||||||
|
"submitting": "로그인 중…",
|
||||||
|
"submit": "로그인",
|
||||||
|
"signupPrompt": "아직 계정이 없으신가요?",
|
||||||
|
"signup": "회원가입",
|
||||||
|
"or": "또는",
|
||||||
|
"internalLogin": "킨텍스 내부 계정으로 로그인",
|
||||||
|
"inviteLink": "초대 코드로 참여하기 →",
|
||||||
|
"copyright": "© 2026 KINTEX. All rights reserved.",
|
||||||
|
"wsTitle": "행사 워크스페이스",
|
||||||
|
"wsSub": "참여 중인 행사를 선택하세요.",
|
||||||
|
"wsEmptyTitle": "참여 중인 행사가 없습니다",
|
||||||
|
"wsEmptyDesc": "초대 링크를 확인하거나 초대 코드로 참여하세요.",
|
||||||
|
"wsInvite": "초대 코드로 참여하기"
|
||||||
|
},
|
||||||
|
"signup": {
|
||||||
|
"title": "회원가입",
|
||||||
|
"desc": "KINTEX AI 전시관리 계정을 만듭니다. 회사명·초대코드는 선택 사항입니다.",
|
||||||
|
"email": "이메일",
|
||||||
|
"emailPh": "name@company.com",
|
||||||
|
"name": "이름",
|
||||||
|
"namePh": "홍길동",
|
||||||
|
"password": "비밀번호",
|
||||||
|
"passwordPh": "8자 이상",
|
||||||
|
"password2": "비밀번호 확인",
|
||||||
|
"password2Ph": "비밀번호 재입력",
|
||||||
|
"company": "회사명 (선택)",
|
||||||
|
"companyPh": "(주)킨텍스",
|
||||||
|
"invite": "초대 코드 (선택)",
|
||||||
|
"invitePh": "INV-XXXX",
|
||||||
|
"mismatch": "비밀번호가 일치하지 않습니다.",
|
||||||
|
"errNotImpl": "회원가입 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.",
|
||||||
|
"errConflict": "이미 가입된 이메일입니다.",
|
||||||
|
"errGeneric": "회원가입 중 오류가 발생했습니다.",
|
||||||
|
"noticePending": "계정이 생성되었습니다. 관리자 승인 후 로그인할 수 있습니다.",
|
||||||
|
"noticeActive": "계정이 생성되었습니다. 이제 로그인해 주세요.",
|
||||||
|
"doneTitle": "회원가입 완료",
|
||||||
|
"goLogin": "로그인하러 가기",
|
||||||
|
"cancel": "취소",
|
||||||
|
"submitting": "가입 중…",
|
||||||
|
"submit": "가입하기"
|
||||||
|
},
|
||||||
|
"forgot": {
|
||||||
|
"requestTitle": "비밀번호 찾기",
|
||||||
|
"requestDesc": "가입한 이메일로 인증코드를 보내드립니다. 등록된 계정인 경우 메일이 발송됩니다.",
|
||||||
|
"email": "이메일",
|
||||||
|
"emailPh": "name@company.com",
|
||||||
|
"sending": "전송 중…",
|
||||||
|
"requestBtn": "인증코드 받기",
|
||||||
|
"resetTitle": "비밀번호 재설정",
|
||||||
|
"resetDesc": "<0>{{email}}</0> 로 받은 인증코드와 새 비밀번호를 입력하세요.",
|
||||||
|
"code": "인증코드",
|
||||||
|
"codePh": "메일로 받은 코드",
|
||||||
|
"newpw": "새 비밀번호",
|
||||||
|
"newpwPh": "8자 이상",
|
||||||
|
"prev": "이전",
|
||||||
|
"changing": "변경 중…",
|
||||||
|
"changeBtn": "비밀번호 변경",
|
||||||
|
"doneTitle": "비밀번호 변경 완료",
|
||||||
|
"doneDesc": "새 비밀번호로 로그인해 주세요.",
|
||||||
|
"goLogin": "로그인하러 가기",
|
||||||
|
"errInvalid": "인증코드가 올바르지 않거나 만료되었습니다.",
|
||||||
|
"errNotImpl": "비밀번호 재설정 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.",
|
||||||
|
"errRequest": "요청 중 오류가 발생했습니다.",
|
||||||
|
"errReset": "재설정 중 오류가 발생했습니다.",
|
||||||
|
"cancel": "취소"
|
||||||
|
}
|
||||||
|
}
|
||||||
521
src/frontend/src/i18n/locales/zh.json
Normal file
521
src/frontend/src/i18n/locales/zh.json
Normal file
@ -0,0 +1,521 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"brand": "KINTEX",
|
||||||
|
"appSuffix": "AI 展会管理",
|
||||||
|
"actions": {
|
||||||
|
"prev": "上一步",
|
||||||
|
"next": "下一步",
|
||||||
|
"cancel": "取消",
|
||||||
|
"close": "关闭",
|
||||||
|
"home": "返回首页",
|
||||||
|
"viewMore": "查看详情",
|
||||||
|
"confirm": "确认"
|
||||||
|
},
|
||||||
|
"states": {
|
||||||
|
"loading": "加载中…",
|
||||||
|
"empty": "暂无内容。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"select": "选择语言"
|
||||||
|
},
|
||||||
|
"shell": {
|
||||||
|
"nav": {
|
||||||
|
"events": "展会",
|
||||||
|
"exhibit": "参展指南",
|
||||||
|
"visit": "参观指南",
|
||||||
|
"transport": "交通"
|
||||||
|
},
|
||||||
|
"searchPlaceholder": "搜索展会",
|
||||||
|
"cta": { "register": "观众预登记" },
|
||||||
|
"menuOpen": "打开菜单",
|
||||||
|
"menuClose": "关闭菜单",
|
||||||
|
"homeAria": "{{brand}} 首页",
|
||||||
|
"primaryMenu": "主菜单",
|
||||||
|
"mobileMenu": "移动菜单",
|
||||||
|
"footer": {
|
||||||
|
"brandDesc": "韩国最大规模的展览会议中心,连接商业与文化的创新。",
|
||||||
|
"quickTitle": "快速链接",
|
||||||
|
"quickSchedule": "展会日程",
|
||||||
|
"quickExhibit": "参展指南",
|
||||||
|
"quickDirections": "交通路线",
|
||||||
|
"quickSitemap": "网站地图",
|
||||||
|
"supportTitle": "客户支持",
|
||||||
|
"supportPrivacy": "隐私政策",
|
||||||
|
"supportTerms": "使用条款",
|
||||||
|
"supportContact": "联系我们",
|
||||||
|
"contactTitle": "联系方式",
|
||||||
|
"contactAddress": "京畿道高阳市一山西区KINTEX路217-60",
|
||||||
|
"contactTel": "总机 1899-1001",
|
||||||
|
"contactEmail": "exhibition@kintex.com",
|
||||||
|
"bottom": "© KINTEX. 版权所有。(10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"heroAria": "关于 KINTEX",
|
||||||
|
"eyebrow": "全球展览平台",
|
||||||
|
"heroTitleLine1": "连接世界的展会,",
|
||||||
|
"heroTitleEm": "KINTEX",
|
||||||
|
"heroLead": "在韩国最大规模的展览会议中心 KINTEX,体验商业与文化的全新创新。",
|
||||||
|
"ctaRegister": "观众预登记",
|
||||||
|
"ctaFacility": "查看设施指南",
|
||||||
|
"eventsAria": "正在进行及即将举办的展会",
|
||||||
|
"eventsTitle": "正在进行 · 即将举办",
|
||||||
|
"eventsSub": "了解 KINTEX 当前的重要资讯。",
|
||||||
|
"eventsMore": "查看全部日程",
|
||||||
|
"loading": "正在加载展会信息…",
|
||||||
|
"empty": "当前没有正在进行或即将举办的展会。",
|
||||||
|
"tagExhibition": "EXHIBITION",
|
||||||
|
"booth": "{{count}} 个展位",
|
||||||
|
"aiOptimized": "AI 优化",
|
||||||
|
"ongoing": "进行中",
|
||||||
|
"ended": "已结束",
|
||||||
|
"dday": "倒计时 {{n}} 天",
|
||||||
|
"ddayZero": "今日开幕",
|
||||||
|
"register": "预登记",
|
||||||
|
"hallTbd": "展厅待分配",
|
||||||
|
"transportAria": "交通路线",
|
||||||
|
"transportTitle": "交通路线",
|
||||||
|
"transportSub": "以更便捷的交通抵达 KINTEX。",
|
||||||
|
"gtxTitle": "GTX-A KINTEX 站正式开通",
|
||||||
|
"gtxDesc": "距 KINTEX 站步行3分钟",
|
||||||
|
"transit": "公共交通",
|
||||||
|
"transitDesc": "城际巴士及地铁",
|
||||||
|
"parking": "停车指南",
|
||||||
|
"parkingDesc": "可同时停放4,000辆",
|
||||||
|
"shuttle": "接驳巴士",
|
||||||
|
"shuttleDesc": "主要枢纽循环运行",
|
||||||
|
"mapAria": "KINTEX 位置地图",
|
||||||
|
"mapAddress": "高阳市一山西区KINTEX路217-60",
|
||||||
|
"mapPlace": "KINTEX 第1、2展馆",
|
||||||
|
"mapViewAria": "查看地图"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"tabs": {
|
||||||
|
"intro": "介绍",
|
||||||
|
"program": "议程",
|
||||||
|
"speakers": "演讲嘉宾",
|
||||||
|
"exhibitors": "参展商",
|
||||||
|
"transport": "交通",
|
||||||
|
"faq": "常见问题"
|
||||||
|
},
|
||||||
|
"eyebrow": "KINTEX Exhibition",
|
||||||
|
"loadingTitle": "正在加载展会信息…",
|
||||||
|
"fallbackTitle": "展会详情",
|
||||||
|
"dateTbd": "日期确认中",
|
||||||
|
"placeTbd": "展厅待分配",
|
||||||
|
"lead": "查看 KINTEX 举办的展会信息并进行预登记。",
|
||||||
|
"factDate": "日期",
|
||||||
|
"factPlace": "地点",
|
||||||
|
"ctaRegister": "立即预登记",
|
||||||
|
"ctaFloorplan": "查看展馆平面图",
|
||||||
|
"navAria": "展会详情导航",
|
||||||
|
"shareAria": "分享",
|
||||||
|
"share": "分享",
|
||||||
|
"copyLink": "复制链接",
|
||||||
|
"overviewAria": "展会概览",
|
||||||
|
"overviewTitle": "展会概览",
|
||||||
|
"overviewLead": "智能工厂韩国 2026 是新一代制造技术的集大成。在同一场地体验结合 AI、机器人、物联网和数字孪生技术的最新解决方案,全球500余家领先企业参展。",
|
||||||
|
"statBooth": "参展展位",
|
||||||
|
"statVisitors": "预计参观人数",
|
||||||
|
"statVisitorsValue": "30,000+",
|
||||||
|
"themesTitle": "主要展示主题",
|
||||||
|
"theme1Title": "AI 与自主机器人",
|
||||||
|
"theme1Desc": "能够自主学习与判断的智能机器人解决方案",
|
||||||
|
"theme2Title": "数字孪生与物联网",
|
||||||
|
"theme2Desc": "虚拟世界与现实制造现场的实时同步",
|
||||||
|
"theme3Title": "碳中和智能制造",
|
||||||
|
"theme3Desc": "最大化能源效率的环保工艺技术",
|
||||||
|
"programAria": "议程安排",
|
||||||
|
"programTitle": "议程安排",
|
||||||
|
"day1": "9月5日 (Day 1)",
|
||||||
|
"day2": "9月6日 (Day 2)",
|
||||||
|
"agenda1Title": "制造业的未来:AI 引领的生产力革命",
|
||||||
|
"agenda1Desc": "全球 AI 学者提出的2030制造战略与实际实施案例分析",
|
||||||
|
"agenda1Room": "主会议厅",
|
||||||
|
"agenda2Title": "工业5G与边缘计算的结合",
|
||||||
|
"agenda2Desc": "通过无延迟数据传输实现实时工艺控制技术演示",
|
||||||
|
"agenda2Room": "研讨室 A",
|
||||||
|
"agenda3Title": "全球采购商配对日",
|
||||||
|
"agenda3Desc": "海外优质企业与国内供应商之间的一对一商务洽谈",
|
||||||
|
"agenda3Room": "商务休息室",
|
||||||
|
"speakersTitle": "主要演讲嘉宾",
|
||||||
|
"speaker1Role": "AI 研究所所长 | TechCorp",
|
||||||
|
"speaker2Role": "CTO | Automatech",
|
||||||
|
"speaker3Role": "机器人工程学讲席教授 | Korea Univ.",
|
||||||
|
"speaker4Role": "Global VP | Digitron",
|
||||||
|
"profileAria": "{{name}} 简介",
|
||||||
|
"faqAria": "常见问题",
|
||||||
|
"faqTitle": "常见问题",
|
||||||
|
"faq1Q": "如何进行预登记?",
|
||||||
|
"faq1A": "点击顶部\"立即预登记\"按钮,选择类型并填写信息后,即可获得移动徽章。",
|
||||||
|
"faq2Q": "是否收取门票?",
|
||||||
|
"faq2A": "普通观众预登记后免费。现场登记可能收费。",
|
||||||
|
"faq3Q": "是否提供停车位?",
|
||||||
|
"faq3A": "KINTEX 第1、2展馆最多可同时停放4,000辆车。",
|
||||||
|
"fabCta": "前往预登记"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"steps": { "type": "类型", "info": "信息", "interest": "兴趣领域", "done": "完成" },
|
||||||
|
"stepsAria": "预登记步骤",
|
||||||
|
"type": {
|
||||||
|
"general": "普通观众",
|
||||||
|
"generalDesc": "可参观展览及公开会议的普通访客",
|
||||||
|
"buyer": "采购商",
|
||||||
|
"buyerDesc": "用于B2B配对及商务洽谈的企业负责人",
|
||||||
|
"vip": "VIP",
|
||||||
|
"vipDesc": "享有专属贵宾休息室及全场次入场的受邀嘉宾"
|
||||||
|
},
|
||||||
|
"step0Title": "选择参加类型",
|
||||||
|
"step0Sub": "请选择您参加 KINTEX 展会的资格。",
|
||||||
|
"typeGroupAria": "参加类型",
|
||||||
|
"step1Title": "填写基本信息",
|
||||||
|
"step1Sub": "请填写用于联系及发放通行证的信息。",
|
||||||
|
"name": "姓名",
|
||||||
|
"namePh": "请输入姓名",
|
||||||
|
"phone": "联系电话 (手机)",
|
||||||
|
"email": "电子邮箱",
|
||||||
|
"emailPh": "example@domain.com",
|
||||||
|
"org": "所属 (公司/机构)",
|
||||||
|
"orgPh": "公司或学校名称",
|
||||||
|
"step2Title": "选择兴趣领域",
|
||||||
|
"step2Sub": "选择您的兴趣领域,我们将为您提供定制化信息。",
|
||||||
|
"interestsAria": "兴趣领域",
|
||||||
|
"interest": {
|
||||||
|
"ai": "AI 解决方案",
|
||||||
|
"factory": "智能工厂",
|
||||||
|
"robotics": "机器人",
|
||||||
|
"bigdata": "大数据",
|
||||||
|
"cloud": "云计算"
|
||||||
|
},
|
||||||
|
"consentPrivacy": "同意收集及使用个人信息",
|
||||||
|
"required": "(必填)",
|
||||||
|
"consentMarketing": "同意接收营销信息 (选填)",
|
||||||
|
"consentMore": "查看详情",
|
||||||
|
"errPrivacy": "请勾选同意收集及使用个人信息 (必填)。",
|
||||||
|
"errName": "请输入姓名。",
|
||||||
|
"errEvent": "无法确认展会信息。",
|
||||||
|
"doneTitle": "预登记已受理",
|
||||||
|
"doneSub": "{{name}},您的预登记已完成。请在入口自助终端使用移动徽章。",
|
||||||
|
"visitorDefault": "观众",
|
||||||
|
"badgeAria": "移动徽章预览",
|
||||||
|
"badgeName": "张三",
|
||||||
|
"badgeCodeLabel": "Badge Code",
|
||||||
|
"doneNote": "在入口自助终端扫描二维码即可打印实体徽章。",
|
||||||
|
"regNo": "登记编号:{{id}}",
|
||||||
|
"submitting": "登记中…",
|
||||||
|
"submit": "立即登记",
|
||||||
|
"eventBrowse": "浏览展会",
|
||||||
|
"floorplan": "查看展馆平面图"
|
||||||
|
},
|
||||||
|
"floorplan": {
|
||||||
|
"sideAria": "展位搜索及列表",
|
||||||
|
"filtersTitle": "行业 · 区域 · 关键词",
|
||||||
|
"searchPh": "搜索参展商或展位号",
|
||||||
|
"searchAria": "搜索展位",
|
||||||
|
"typeFilter": "展位类型筛选",
|
||||||
|
"hallSelect": "选择展馆",
|
||||||
|
"allHalls": "全部展厅",
|
||||||
|
"listAria": "展位列表 (非可视替代)",
|
||||||
|
"loading": "正在加载展位信息…",
|
||||||
|
"searchResults": "搜索结果 ({{count}})",
|
||||||
|
"noMatch": "没有匹配的展位。",
|
||||||
|
"reservedBooth": "已预订展位",
|
||||||
|
"availableBooth": "可预订",
|
||||||
|
"type": {
|
||||||
|
"assembled": "标准展位",
|
||||||
|
"independent": "光地展位",
|
||||||
|
"corner": "角落展位",
|
||||||
|
"island": "岛型展位",
|
||||||
|
"default": "展位"
|
||||||
|
},
|
||||||
|
"stageAria": "交互式展厅地图",
|
||||||
|
"boothLayoutAria": "展位布局",
|
||||||
|
"boothAria": "展位 {{no}} {{name}}",
|
||||||
|
"loadFail": "无法加载平面图。",
|
||||||
|
"noBooth": "没有可显示的展位。",
|
||||||
|
"popInfoAria": "{{name}} 信息",
|
||||||
|
"popDetail": "展位详情",
|
||||||
|
"directions": "路线导航",
|
||||||
|
"zoomIn": "放大",
|
||||||
|
"zoomOut": "缩小",
|
||||||
|
"reset": "重置",
|
||||||
|
"myLocation": "我的位置",
|
||||||
|
"legendAria": "图例",
|
||||||
|
"legendAvailable": "可售 (Available)",
|
||||||
|
"legendReserved": "已订 (Reserved)",
|
||||||
|
"legendPublic": "公共区 (Public)"
|
||||||
|
},
|
||||||
|
"inquiry": {
|
||||||
|
"heroAria": "参展咨询",
|
||||||
|
"heroTitle": "参展咨询",
|
||||||
|
"heroLead": "与 KINTEX 一起将贵公司的业务拓展至全球市场。专业顾问将为您提供最佳展位方案。",
|
||||||
|
"cta": "参展咨询",
|
||||||
|
"guideAria": "参展指南",
|
||||||
|
"guideTitle": "参展指南",
|
||||||
|
"booth1Tier": "Entry Tier",
|
||||||
|
"booth1Title": "标准展位 (Shell Scheme)",
|
||||||
|
"booth1Desc": "提供基本墙体和地板的最经济选择。推荐给中小型企业。",
|
||||||
|
"booth1Price": "₩ 2,500,000 / 单位",
|
||||||
|
"booth2Tier": "Design Freedom",
|
||||||
|
"booth2Title": "光地展位 (Raw Space)",
|
||||||
|
"booth2Desc": "仅提供空间,可根据品牌进行自由设计。",
|
||||||
|
"booth2Price": "₩ 2,000,000 / 单位",
|
||||||
|
"booth3Tier": "Full Solution",
|
||||||
|
"booth3Title": "高级 (Premium)",
|
||||||
|
"booth3Desc": "包含 AI 优化动线设计及定制品牌套餐的一站式解决方案。",
|
||||||
|
"booth3Price": "另行咨询",
|
||||||
|
"boothExampleAria": "{{title}} 示例",
|
||||||
|
"formAria": "咨询",
|
||||||
|
"formTitle": "咨询",
|
||||||
|
"formSub": "请留下有关参展申请及展会的疑问。负责人将在工作日24小时内回复。",
|
||||||
|
"contactPhone": "电话咨询",
|
||||||
|
"contactPhoneVal": "+82-31-810-8114",
|
||||||
|
"contactEmail": "邮件咨询",
|
||||||
|
"contactEmailVal": "exhibit@kintex.com",
|
||||||
|
"contactLoc": "位置指南",
|
||||||
|
"contactLocVal": "京畿道高阳市一山西区KINTEX路217-60",
|
||||||
|
"loginHintQ": "已有账户?",
|
||||||
|
"loginHintA": "登录以快速咨询",
|
||||||
|
"company": "公司名称",
|
||||||
|
"companyPh": "请输入公司名称",
|
||||||
|
"name": "负责人",
|
||||||
|
"namePh": "请输入姓名",
|
||||||
|
"contact": "联系方式 (邮箱或电话)",
|
||||||
|
"contactPh": "请输入可联系的信息",
|
||||||
|
"scale": "预计规模",
|
||||||
|
"scaleSelect": "请选择",
|
||||||
|
"scale1": "1个展位 (9sqm)",
|
||||||
|
"scale2": "2-4个展位 (18-36sqm)",
|
||||||
|
"scale3": "5-9个展位 (45-81sqm)",
|
||||||
|
"scale4": "10个以上",
|
||||||
|
"hall": "意向展厅",
|
||||||
|
"hallSelect": "选择展厅",
|
||||||
|
"message": "咨询内容",
|
||||||
|
"messagePh": "如有参展目的或特殊要求,请留言。",
|
||||||
|
"agree": "我同意收集及使用个人信息。",
|
||||||
|
"required": "(必填)",
|
||||||
|
"errAgree": "请同意收集及使用个人信息。",
|
||||||
|
"errName": "请输入负责人姓名。",
|
||||||
|
"errContact": "请输入可联系的邮箱或电话号码。",
|
||||||
|
"errMessage": "请输入咨询内容。",
|
||||||
|
"success": "咨询已受理。受理编号 {{no}}",
|
||||||
|
"submitting": "发送中…",
|
||||||
|
"submit": "发送咨询"
|
||||||
|
},
|
||||||
|
"microsite": {
|
||||||
|
"introAria": "关于 {{brand}}",
|
||||||
|
"eyebrow": "KINTEX Exhibitor",
|
||||||
|
"ctaMeeting": "预约会议",
|
||||||
|
"ctaBrochure": "企业介绍手册",
|
||||||
|
"defaultBrand": "翰光机器人有限公司",
|
||||||
|
"defaultTagline": "驱动未来的智能机器人解决方案",
|
||||||
|
"defaultBoothLoc": "展位 A-102 · 7号厅",
|
||||||
|
"boothUndetermined": "待定",
|
||||||
|
"boothPrefix": "展位",
|
||||||
|
"boothLocLabel": "KINTEX 展馆展位位置",
|
||||||
|
"viewMap": "查看展馆平面图",
|
||||||
|
"productsAria": "核心产品线",
|
||||||
|
"productsTitle": "核心产品线",
|
||||||
|
"productsSub": "您可在现场见到的代表性产品。",
|
||||||
|
"product": "产品",
|
||||||
|
"product1Tag": "Industrial COBOT",
|
||||||
|
"product1Title": "HR-A1 协作机器人",
|
||||||
|
"product1Desc": "兼具高精度与安全性的工业协作机器人。以人机协作为最优先设计。",
|
||||||
|
"product2Tag": "Smart Logistics",
|
||||||
|
"product2Title": "HR-M3 自动驾驶 AMR",
|
||||||
|
"product2Desc": "最大化智能工厂物流效率的自主移动机器人。实时避障与路径优化。",
|
||||||
|
"product3Tag": "AI Vision",
|
||||||
|
"product3Title": "HR-Q Vision 扫描仪",
|
||||||
|
"product3Desc": "基于 AI 深度学习的完美质量检测。不放过任何微小缺陷的超精密3D扫描。",
|
||||||
|
"productImgAria": "{{title}} 图片",
|
||||||
|
"previewAria": "展位预览",
|
||||||
|
"previewTitle": "展位预览",
|
||||||
|
"previewSub": "由 AI 生成的预期展位图像。实际施工结果可能有所不同。",
|
||||||
|
"galleryMain": "主展示区",
|
||||||
|
"galleryVip": "VIP 休息室",
|
||||||
|
"galleryAria": "{{label}} AI 生成的预期图像",
|
||||||
|
"watermark": "AI 生成的预期图像 — 实际施工结果可能不同",
|
||||||
|
"meetingAria": "预约会议",
|
||||||
|
"ctaBanner": "我们将为您的业务提供量身定制的机器人解决方案。",
|
||||||
|
"ctaBannerBtn": "立即预约会议"
|
||||||
|
},
|
||||||
|
"ticket": {
|
||||||
|
"viewPurchase": "购票",
|
||||||
|
"viewManage": "查询 · 取消",
|
||||||
|
"viewSwitchAria": "切换票务视图",
|
||||||
|
"type": {
|
||||||
|
"general": "普通票",
|
||||||
|
"generalDesc": "普通观众及个人参加者",
|
||||||
|
"generalPrice": "₩0 (预登记优惠价)",
|
||||||
|
"buyer": "采购商票",
|
||||||
|
"buyerDesc": "仅限相关企业从业者及采购商",
|
||||||
|
"buyerPrice": "免费 · 需资格审核",
|
||||||
|
"group": "团体票 (10张以上)",
|
||||||
|
"groupDesc": "仅限企业团体参观",
|
||||||
|
"groupPrice": "₩9,000 / 人",
|
||||||
|
"vip": "VIP 票",
|
||||||
|
"vipDesc": "展会通票 + VIP 休息室使用权",
|
||||||
|
"vipPrice": "₩50,000"
|
||||||
|
},
|
||||||
|
"steps": { "s1": "展会·票种", "s2": "购票人", "s3": "支付", "s4": "完成" },
|
||||||
|
"stepsAria": "预订步骤",
|
||||||
|
"bannerTitle": "智能工厂韩国 2026",
|
||||||
|
"bannerDate": "2026.09.05 – 09.08",
|
||||||
|
"bannerPlace": "第2展馆 7号厅",
|
||||||
|
"posterAria": "展会海报",
|
||||||
|
"loginBanner": "登录后将自动保存预订记录。",
|
||||||
|
"loginLink": "登录",
|
||||||
|
"sample": "示例数据",
|
||||||
|
"h2Type": "选择票种",
|
||||||
|
"aiOptimized": "AI OPTIMIZED",
|
||||||
|
"off10": "10% OFF",
|
||||||
|
"soldout": "售罄",
|
||||||
|
"qtyAria": "{{name}} 数量",
|
||||||
|
"decrease": "减少",
|
||||||
|
"increase": "增加",
|
||||||
|
"h2Booker": "购票人信息",
|
||||||
|
"bookerName": "姓名",
|
||||||
|
"bookerNamePh": "姓名",
|
||||||
|
"bookerTel": "联系电话",
|
||||||
|
"bookerEmail": "邮箱 (票据发送)",
|
||||||
|
"consentPrivacy": "同意收集及使用个人信息",
|
||||||
|
"required": "(必填)",
|
||||||
|
"consentMarketing": "同意接收营销信息 (选填)",
|
||||||
|
"h2Pay": "支付方式",
|
||||||
|
"payCard": "信用卡",
|
||||||
|
"payEasy": "快捷支付",
|
||||||
|
"payBank": "银行转账",
|
||||||
|
"payGroupAria": "支付方式",
|
||||||
|
"pgNote": "支付在 PG 公司的安全页面进行,卡片信息不会存储在本网站。所有交易均受256位SSL加密保护。(PG 未接入 — 演示流程)",
|
||||||
|
"doneTitle": "预订已受理",
|
||||||
|
"doneNo": "KTX-2026-0000-DEMO",
|
||||||
|
"doneSub": "我们将通过邮件发送票据。(正式服务接入时) 共 {{count}} 张 · 支付 {{total}}",
|
||||||
|
"sampleReady": "示例数据 · 支付准备中",
|
||||||
|
"walletBtn": "打开移动票夹",
|
||||||
|
"manageBtn": "查询 / 取消预订",
|
||||||
|
"summaryAria": "预订明细摘要",
|
||||||
|
"summaryTitle": "预订明细摘要",
|
||||||
|
"summaryCount": "所选张数",
|
||||||
|
"count": "{{n}} 张",
|
||||||
|
"subtotal": "小计",
|
||||||
|
"discount": "优惠",
|
||||||
|
"finalTotal": "最终支付金额",
|
||||||
|
"nextStep": "进入下一步",
|
||||||
|
"freeDone": "完成免费预订",
|
||||||
|
"pay": "支付",
|
||||||
|
"payProceed": "继续支付",
|
||||||
|
"customerCenter": "客服中心 1588-0000 (工作日 09–18时)",
|
||||||
|
"manageTitle": "查询 · 取消预订",
|
||||||
|
"manageSub": "您可以查询并取消非会员预订记录。",
|
||||||
|
"lookNo": "预订编号",
|
||||||
|
"lookNoPh": "KTX-YYYY-XXXXXX",
|
||||||
|
"lookEmail": "邮箱",
|
||||||
|
"lookEmailPh": "example@email.com",
|
||||||
|
"lookBtn": "查询",
|
||||||
|
"loginToView": "登录查看我的预订",
|
||||||
|
"notLooked": "请输入预订编号和邮箱进行查询。(示例:输入任意值后查询)",
|
||||||
|
"booked": "预订完成",
|
||||||
|
"bookNoLabel": "预订编号",
|
||||||
|
"paidAt": "支付时间",
|
||||||
|
"colType": "票种",
|
||||||
|
"colQty": "数量",
|
||||||
|
"colAmount": "金额",
|
||||||
|
"bookerInfo": "购票人信息",
|
||||||
|
"totalPaid": "总支付金额",
|
||||||
|
"refundTitle": "取消·退款政策",
|
||||||
|
"refundD7": "参观前7天 (D-7)",
|
||||||
|
"refundD7Val": "100% 退款",
|
||||||
|
"refundD3": "参观前3天 (D-3)",
|
||||||
|
"refundD3Val": "50% 退款",
|
||||||
|
"refundDay": "参观当天",
|
||||||
|
"refundDayVal": "0% (不可退款)",
|
||||||
|
"refundEst": "当前预计退款额",
|
||||||
|
"cancelBooking": "取消预订",
|
||||||
|
"refundNote": "退款将通过 PG 公司按原支付方式处理。",
|
||||||
|
"aiHelper": "AI 结算助手",
|
||||||
|
"aiHelperDesc": "信用卡分期付款的订单,取消后通过卡公司恢复额度约需3–5个工作日。",
|
||||||
|
"cancelModalAria": "确认取消预订",
|
||||||
|
"cancelModalTitle": "确定取消此预订吗?",
|
||||||
|
"cancelModalDesc": "按 D-15 标准适用100%退款(₩40,000)。退款将通过 PG 公司按原支付方式处理,正式服务接入时进行实际退款。",
|
||||||
|
"cancelConfirm": "取消"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "登录",
|
||||||
|
"sub": "欢迎使用 KINTEX 展会管理系统。",
|
||||||
|
"email": "电子邮箱",
|
||||||
|
"emailPh": "name@company.com",
|
||||||
|
"password": "密码",
|
||||||
|
"remember": "记住账号",
|
||||||
|
"forgot": "找回密码",
|
||||||
|
"errResponse": "无法处理登录响应。请重试。",
|
||||||
|
"errLocked": "登录尝试暂时受限。请稍后重试。",
|
||||||
|
"errNotImpl": "登录服务正在准备中。请稍后重试。",
|
||||||
|
"errGeneric": "登录时发生错误。",
|
||||||
|
"submitting": "登录中…",
|
||||||
|
"submit": "登录",
|
||||||
|
"signupPrompt": "还没有账户?",
|
||||||
|
"signup": "注册",
|
||||||
|
"or": "或",
|
||||||
|
"internalLogin": "使用 KINTEX 内部账户登录",
|
||||||
|
"inviteLink": "使用邀请码加入 →",
|
||||||
|
"copyright": "© 2026 KINTEX. 版权所有。",
|
||||||
|
"wsTitle": "展会工作区",
|
||||||
|
"wsSub": "请选择您正在参与的展会。",
|
||||||
|
"wsEmptyTitle": "尚未加入任何展会",
|
||||||
|
"wsEmptyDesc": "请确认邀请链接或使用邀请码加入。",
|
||||||
|
"wsInvite": "使用邀请码加入"
|
||||||
|
},
|
||||||
|
"signup": {
|
||||||
|
"title": "注册",
|
||||||
|
"desc": "创建 KINTEX AI 展会管理账户。公司名称及邀请码为选填项。",
|
||||||
|
"email": "电子邮箱",
|
||||||
|
"emailPh": "name@company.com",
|
||||||
|
"name": "姓名",
|
||||||
|
"namePh": "张三",
|
||||||
|
"password": "密码",
|
||||||
|
"passwordPh": "8位以上",
|
||||||
|
"password2": "确认密码",
|
||||||
|
"password2Ph": "再次输入密码",
|
||||||
|
"company": "公司名称 (选填)",
|
||||||
|
"companyPh": "KINTEX 有限公司",
|
||||||
|
"invite": "邀请码 (选填)",
|
||||||
|
"invitePh": "INV-XXXX",
|
||||||
|
"mismatch": "两次密码不一致。",
|
||||||
|
"errNotImpl": "注册服务正在准备中。请稍后重试。",
|
||||||
|
"errConflict": "该邮箱已注册。",
|
||||||
|
"errGeneric": "注册时发生错误。",
|
||||||
|
"noticePending": "账户已创建。管理员审核后即可登录。",
|
||||||
|
"noticeActive": "账户已创建。现在请登录。",
|
||||||
|
"doneTitle": "注册完成",
|
||||||
|
"goLogin": "前往登录",
|
||||||
|
"cancel": "取消",
|
||||||
|
"submitting": "注册中…",
|
||||||
|
"submit": "注册"
|
||||||
|
},
|
||||||
|
"forgot": {
|
||||||
|
"requestTitle": "找回密码",
|
||||||
|
"requestDesc": "我们将向您注册的邮箱发送验证码。若账户存在,将发送邮件。",
|
||||||
|
"email": "电子邮箱",
|
||||||
|
"emailPh": "name@company.com",
|
||||||
|
"sending": "发送中…",
|
||||||
|
"requestBtn": "获取验证码",
|
||||||
|
"resetTitle": "重置密码",
|
||||||
|
"resetDesc": "请输入发送至 <0>{{email}}</0> 的验证码及新密码。",
|
||||||
|
"code": "验证码",
|
||||||
|
"codePh": "邮件收到的验证码",
|
||||||
|
"newpw": "新密码",
|
||||||
|
"newpwPh": "8位以上",
|
||||||
|
"prev": "上一步",
|
||||||
|
"changing": "更改中…",
|
||||||
|
"changeBtn": "更改密码",
|
||||||
|
"doneTitle": "密码更改完成",
|
||||||
|
"doneDesc": "请使用新密码登录。",
|
||||||
|
"goLogin": "前往登录",
|
||||||
|
"errInvalid": "验证码不正确或已过期。",
|
||||||
|
"errNotImpl": "密码重置服务正在准备中。请稍后重试。",
|
||||||
|
"errRequest": "请求时发生错误。",
|
||||||
|
"errReset": "重置时发生错误。",
|
||||||
|
"cancel": "取消"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,8 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
|
import './store/themeStore'; // 진입 즉시 data-theme 스탬프(셸 밖 화면 포함)
|
||||||
|
import './i18n'; // i18next 초기화(다국어 ko/en/zh/ja)
|
||||||
import './styles/global.css';
|
import './styles/global.css';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation, Trans } from 'react-i18next';
|
||||||
import { authApi } from '../../api/endpoints';
|
import { authApi } from '../../api/endpoints';
|
||||||
import { ApiRequestError } from '../../api/client';
|
import { ApiRequestError } from '../../api/client';
|
||||||
import { Button } from '../../components/ui/Button';
|
import { Button } from '../../components/ui/Button';
|
||||||
@ -22,6 +23,7 @@ export function ForgotPasswordDialog({
|
|||||||
onDone: (email: string) => void;
|
onDone: (email: string) => void;
|
||||||
defaultEmail?: string;
|
defaultEmail?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [step, setStep] = useState<Step>('request');
|
const [step, setStep] = useState<Step>('request');
|
||||||
const [email, setEmail] = useState(defaultEmail);
|
const [email, setEmail] = useState(defaultEmail);
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
@ -33,7 +35,7 @@ export function ForgotPasswordDialog({
|
|||||||
|
|
||||||
function degradedMessage(err: ApiRequestError): string | null {
|
function degradedMessage(err: ApiRequestError): string | null {
|
||||||
if (err.code === 'NOT_IMPLEMENTED' || err.httpStatus === 404) {
|
if (err.code === 'NOT_IMPLEMENTED' || err.httpStatus === 404) {
|
||||||
return '비밀번호 재설정 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.';
|
return t('forgot.errNotImpl');
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -49,7 +51,7 @@ export function ForgotPasswordDialog({
|
|||||||
if (err instanceof ApiRequestError) {
|
if (err instanceof ApiRequestError) {
|
||||||
setError(degradedMessage(err) ?? err.message);
|
setError(degradedMessage(err) ?? err.message);
|
||||||
} else {
|
} else {
|
||||||
setError('요청 중 오류가 발생했습니다.');
|
setError(t('forgot.errRequest'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@ -70,12 +72,12 @@ export function ForgotPasswordDialog({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiRequestError) {
|
if (err instanceof ApiRequestError) {
|
||||||
if (err.code === 'OTP_INVALID' || err.code === 'VALIDATION') {
|
if (err.code === 'OTP_INVALID' || err.code === 'VALIDATION') {
|
||||||
setError('인증코드가 올바르지 않거나 만료되었습니다.');
|
setError(t('forgot.errInvalid'));
|
||||||
} else {
|
} else {
|
||||||
setError(degradedMessage(err) ?? err.message);
|
setError(degradedMessage(err) ?? err.message);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setError('재설정 중 오류가 발생했습니다.');
|
setError(t('forgot.errReset'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@ -91,29 +93,27 @@ export function ForgotPasswordDialog({
|
|||||||
<span className="kx-modal__blocked-icon kx-modal__ok-icon" aria-hidden="true">
|
<span className="kx-modal__blocked-icon kx-modal__ok-icon" aria-hidden="true">
|
||||||
✓
|
✓
|
||||||
</span>
|
</span>
|
||||||
<h3 id="forgot-title">비밀번호 변경 완료</h3>
|
<h3 id="forgot-title">{t('forgot.doneTitle')}</h3>
|
||||||
<p>새 비밀번호로 로그인해 주세요.</p>
|
<p>{t('forgot.doneDesc')}</p>
|
||||||
<div className="kx-modal__actions">
|
<div className="kx-modal__actions">
|
||||||
<Button onClick={() => onDone(email.trim())}>로그인하러 가기</Button>
|
<Button onClick={() => onDone(email.trim())}>{t('forgot.goLogin')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : step === 'request' ? (
|
) : step === 'request' ? (
|
||||||
<form onSubmit={requestCode}>
|
<form onSubmit={requestCode}>
|
||||||
<h3 id="forgot-title" className="kx-modal__title">
|
<h3 id="forgot-title" className="kx-modal__title">
|
||||||
비밀번호 찾기
|
{t('forgot.requestTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="kx-modal__desc">
|
<p className="kx-modal__desc">{t('forgot.requestDesc')}</p>
|
||||||
가입한 이메일로 인증코드를 보내드립니다. 등록된 계정인 경우 메일이 발송됩니다.
|
|
||||||
</p>
|
|
||||||
<div className="kx-field">
|
<div className="kx-field">
|
||||||
<label htmlFor="fp-email">이메일</label>
|
<label htmlFor="fp-email">{t('forgot.email')}</label>
|
||||||
<input
|
<input
|
||||||
id="fp-email"
|
id="fp-email"
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="name@company.com"
|
placeholder={t('forgot.emailPh')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -124,42 +124,42 @@ export function ForgotPasswordDialog({
|
|||||||
)}
|
)}
|
||||||
<div className="kx-modal__actions">
|
<div className="kx-modal__actions">
|
||||||
<Button variant="ghost" type="button" onClick={onClose}>
|
<Button variant="ghost" type="button" onClick={onClose}>
|
||||||
취소
|
{t('forgot.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={submitting}>
|
<Button type="submit" disabled={submitting}>
|
||||||
{submitting ? '전송 중…' : '인증코드 받기'}
|
{submitting ? t('forgot.sending') : t('forgot.requestBtn')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={resetPassword}>
|
<form onSubmit={resetPassword}>
|
||||||
<h3 id="forgot-title" className="kx-modal__title">
|
<h3 id="forgot-title" className="kx-modal__title">
|
||||||
비밀번호 재설정
|
{t('forgot.resetTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="kx-modal__desc">
|
<p className="kx-modal__desc">
|
||||||
<strong>{email}</strong> 로 받은 인증코드와 새 비밀번호를 입력하세요.
|
<Trans i18nKey="forgot.resetDesc" values={{ email }} components={[<strong key="e" />]} />
|
||||||
</p>
|
</p>
|
||||||
<div className="kx-field">
|
<div className="kx-field">
|
||||||
<label htmlFor="fp-code">인증코드</label>
|
<label htmlFor="fp-code">{t('forgot.code')}</label>
|
||||||
<input
|
<input
|
||||||
id="fp-code"
|
id="fp-code"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={(e) => setCode(e.target.value)}
|
||||||
placeholder="메일로 받은 코드"
|
placeholder={t('forgot.codePh')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="kx-field">
|
<div className="kx-field">
|
||||||
<label htmlFor="fp-newpw">새 비밀번호</label>
|
<label htmlFor="fp-newpw">{t('forgot.newpw')}</label>
|
||||||
<input
|
<input
|
||||||
id="fp-newpw"
|
id="fp-newpw"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
placeholder="8자 이상"
|
placeholder={t('forgot.newpwPh')}
|
||||||
aria-describedby="fp-pw-hint"
|
aria-describedby="fp-pw-hint"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -179,10 +179,10 @@ export function ForgotPasswordDialog({
|
|||||||
)}
|
)}
|
||||||
<div className="kx-modal__actions">
|
<div className="kx-modal__actions">
|
||||||
<Button variant="ghost" type="button" onClick={() => setStep('request')}>
|
<Button variant="ghost" type="button" onClick={() => setStep('request')}>
|
||||||
이전
|
{t('forgot.prev')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={submitting}>
|
<Button type="submit" disabled={submitting}>
|
||||||
{submitting ? '변경 중…' : '비밀번호 변경'}
|
{submitting ? t('forgot.changing') : t('forgot.changeBtn')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { authApi } from '../../api/endpoints';
|
import { authApi } from '../../api/endpoints';
|
||||||
import { ApiRequestError } from '../../api/client';
|
import { ApiRequestError } from '../../api/client';
|
||||||
@ -6,6 +7,7 @@ import { useAuthStore } from '../../store/authStore';
|
|||||||
import { Button } from '../../components/ui/Button';
|
import { Button } from '../../components/ui/Button';
|
||||||
import { DdayChip, RoleBadge } from '../../components/ui/Badge';
|
import { DdayChip, RoleBadge } from '../../components/ui/Badge';
|
||||||
import { EmptyState } from '../../components/ui/States';
|
import { EmptyState } from '../../components/ui/States';
|
||||||
|
import { LanguageSwitcher } from '../../components/ui/LanguageSwitcher';
|
||||||
import { InviteDialog } from './InviteDialog';
|
import { InviteDialog } from './InviteDialog';
|
||||||
import { SignupDialog } from './SignupDialog';
|
import { SignupDialog } from './SignupDialog';
|
||||||
import { ForgotPasswordDialog } from './ForgotPasswordDialog';
|
import { ForgotPasswordDialog } from './ForgotPasswordDialog';
|
||||||
@ -21,6 +23,7 @@ type OtpMode = 'verify' | 'enroll';
|
|||||||
const REMEMBER_KEY = 'kintex_remember_email';
|
const REMEMBER_KEY = 'kintex_remember_email';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const applyLogin = useAuthStore((s) => s.applyLogin);
|
const applyLogin = useAuthStore((s) => s.applyLogin);
|
||||||
const selectEvent = useAuthStore((s) => s.selectEvent);
|
const selectEvent = useAuthStore((s) => s.selectEvent);
|
||||||
@ -73,20 +76,20 @@ export function LoginPage() {
|
|||||||
});
|
});
|
||||||
setPhase('otp');
|
setPhase('otp');
|
||||||
} else {
|
} else {
|
||||||
setError('로그인 응답을 처리하지 못했습니다. 다시 시도해 주세요.');
|
setError(t('login.errResponse'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiRequestError) {
|
if (err instanceof ApiRequestError) {
|
||||||
// 잠금·미구현·자격오류 등 코드별 안내(계정 존재 여부 미노출).
|
// 잠금·미구현·자격오류 등 코드별 안내(계정 존재 여부 미노출).
|
||||||
if (err.code === 'ACCOUNT_LOCKED') {
|
if (err.code === 'ACCOUNT_LOCKED') {
|
||||||
setError('로그인 시도가 일시적으로 제한되었습니다. 잠시 후 다시 시도해 주세요.');
|
setError(t('login.errLocked'));
|
||||||
} else if (err.code === 'NOT_IMPLEMENTED') {
|
} else if (err.code === 'NOT_IMPLEMENTED') {
|
||||||
setError('로그인 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.');
|
setError(t('login.errNotImpl'));
|
||||||
} else {
|
} else {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setError('로그인 중 오류가 발생했습니다.');
|
setError(t('login.errGeneric'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@ -115,7 +118,7 @@ export function LoginPage() {
|
|||||||
setPhase('login');
|
setPhase('login');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="kx-login__copy">© 2026 KINTEX. All rights reserved.</p>
|
<p className="kx-login__copy">{t('login.copyright')}</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -126,15 +129,15 @@ export function LoginPage() {
|
|||||||
<div className="kx-ws">
|
<div className="kx-ws">
|
||||||
<div className="kx-ws__inner">
|
<div className="kx-ws__inner">
|
||||||
<header className="kx-ws__header">
|
<header className="kx-ws__header">
|
||||||
<h1>행사 워크스페이스</h1>
|
<h1>{t('login.wsTitle')}</h1>
|
||||||
<p>참여 중인 행사를 선택하세요.</p>
|
<p>{t('login.wsSub')}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{workspaces.length === 0 ? (
|
{workspaces.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="참여 중인 행사가 없습니다"
|
title={t('login.wsEmptyTitle')}
|
||||||
description="초대 링크를 확인하거나 초대 코드로 참여하세요."
|
description={t('login.wsEmptyDesc')}
|
||||||
action={<Button onClick={() => setInviteOpen(true)}>초대 코드로 참여하기</Button>}
|
action={<Button onClick={() => setInviteOpen(true)}>{t('login.wsInvite')}</Button>}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -160,7 +163,7 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="kx-ws__invite-row">
|
<div className="kx-ws__invite-row">
|
||||||
<Button variant="secondary" onClick={() => setInviteOpen(true)}>
|
<Button variant="secondary" onClick={() => setInviteOpen(true)}>
|
||||||
초대 코드로 참여하기
|
{t('login.wsInvite')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -179,6 +182,9 @@ export function LoginPage() {
|
|||||||
|
|
||||||
{/* 우측 45% 로그인 카드 */}
|
{/* 우측 45% 로그인 카드 */}
|
||||||
<section className="kx-login__panel">
|
<section className="kx-login__panel">
|
||||||
|
<div className="kx-login__topbar" style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<LanguageSwitcher variant="light" />
|
||||||
|
</div>
|
||||||
<div className="kx-login__card">
|
<div className="kx-login__card">
|
||||||
<div className="kx-login__logo">
|
<div className="kx-login__logo">
|
||||||
<img
|
<img
|
||||||
@ -186,26 +192,26 @@ export function LoginPage() {
|
|||||||
src="/brand/kintex-wordmark.jpg"
|
src="/brand/kintex-wordmark.jpg"
|
||||||
alt="KINTEX"
|
alt="KINTEX"
|
||||||
/>
|
/>
|
||||||
<span className="kx-login__logo-suffix">AI 전시관리</span>
|
<span className="kx-login__logo-suffix">{t('common.appSuffix')}</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="kx-login__title">로그인</h2>
|
<h2 className="kx-login__title">{t('login.title')}</h2>
|
||||||
<p className="kx-login__title-sub">KINTEX 전시 관리 시스템에 오신 것을 환영합니다.</p>
|
<p className="kx-login__title-sub">{t('login.sub')}</p>
|
||||||
|
|
||||||
<form className="kx-login__form" onSubmit={handleLogin}>
|
<form className="kx-login__form" onSubmit={handleLogin}>
|
||||||
<div className="kx-field">
|
<div className="kx-field">
|
||||||
<label htmlFor="email">이메일</label>
|
<label htmlFor="email">{t('login.email')}</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
placeholder="name@company.com"
|
placeholder={t('login.emailPh')}
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="kx-field">
|
<div className="kx-field">
|
||||||
<label htmlFor="password">비밀번호</label>
|
<label htmlFor="password">{t('login.password')}</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
@ -225,14 +231,14 @@ export function LoginPage() {
|
|||||||
checked={remember}
|
checked={remember}
|
||||||
onChange={(e) => setRemember(e.target.checked)}
|
onChange={(e) => setRemember(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>아이디 기억</span>
|
<span>{t('login.remember')}</span>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="kx-login__text-link"
|
className="kx-login__text-link"
|
||||||
onClick={() => setForgotOpen(true)}
|
onClick={() => setForgotOpen(true)}
|
||||||
>
|
>
|
||||||
비밀번호 찾기
|
{t('login.forgot')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -243,27 +249,27 @@ export function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" block disabled={submitting}>
|
<Button type="submit" block disabled={submitting}>
|
||||||
{submitting ? '로그인 중…' : '로그인'}
|
{submitting ? t('login.submitting') : t('login.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="kx-login__signup-row">
|
<p className="kx-login__signup-row">
|
||||||
아직 계정이 없으신가요?{' '}
|
{t('login.signupPrompt')}{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="kx-login__text-link"
|
className="kx-login__text-link"
|
||||||
onClick={() => setSignupOpen(true)}
|
onClick={() => setSignupOpen(true)}
|
||||||
>
|
>
|
||||||
회원가입
|
{t('login.signup')}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="kx-login__divider">
|
<div className="kx-login__divider">
|
||||||
<span>또는</span>
|
<span>{t('login.or')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="secondary" block leadingIcon="🪪" type="button">
|
<Button variant="secondary" block leadingIcon="🪪" type="button">
|
||||||
킨텍스 내부 계정으로 로그인
|
{t('login.internalLogin')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -271,10 +277,10 @@ export function LoginPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setInviteOpen(true)}
|
onClick={() => setInviteOpen(true)}
|
||||||
>
|
>
|
||||||
초대 코드로 참여하기 →
|
{t('login.inviteLink')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="kx-login__copy">© 2026 KINTEX. All rights reserved.</p>
|
<p className="kx-login__copy">{t('login.copyright')}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{inviteOpen && <InviteDialog onClose={() => setInviteOpen(false)} />}
|
{inviteOpen && <InviteDialog onClose={() => setInviteOpen(false)} />}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { authApi } from '../../api/endpoints';
|
import { authApi } from '../../api/endpoints';
|
||||||
import { ApiRequestError } from '../../api/client';
|
import { ApiRequestError } from '../../api/client';
|
||||||
import { Button } from '../../components/ui/Button';
|
import { Button } from '../../components/ui/Button';
|
||||||
@ -20,6 +21,7 @@ export function SignupDialog({
|
|||||||
onDone: (email: string) => void;
|
onDone: (email: string) => void;
|
||||||
defaultEmail?: string;
|
defaultEmail?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [email, setEmail] = useState(defaultEmail);
|
const [email, setEmail] = useState(defaultEmail);
|
||||||
const [displayName, setDisplayName] = useState('');
|
const [displayName, setDisplayName] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@ -37,7 +39,7 @@ export function SignupDialog({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
if (password !== confirm) {
|
if (password !== confirm) {
|
||||||
setError('비밀번호가 일치하지 않습니다.');
|
setError(t('signup.mismatch'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@ -51,22 +53,19 @@ export function SignupDialog({
|
|||||||
});
|
});
|
||||||
const pending = res?.status && res.status !== 'ACTIVE';
|
const pending = res?.status && res.status !== 'ACTIVE';
|
||||||
setNotice(
|
setNotice(
|
||||||
res?.message ??
|
res?.message ?? (pending ? t('signup.noticePending') : t('signup.noticeActive')),
|
||||||
(pending
|
|
||||||
? '계정이 생성되었습니다. 관리자 승인 후 로그인할 수 있습니다.'
|
|
||||||
: '계정이 생성되었습니다. 이제 로그인해 주세요.'),
|
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiRequestError) {
|
if (err instanceof ApiRequestError) {
|
||||||
if (err.code === 'NOT_IMPLEMENTED' || err.httpStatus === 404) {
|
if (err.code === 'NOT_IMPLEMENTED' || err.httpStatus === 404) {
|
||||||
setError('회원가입 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.');
|
setError(t('signup.errNotImpl'));
|
||||||
} else if (err.code === 'CONFLICT') {
|
} else if (err.code === 'CONFLICT') {
|
||||||
setError('이미 가입된 이메일입니다.');
|
setError(t('signup.errConflict'));
|
||||||
} else {
|
} else {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setError('회원가입 중 오류가 발생했습니다.');
|
setError(t('signup.errGeneric'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@ -82,53 +81,51 @@ export function SignupDialog({
|
|||||||
<span className="kx-modal__blocked-icon kx-modal__ok-icon" aria-hidden="true">
|
<span className="kx-modal__blocked-icon kx-modal__ok-icon" aria-hidden="true">
|
||||||
✓
|
✓
|
||||||
</span>
|
</span>
|
||||||
<h3 id="signup-title">회원가입 완료</h3>
|
<h3 id="signup-title">{t('signup.doneTitle')}</h3>
|
||||||
<p>{notice}</p>
|
<p>{notice}</p>
|
||||||
<div className="kx-modal__actions">
|
<div className="kx-modal__actions">
|
||||||
<Button onClick={() => onDone(email.trim())}>로그인하러 가기</Button>
|
<Button onClick={() => onDone(email.trim())}>{t('signup.goLogin')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<h3 id="signup-title" className="kx-modal__title">
|
<h3 id="signup-title" className="kx-modal__title">
|
||||||
회원가입
|
{t('signup.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="kx-modal__desc">
|
<p className="kx-modal__desc">{t('signup.desc')}</p>
|
||||||
KINTEX AI 전시관리 계정을 만듭니다. 회사명·초대코드는 선택 사항입니다.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="kx-field">
|
<div className="kx-field">
|
||||||
<label htmlFor="su-email">이메일</label>
|
<label htmlFor="su-email">{t('signup.email')}</label>
|
||||||
<input
|
<input
|
||||||
id="su-email"
|
id="su-email"
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="name@company.com"
|
placeholder={t('signup.emailPh')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="kx-field">
|
<div className="kx-field">
|
||||||
<label htmlFor="su-name">이름</label>
|
<label htmlFor="su-name">{t('signup.name')}</label>
|
||||||
<input
|
<input
|
||||||
id="su-name"
|
id="su-name"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
placeholder="홍길동"
|
placeholder={t('signup.namePh')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="kx-field">
|
<div className="kx-field">
|
||||||
<label htmlFor="su-pw">비밀번호</label>
|
<label htmlFor="su-pw">{t('signup.password')}</label>
|
||||||
<input
|
<input
|
||||||
id="su-pw"
|
id="su-pw"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="8자 이상"
|
placeholder={t('signup.passwordPh')}
|
||||||
aria-describedby="su-pw-hint"
|
aria-describedby="su-pw-hint"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -142,40 +139,38 @@ export function SignupDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kx-field">
|
<div className="kx-field">
|
||||||
<label htmlFor="su-pw2">비밀번호 확인</label>
|
<label htmlFor="su-pw2">{t('signup.password2')}</label>
|
||||||
<input
|
<input
|
||||||
id="su-pw2"
|
id="su-pw2"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={confirm}
|
value={confirm}
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
placeholder="비밀번호 재입력"
|
placeholder={t('signup.password2Ph')}
|
||||||
aria-invalid={mismatch}
|
aria-invalid={mismatch}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{mismatch && (
|
{mismatch && (
|
||||||
<span className="kx-pw__label kx-pw__label--error">
|
<span className="kx-pw__label kx-pw__label--error">{t('signup.mismatch')}</span>
|
||||||
비밀번호가 일치하지 않습니다.
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="kx-field">
|
<div className="kx-field">
|
||||||
<label htmlFor="su-company">회사명 (선택)</label>
|
<label htmlFor="su-company">{t('signup.company')}</label>
|
||||||
<input
|
<input
|
||||||
id="su-company"
|
id="su-company"
|
||||||
autoComplete="organization"
|
autoComplete="organization"
|
||||||
value={companyName}
|
value={companyName}
|
||||||
onChange={(e) => setCompanyName(e.target.value)}
|
onChange={(e) => setCompanyName(e.target.value)}
|
||||||
placeholder="(주)킨텍스"
|
placeholder={t('signup.companyPh')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="kx-field">
|
<div className="kx-field">
|
||||||
<label htmlFor="su-invite">초대 코드 (선택)</label>
|
<label htmlFor="su-invite">{t('signup.invite')}</label>
|
||||||
<input
|
<input
|
||||||
id="su-invite"
|
id="su-invite"
|
||||||
value={inviteCode}
|
value={inviteCode}
|
||||||
onChange={(e) => setInviteCode(e.target.value)}
|
onChange={(e) => setInviteCode(e.target.value)}
|
||||||
placeholder="INV-XXXX"
|
placeholder={t('signup.invitePh')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -187,10 +182,10 @@ export function SignupDialog({
|
|||||||
|
|
||||||
<div className="kx-modal__actions">
|
<div className="kx-modal__actions">
|
||||||
<Button variant="ghost" type="button" onClick={onClose}>
|
<Button variant="ghost" type="button" onClick={onClose}>
|
||||||
취소
|
{t('signup.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={submitting || mismatch}>
|
<Button type="submit" disabled={submitting || mismatch}>
|
||||||
{submitting ? '가입 중…' : '가입하기'}
|
{submitting ? t('signup.submitting') : t('signup.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
* 데이터: GET /api/public/events/{eventId} (공개 카탈로그). 아젠다·연사·FAQ는 안내용 예시(전용 API 후속).
|
* 데이터: GET /api/public/events/{eventId} (공개 카탈로그). 아젠다·연사·FAQ는 안내용 예시(전용 API 후속).
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { PublicShell } from './PublicShell';
|
import { PublicShell } from './PublicShell';
|
||||||
import { publicApi, type PublicEvent } from './publicApi';
|
import { publicApi, type PublicEvent } from './publicApi';
|
||||||
@ -22,52 +23,35 @@ import {
|
|||||||
} from './publicIcons';
|
} from './publicIcons';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'intro', label: '소개' },
|
{ key: 'intro', i18n: 'detail.tabs.intro' },
|
||||||
{ key: 'program', label: '프로그램' },
|
{ key: 'program', i18n: 'detail.tabs.program' },
|
||||||
{ key: 'speakers', label: '연사' },
|
{ key: 'speakers', i18n: 'detail.tabs.speakers' },
|
||||||
{ key: 'exhibitors', label: '참가업체' },
|
{ key: 'exhibitors', i18n: 'detail.tabs.exhibitors' },
|
||||||
{ key: 'transport', label: '교통' },
|
{ key: 'transport', i18n: 'detail.tabs.transport' },
|
||||||
{ key: 'faq', label: 'FAQ' },
|
{ key: 'faq', i18n: 'detail.tabs.faq' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const THEMES = [
|
const THEMES = [
|
||||||
{ ic: <IconRobot width={40} height={40} />, title: 'AI & 자율 로봇', desc: '스스로 학습하고 판단하는 스마트 로봇 솔루션' },
|
{ ic: <IconRobot width={40} height={40} />, titleKey: 'detail.theme1Title', descKey: 'detail.theme1Desc' },
|
||||||
{ ic: <IconCloud width={40} height={40} />, title: '디지털 트윈 & IoT', desc: '가상 세계와 현실 제조 현장의 실시간 동기화' },
|
{ ic: <IconCloud width={40} height={40} />, titleKey: 'detail.theme2Title', descKey: 'detail.theme2Desc' },
|
||||||
{ ic: <IconLeaf width={40} height={40} />, title: '탄소중립 스마트 제조', desc: '에너지 효율 극대화를 위한 친환경 공정 기술' },
|
{ ic: <IconLeaf width={40} height={40} />, titleKey: 'detail.theme3Title', descKey: 'detail.theme3Desc' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const AGENDA = [
|
const AGENDA = [
|
||||||
{
|
{ time: '10:00 – 11:30', roomKey: 'detail.agenda1Room', tag: 'KEYNOTE', titleKey: 'detail.agenda1Title', descKey: 'detail.agenda1Desc' },
|
||||||
time: '10:00 – 11:30',
|
{ time: '13:00 – 14:30', roomKey: 'detail.agenda2Room', tag: 'TECH SESSION', titleKey: 'detail.agenda2Title', descKey: 'detail.agenda2Desc' },
|
||||||
room: '메인 컨퍼런스 홀',
|
{ time: '15:00 – 17:00', roomKey: 'detail.agenda3Room', tag: 'NETWORKING', titleKey: 'detail.agenda3Title', descKey: 'detail.agenda3Desc' },
|
||||||
tag: 'KEYNOTE',
|
|
||||||
title: '제조업의 미래: AI가 이끄는 생산성 혁명',
|
|
||||||
desc: 'AI 글로벌 석학들이 제안하는 2030 제조 전략과 실제 구현 사례 분석',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
time: '13:00 – 14:30',
|
|
||||||
room: '세미나실 A',
|
|
||||||
tag: 'TECH SESSION',
|
|
||||||
title: '산업용 5G와 엣지 컴퓨팅의 결합',
|
|
||||||
desc: '지연 없는 데이터 전송을 통한 실시간 공정 제어 기술 시연',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
time: '15:00 – 17:00',
|
|
||||||
room: '비즈니스 라운지',
|
|
||||||
tag: 'NETWORKING',
|
|
||||||
title: 'Global Buyers Matching Day',
|
|
||||||
desc: '해외 유망 기업과 국내 공급사 간의 1:1 비즈니스 미팅',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const SPEAKERS = [
|
const SPEAKERS = [
|
||||||
{ name: '김철수', role: 'AI 연구소장 | TechCorp' },
|
{ name: '김철수', roleKey: 'detail.speaker1Role' },
|
||||||
{ name: '이영희', role: 'CTO | Automatech' },
|
{ name: '이영희', roleKey: 'detail.speaker2Role' },
|
||||||
{ name: '박지성', role: '로봇공학 석좌교수 | Korea Univ.' },
|
{ name: '박지성', roleKey: 'detail.speaker3Role' },
|
||||||
{ name: 'Sarah Jenkins', role: 'Global VP | Digitron' },
|
{ name: 'Sarah Jenkins', roleKey: 'detail.speaker4Role' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function PublicEventDetailPage() {
|
export function PublicEventDetailPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { eventId } = useParams<{ eventId: string }>();
|
const { eventId } = useParams<{ eventId: string }>();
|
||||||
const [activeTab, setActiveTab] = useState('intro');
|
const [activeTab, setActiveTab] = useState('intro');
|
||||||
const [day, setDay] = useState(1);
|
const [day, setDay] = useState(1);
|
||||||
@ -94,17 +78,17 @@ export function PublicEventDetailPage() {
|
|||||||
};
|
};
|
||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
const title = event?.name ?? (loading ? '행사 정보를 불러오는 중…' : '행사 상세');
|
const title = event?.name ?? (loading ? t('detail.loadingTitle') : t('detail.fallbackTitle'));
|
||||||
const dateRange = event ? formatRange(event.startDate, event.endDate) : '일정 확인 중';
|
const dateRange = event ? formatRange(event.startDate, event.endDate) : t('detail.dateTbd');
|
||||||
const place = event?.hallLabel ?? '홀 배정 예정';
|
const place = event?.hallLabel ?? t('detail.placeTbd');
|
||||||
const registerTo = eventId ? `/public/events/${encodeURIComponent(eventId)}/register` : '#program';
|
const registerTo = eventId ? `/public/events/${encodeURIComponent(eventId)}/register` : '#program';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicShell active="events" cta="사전등록하기">
|
<PublicShell active="events" cta={t('detail.ctaRegister')}>
|
||||||
{/* 행사 히어로 */}
|
{/* 행사 히어로 */}
|
||||||
<section
|
<section
|
||||||
className="kxp-hero"
|
className="kxp-hero"
|
||||||
aria-label="스마트팩토리 코리아 2026"
|
aria-label={title}
|
||||||
style={{ backgroundColor: 'var(--color-canvas-bg)' }}
|
style={{ backgroundColor: 'var(--color-canvas-bg)' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -115,37 +99,35 @@ export function PublicEventDetailPage() {
|
|||||||
<div className="kxp-hero__scrim" aria-hidden />
|
<div className="kxp-hero__scrim" aria-hidden />
|
||||||
<div className="kxp-hero__inner">
|
<div className="kxp-hero__inner">
|
||||||
<div className="kxp-hero__content">
|
<div className="kxp-hero__content">
|
||||||
<span className="kxp-hero__eyebrow">KINTEX Exhibition</span>
|
<span className="kxp-hero__eyebrow">{t('detail.eyebrow')}</span>
|
||||||
<h1 className="kxp-hero__title">{title}</h1>
|
<h1 className="kxp-hero__title">{title}</h1>
|
||||||
<p className="kxp-hero__lead">
|
<p className="kxp-hero__lead">{error ? error : t('detail.lead')}</p>
|
||||||
{error ? error : '킨텍스에서 열리는 전시회 정보를 확인하고 사전등록하세요.'}
|
|
||||||
</p>
|
|
||||||
<div className="kxp-eventhero__facts kxp-hero__meta">
|
<div className="kxp-eventhero__facts kxp-hero__meta">
|
||||||
<span className="kxp-hero__metacard">
|
<span className="kxp-hero__metacard">
|
||||||
<IconCalendar width={18} height={18} />
|
<IconCalendar width={18} height={18} />
|
||||||
<span>
|
<span>
|
||||||
<small>일시</small>
|
<small>{t('detail.factDate')}</small>
|
||||||
<strong>{dateRange}</strong>
|
<strong>{dateRange}</strong>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="kxp-hero__metacard">
|
<span className="kxp-hero__metacard">
|
||||||
<IconPin width={18} height={18} />
|
<IconPin width={18} height={18} />
|
||||||
<span>
|
<span>
|
||||||
<small>장소</small>
|
<small>{t('detail.factPlace')}</small>
|
||||||
<strong>{place}</strong>
|
<strong>{place}</strong>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-hero__actions">
|
<div className="kxp-hero__actions">
|
||||||
<Link className="kxp-btn kxp-btn--primary kxp-btn--lg" to={registerTo}>
|
<Link className="kxp-btn kxp-btn--primary kxp-btn--lg" to={registerTo}>
|
||||||
사전등록하기
|
{t('detail.ctaRegister')}
|
||||||
</Link>
|
</Link>
|
||||||
{eventId && (
|
{eventId && (
|
||||||
<Link
|
<Link
|
||||||
className="kxp-btn kxp-btn--ghost kxp-btn--lg"
|
className="kxp-btn kxp-btn--ghost kxp-btn--lg"
|
||||||
to={`/public/events/${encodeURIComponent(eventId)}/floorplan`}
|
to={`/public/events/${encodeURIComponent(eventId)}/floorplan`}
|
||||||
>
|
>
|
||||||
전시장 도면 보기
|
{t('detail.ctaFloorplan')}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -154,52 +136,48 @@ export function PublicEventDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 서브탭(앵커) */}
|
{/* 서브탭(앵커) */}
|
||||||
<nav className="kxp-subtabs" aria-label="행사 상세 내비게이션">
|
<nav className="kxp-subtabs" aria-label={t('detail.navAria')}>
|
||||||
<div className="kxp-subtabs__inner">
|
<div className="kxp-subtabs__inner">
|
||||||
{TABS.map((t) => (
|
{TABS.map((tab) => (
|
||||||
<a
|
<a
|
||||||
key={t.key}
|
key={tab.key}
|
||||||
href={`#${t.key}`}
|
href={`#${tab.key}`}
|
||||||
className={`kxp-subtab${activeTab === t.key ? ' is-active' : ''}`}
|
className={`kxp-subtab${activeTab === tab.key ? ' is-active' : ''}`}
|
||||||
onClick={() => setActiveTab(t.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t(tab.i18n)}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 공유 사이드 */}
|
{/* 공유 사이드 */}
|
||||||
<div className="kxp-share" aria-label="공유">
|
<div className="kxp-share" aria-label={t('detail.shareAria')}>
|
||||||
<button type="button" className="kxp-share__btn" title="공유하기">
|
<button type="button" className="kxp-share__btn" title={t('detail.share')}>
|
||||||
<IconShare />
|
<IconShare />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="kxp-share__btn" title="링크 복사">
|
<button type="button" className="kxp-share__btn" title={t('detail.copyLink')}>
|
||||||
<IconLink />
|
<IconLink />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 소개 */}
|
{/* 소개 */}
|
||||||
<section className="kxp-section" id="intro" aria-label="전시회 개요">
|
<section className="kxp-section" id="intro" aria-label={t('detail.overviewAria')}>
|
||||||
<div className="kxp-wrap">
|
<div className="kxp-wrap">
|
||||||
<div className="kxp-detail">
|
<div className="kxp-detail">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="kxp-section__title">전시회 개요</h2>
|
<h2 className="kxp-section__title">{t('detail.overviewTitle')}</h2>
|
||||||
<p className="kxp-detail__lead">
|
<p className="kxp-detail__lead">{t('detail.overviewLead')}</p>
|
||||||
스마트팩토리 코리아 2026은 차세대 제조 기술의 집약체입니다. AI, 로보틱스, IoT,
|
|
||||||
그리고 디지털 트윈 기술이 결합된 최신 솔루션을 한자리에서 확인하십시오. 전 세계
|
|
||||||
500개 이상의 선도 기업이 참여합니다.
|
|
||||||
</p>
|
|
||||||
<div className="kxp-detail__stats">
|
<div className="kxp-detail__stats">
|
||||||
<div className="kxp-statcard">
|
<div className="kxp-statcard">
|
||||||
<IconFactory width={28} height={28} />
|
<IconFactory width={28} height={28} />
|
||||||
<strong>{event?.boothCount != null && event.boothCount > 0 ? `${event.boothCount}+` : '500+'}</strong>
|
<strong>{event?.boothCount != null && event.boothCount > 0 ? `${event.boothCount}+` : '500+'}</strong>
|
||||||
<span>참가 부스</span>
|
<span>{t('detail.statBooth')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-statcard">
|
<div className="kxp-statcard">
|
||||||
<IconRobot width={28} height={28} />
|
<IconRobot width={28} height={28} />
|
||||||
<strong>30,000+</strong>
|
<strong>{t('detail.statVisitorsValue')}</strong>
|
||||||
<span>예상 참관객</span>
|
<span>{t('detail.statVisitors')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -207,19 +185,19 @@ export function PublicEventDetailPage() {
|
|||||||
className="kxp-detail__img"
|
className="kxp-detail__img"
|
||||||
style={{ background: 'linear-gradient(135deg, #0066b3 0%, #6d4aff 120%)' }}
|
style={{ background: 'linear-gradient(135deg, #0066b3 0%, #6d4aff 120%)' }}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="전시장 전경"
|
aria-label={t('detail.overviewAria')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="kxp-section__title" style={{ fontSize: 22, textAlign: 'center', marginTop: 48 }}>
|
<h3 className="kxp-section__title" style={{ fontSize: 22, textAlign: 'center', marginTop: 48 }}>
|
||||||
주요 전시 테마
|
{t('detail.themesTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="kxp-themes">
|
<div className="kxp-themes">
|
||||||
{THEMES.map((t) => (
|
{THEMES.map((theme) => (
|
||||||
<div className="kxp-theme" key={t.title}>
|
<div className="kxp-theme" key={theme.titleKey}>
|
||||||
{t.ic}
|
{theme.ic}
|
||||||
<h4>{t.title}</h4>
|
<h4>{t(theme.titleKey)}</h4>
|
||||||
<p>{t.desc}</p>
|
<p>{t(theme.descKey)}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -227,11 +205,11 @@ export function PublicEventDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 프로그램 */}
|
{/* 프로그램 */}
|
||||||
<section className="kxp-section kxp-section--alt" id="program" aria-label="프로그램 일정">
|
<section className="kxp-section kxp-section--alt" id="program" aria-label={t('detail.programAria')}>
|
||||||
<div className="kxp-wrap">
|
<div className="kxp-wrap">
|
||||||
<div className="kxp-section__head">
|
<div className="kxp-section__head">
|
||||||
<h2 className="kxp-section__title">프로그램 일정</h2>
|
<h2 className="kxp-section__title">{t('detail.programTitle')}</h2>
|
||||||
<div className="kxp-daytabs" role="tablist" aria-label="일자 선택">
|
<div className="kxp-daytabs" role="tablist" aria-label={t('detail.programAria')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
@ -239,7 +217,7 @@ export function PublicEventDetailPage() {
|
|||||||
className={`kxp-daytab${day === 1 ? ' is-active' : ''}`}
|
className={`kxp-daytab${day === 1 ? ' is-active' : ''}`}
|
||||||
onClick={() => setDay(1)}
|
onClick={() => setDay(1)}
|
||||||
>
|
>
|
||||||
9월 5일 (Day 1)
|
{t('detail.day1')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -248,24 +226,24 @@ export function PublicEventDetailPage() {
|
|||||||
className={`kxp-daytab${day === 2 ? ' is-active' : ''}`}
|
className={`kxp-daytab${day === 2 ? ' is-active' : ''}`}
|
||||||
onClick={() => setDay(2)}
|
onClick={() => setDay(2)}
|
||||||
>
|
>
|
||||||
9월 6일 (Day 2)
|
{t('detail.day2')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="kxp-agenda">
|
<div className="kxp-agenda">
|
||||||
{AGENDA.map((a) => (
|
{AGENDA.map((a) => (
|
||||||
<div className="kxp-agenda__item" key={a.title}>
|
<div className="kxp-agenda__item" key={a.titleKey}>
|
||||||
<div className="kxp-agenda__time">
|
<div className="kxp-agenda__time">
|
||||||
{a.time}
|
{a.time}
|
||||||
<small>{a.room}</small>
|
<small>{t(a.roomKey)}</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="kxp-tag" style={{ marginBottom: 8, display: 'inline-block' }}>
|
<span className="kxp-tag" style={{ marginBottom: 8, display: 'inline-block' }}>
|
||||||
{a.tag}
|
{a.tag}
|
||||||
</span>
|
</span>
|
||||||
<h4 className="kxp-agenda__title">{a.title}</h4>
|
<h4 className="kxp-agenda__title">{t(a.titleKey)}</h4>
|
||||||
<p className="kxp-agenda__desc">{a.desc}</p>
|
<p className="kxp-agenda__desc">{t(a.descKey)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -274,10 +252,10 @@ export function PublicEventDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 연사 */}
|
{/* 연사 */}
|
||||||
<section className="kxp-section" id="speakers" aria-label="주요 연사">
|
<section className="kxp-section" id="speakers" aria-label={t('detail.speakersTitle')}>
|
||||||
<div className="kxp-wrap">
|
<div className="kxp-wrap">
|
||||||
<div className="kxp-section__head" style={{ justifyContent: 'center' }}>
|
<div className="kxp-section__head" style={{ justifyContent: 'center' }}>
|
||||||
<h2 className="kxp-section__title">주요 연사</h2>
|
<h2 className="kxp-section__title">{t('detail.speakersTitle')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-speakers">
|
<div className="kxp-speakers">
|
||||||
{SPEAKERS.map((s, i) => (
|
{SPEAKERS.map((s, i) => (
|
||||||
@ -290,10 +268,10 @@ export function PublicEventDetailPage() {
|
|||||||
} 0%, #101828 160%)`,
|
} 0%, #101828 160%)`,
|
||||||
}}
|
}}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`${s.name} 프로필`}
|
aria-label={t('detail.profileAria', { name: s.name })}
|
||||||
/>
|
/>
|
||||||
<h5>{s.name}</h5>
|
<h5>{s.name}</h5>
|
||||||
<p>{s.role}</p>
|
<p>{t(s.roleKey)}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -301,16 +279,16 @@ export function PublicEventDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ (요약) */}
|
{/* FAQ (요약) */}
|
||||||
<section className="kxp-section kxp-section--tint" id="faq" aria-label="자주 묻는 질문">
|
<section className="kxp-section kxp-section--tint" id="faq" aria-label={t('detail.faqAria')}>
|
||||||
<div className="kxp-wrap">
|
<div className="kxp-wrap">
|
||||||
<div className="kxp-section__head">
|
<div className="kxp-section__head">
|
||||||
<h2 className="kxp-section__title">자주 묻는 질문</h2>
|
<h2 className="kxp-section__title">{t('detail.faqTitle')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-eventgrid" style={{ gridTemplateColumns: '1fr' }}>
|
<div className="kxp-eventgrid" style={{ gridTemplateColumns: '1fr' }}>
|
||||||
{[
|
{[
|
||||||
['사전등록은 어떻게 하나요?', '상단 "사전등록하기" 버튼에서 유형 선택 후 정보를 입력하면 모바일 배지가 발급됩니다.'],
|
[t('detail.faq1Q'), t('detail.faq1A')],
|
||||||
['입장료가 있나요?', '일반 관람객은 사전등록 시 무료입니다. 현장 등록은 유료로 운영될 수 있습니다.'],
|
[t('detail.faq2Q'), t('detail.faq2A')],
|
||||||
['주차 공간이 있나요?', '킨텍스 제1·2전시장에 최대 4,000대 동시 주차가 가능합니다.'],
|
[t('detail.faq3Q'), t('detail.faq3A')],
|
||||||
].map(([q, a]) => (
|
].map(([q, a]) => (
|
||||||
<div className="kxp-boothtype__body" key={q} style={{ background: 'var(--color-white)', border: 'var(--border-card)', borderRadius: 8 }}>
|
<div className="kxp-boothtype__body" key={q} style={{ background: 'var(--color-white)', border: 'var(--border-card)', borderRadius: 8 }}>
|
||||||
<h4 style={{ display: 'flex', gap: 8, alignItems: 'center', margin: 0, color: 'var(--color-neutral-900)', fontSize: 'var(--fs-h3)' }}>
|
<h4 style={{ display: 'flex', gap: 8, alignItems: 'center', margin: 0, color: 'var(--color-neutral-900)', fontSize: 'var(--fs-h3)' }}>
|
||||||
@ -325,7 +303,7 @@ export function PublicEventDetailPage() {
|
|||||||
|
|
||||||
{/* 사전등록 고정 CTA */}
|
{/* 사전등록 고정 CTA */}
|
||||||
<Link className="kxp-btn kxp-btn--primary kxp-btn--lg kxp-fab" to={registerTo}>
|
<Link className="kxp-btn kxp-btn--primary kxp-btn--lg kxp-fab" to={registerTo}>
|
||||||
사전등록 바로가기 <IconChevronRight width={18} height={18} />
|
{t('detail.fabCta')} <IconChevronRight width={18} height={18} />
|
||||||
</Link>
|
</Link>
|
||||||
</PublicShell>
|
</PublicShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
* 데이터: GET /api/public/events/{eventId}/floorplan (부스번호·업체명·상태·중심점).
|
* 데이터: GET /api/public/events/{eventId}/floorplan (부스번호·업체명·상태·중심점).
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { PublicShell } from './PublicShell';
|
import { PublicShell } from './PublicShell';
|
||||||
import { publicApi, type PublicFloorplan, type PublicFloorplanBooth } from './publicApi';
|
import { publicApi, type PublicFloorplan, type PublicFloorplanBooth } from './publicApi';
|
||||||
@ -19,17 +20,20 @@ import {
|
|||||||
IconTarget,
|
IconTarget,
|
||||||
} from './publicIcons';
|
} from './publicIcons';
|
||||||
|
|
||||||
/** boothType 원문(assembled|independent|corner|island 등) → 표시 라벨. */
|
/** boothType 원문(assembled|independent|corner|island 등) → i18n 키. */
|
||||||
const TYPE_LABEL: Record<string, string> = {
|
const TYPE_I18N: Record<string, string> = {
|
||||||
assembled: '조립부스',
|
assembled: 'floorplan.type.assembled',
|
||||||
independent: '독립부스',
|
independent: 'floorplan.type.independent',
|
||||||
corner: '코너부스',
|
corner: 'floorplan.type.corner',
|
||||||
island: '아일랜드',
|
island: 'floorplan.type.island',
|
||||||
};
|
};
|
||||||
const typeLabel = (t: string | null | undefined) => (t ? TYPE_LABEL[t] ?? t : '부스');
|
|
||||||
|
|
||||||
export function PublicFloorplanPage() {
|
export function PublicFloorplanPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { eventId } = useParams<{ eventId: string }>();
|
const { eventId } = useParams<{ eventId: string }>();
|
||||||
|
// boothType → 표시 라벨(번역). 알 수 없는 값은 원문 유지.
|
||||||
|
const typeLabel = (bt: string | null | undefined) =>
|
||||||
|
bt ? (TYPE_I18N[bt] ? t(TYPE_I18N[bt]) : bt) : t('floorplan.type.default');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [cat, setCat] = useState<string | null>(null);
|
const [cat, setCat] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<PublicFloorplanBooth | null>(null);
|
const [selected, setSelected] = useState<PublicFloorplanBooth | null>(null);
|
||||||
@ -94,25 +98,25 @@ export function PublicFloorplanPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicShell active="visit" cta="입장권 예매">
|
<PublicShell active="visit" cta={t('ticket.viewPurchase')}>
|
||||||
<div className="kxp-fp">
|
<div className="kxp-fp">
|
||||||
{/* 좌측 검색/필터/목록 */}
|
{/* 좌측 검색/필터/목록 */}
|
||||||
<aside className="kxp-fp__side" aria-label="부스 검색 및 목록">
|
<aside className="kxp-fp__side" aria-label={t('floorplan.sideAria')}>
|
||||||
<div className="kxp-fp__filters">
|
<div className="kxp-fp__filters">
|
||||||
<h2>업종 · 존 · 키워드</h2>
|
<h2>{t('floorplan.filtersTitle')}</h2>
|
||||||
<div className="kxp-fp__searchbox">
|
<div className="kxp-fp__searchbox">
|
||||||
<IconSearch width={18} height={18} />
|
<IconSearch width={18} height={18} />
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="참가업체 또는 부스번호 검색"
|
placeholder={t('floorplan.searchPh')}
|
||||||
aria-label="부스 검색"
|
aria-label={t('floorplan.searchAria')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="kxp-fp__label">부스 유형 필터</p>
|
<p className="kxp-fp__label">{t('floorplan.typeFilter')}</p>
|
||||||
<div className="kxp-fp__chips">
|
<div className="kxp-fp__chips">
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<button
|
<button
|
||||||
@ -130,14 +134,14 @@ export function PublicFloorplanPage() {
|
|||||||
)}
|
)}
|
||||||
{halls.length > 0 && (
|
{halls.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="kxp-fp__label">전시장 선택</p>
|
<p className="kxp-fp__label">{t('floorplan.hallSelect')}</p>
|
||||||
<select
|
<select
|
||||||
className="kxp-select"
|
className="kxp-select"
|
||||||
aria-label="전시장 선택"
|
aria-label={t('floorplan.hallSelect')}
|
||||||
value={hallId}
|
value={hallId}
|
||||||
onChange={(e) => setHallId(e.target.value)}
|
onChange={(e) => setHallId(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="all">전체 홀</option>
|
<option value="all">{t('floorplan.allHalls')}</option>
|
||||||
{halls.map((h) => (
|
{halls.map((h) => (
|
||||||
<option key={h.hallId} value={h.label}>
|
<option key={h.hallId} value={h.label}>
|
||||||
{h.label}
|
{h.label}
|
||||||
@ -148,8 +152,8 @@ export function PublicFloorplanPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="kxp-fp__list" aria-label="부스 목록 (비시각 대안)">
|
<div className="kxp-fp__list" aria-label={t('floorplan.listAria')}>
|
||||||
{loading && <p className="kxp-empty" role="status">부스 정보를 불러오는 중…</p>}
|
{loading && <p className="kxp-empty" role="status">{t('floorplan.loading')}</p>}
|
||||||
{!loading && error && (
|
{!loading && error && (
|
||||||
<p className="kxp-empty" role="alert" style={{ color: 'var(--color-error)' }}>
|
<p className="kxp-empty" role="alert" style={{ color: 'var(--color-error)' }}>
|
||||||
{error}
|
{error}
|
||||||
@ -157,8 +161,8 @@ export function PublicFloorplanPage() {
|
|||||||
)}
|
)}
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<>
|
<>
|
||||||
<p className="kxp-fp__listhint">검색 결과 ({filtered.length})</p>
|
<p className="kxp-fp__listhint">{t('floorplan.searchResults', { count: filtered.length })}</p>
|
||||||
{filtered.length === 0 && <p className="kxp-empty">일치하는 부스가 없습니다.</p>}
|
{filtered.length === 0 && <p className="kxp-empty">{t('floorplan.noMatch')}</p>}
|
||||||
{filtered.map((b) => (
|
{filtered.map((b) => (
|
||||||
<button
|
<button
|
||||||
key={boothKey(b)}
|
key={boothKey(b)}
|
||||||
@ -171,7 +175,7 @@ export function PublicFloorplanPage() {
|
|||||||
<span>
|
<span>
|
||||||
<span className="kxp-fp__row-code">{b.boothNo ?? '—'}</span>
|
<span className="kxp-fp__row-code">{b.boothNo ?? '—'}</span>
|
||||||
<span className="kxp-fp__row-name">
|
<span className="kxp-fp__row-name">
|
||||||
{b.companyName ?? (b.status === 'reserved' ? '예약 부스' : '분양 가능')}
|
{b.companyName ?? (b.status === 'reserved' ? t('floorplan.reservedBooth') : t('floorplan.availableBooth'))}
|
||||||
</span>
|
</span>
|
||||||
<span className="kxp-fp__row-cat">
|
<span className="kxp-fp__row-cat">
|
||||||
{typeLabel(b.boothType)}
|
{typeLabel(b.boothType)}
|
||||||
@ -187,13 +191,13 @@ export function PublicFloorplanPage() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 우측 인터랙티브 맵 */}
|
{/* 우측 인터랙티브 맵 */}
|
||||||
<section className="kxp-fp__stage" aria-label="인터랙티브 홀 지도">
|
<section className="kxp-fp__stage" aria-label={t('floorplan.stageAria')}>
|
||||||
<div className="kxp-fp__canvaswrap">
|
<div className="kxp-fp__canvaswrap">
|
||||||
<div
|
<div
|
||||||
className="kxp-fp__canvas"
|
className="kxp-fp__canvas"
|
||||||
style={{ transform: `scale(${scale})` }}
|
style={{ transform: `scale(${scale})` }}
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Hall 1-A 부스 배치"
|
aria-label={t('floorplan.boothLayoutAria')}
|
||||||
>
|
>
|
||||||
{scoped.map((b, i) => (
|
{scoped.map((b, i) => (
|
||||||
<button
|
<button
|
||||||
@ -201,7 +205,7 @@ export function PublicFloorplanPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={boothClass(b)}
|
className={boothClass(b)}
|
||||||
onClick={() => setSelected(b)}
|
onClick={() => setSelected(b)}
|
||||||
aria-label={`부스 ${b.boothNo ?? ''} ${b.companyName ?? ''}`}
|
aria-label={t('floorplan.boothAria', { no: b.boothNo ?? '', name: b.companyName ?? '' })}
|
||||||
>
|
>
|
||||||
{b.boothNo ?? '—'}
|
{b.boothNo ?? '—'}
|
||||||
{b.status === 'reserved' && <span className="kxp-booth__badge">R</span>}
|
{b.status === 'reserved' && <span className="kxp-booth__badge">R</span>}
|
||||||
@ -209,7 +213,7 @@ export function PublicFloorplanPage() {
|
|||||||
))}
|
))}
|
||||||
{!loading && scoped.length === 0 && (
|
{!loading && scoped.length === 0 && (
|
||||||
<p className="kxp-empty" style={{ gridColumn: '1 / -1' }}>
|
<p className="kxp-empty" style={{ gridColumn: '1 / -1' }}>
|
||||||
{error ? '도면을 불러오지 못했습니다.' : '표시할 부스가 없습니다.'}
|
{error ? t('floorplan.loadFail') : t('floorplan.noBooth')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -221,7 +225,7 @@ export function PublicFloorplanPage() {
|
|||||||
className="kxp-fp__pop"
|
className="kxp-fp__pop"
|
||||||
style={{ left: 24, top: 24 }}
|
style={{ left: 24, top: 24 }}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={`${selected.companyName ?? selected.boothNo ?? '부스'} 정보`}
|
aria-label={t('floorplan.popInfoAria', { name: selected.companyName ?? selected.boothNo ?? t('floorplan.type.default') })}
|
||||||
>
|
>
|
||||||
<div className="kxp-fp__pop-head">
|
<div className="kxp-fp__pop-head">
|
||||||
<div className="kxp-fp__pop-top">
|
<div className="kxp-fp__pop-top">
|
||||||
@ -229,14 +233,14 @@ export function PublicFloorplanPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="kxp-fp__pop-close"
|
className="kxp-fp__pop-close"
|
||||||
aria-label="닫기"
|
aria-label={t('common.actions.close')}
|
||||||
onClick={() => setSelected(null)}
|
onClick={() => setSelected(null)}
|
||||||
>
|
>
|
||||||
<IconClose width={18} height={18} />
|
<IconClose width={18} height={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h5 className="kxp-fp__pop-name">
|
<h5 className="kxp-fp__pop-name">
|
||||||
{selected.companyName ?? (selected.status === 'reserved' ? '예약 부스' : '분양 가능')}
|
{selected.companyName ?? (selected.status === 'reserved' ? t('floorplan.reservedBooth') : t('floorplan.availableBooth'))}
|
||||||
</h5>
|
</h5>
|
||||||
<p className="kxp-fp__pop-cat">
|
<p className="kxp-fp__pop-cat">
|
||||||
{typeLabel(selected.boothType)}
|
{typeLabel(selected.boothType)}
|
||||||
@ -245,10 +249,10 @@ export function PublicFloorplanPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="kxp-fp__pop-foot">
|
<div className="kxp-fp__pop-foot">
|
||||||
<a className="kxp-fp__pop-link" href="#top">
|
<a className="kxp-fp__pop-link" href="#top">
|
||||||
부스 상세 <IconArrowRight width={14} height={14} />
|
{t('floorplan.popDetail')} <IconArrowRight width={14} height={14} />
|
||||||
</a>
|
</a>
|
||||||
<a className="kxp-btn kxp-btn--outline" href="#top" style={{ padding: '6px 12px', fontSize: 12 }}>
|
<a className="kxp-btn kxp-btn--outline" href="#top" style={{ padding: '6px 12px', fontSize: 12 }}>
|
||||||
길찾기
|
{t('floorplan.directions')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -260,7 +264,7 @@ export function PublicFloorplanPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="kxp-fp__zoombtn"
|
className="kxp-fp__zoombtn"
|
||||||
aria-label="확대"
|
aria-label={t('floorplan.zoomIn')}
|
||||||
onClick={() => setScale((s) => Math.min(2, s + 0.1))}
|
onClick={() => setScale((s) => Math.min(2, s + 0.1))}
|
||||||
>
|
>
|
||||||
<IconZoomIn />
|
<IconZoomIn />
|
||||||
@ -268,7 +272,7 @@ export function PublicFloorplanPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="kxp-fp__zoombtn"
|
className="kxp-fp__zoombtn"
|
||||||
aria-label="축소"
|
aria-label={t('floorplan.zoomOut')}
|
||||||
onClick={() => setScale((s) => Math.max(0.6, s - 0.1))}
|
onClick={() => setScale((s) => Math.max(0.6, s - 0.1))}
|
||||||
>
|
>
|
||||||
<IconZoomOut />
|
<IconZoomOut />
|
||||||
@ -276,7 +280,7 @@ export function PublicFloorplanPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="kxp-fp__zoombtn"
|
className="kxp-fp__zoombtn"
|
||||||
aria-label="초기화"
|
aria-label={t('floorplan.reset')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setScale(1);
|
setScale(1);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
@ -288,7 +292,7 @@ export function PublicFloorplanPage() {
|
|||||||
<a
|
<a
|
||||||
className="kxp-btn kxp-btn--primary"
|
className="kxp-btn kxp-btn--primary"
|
||||||
href="#top"
|
href="#top"
|
||||||
aria-label="내 위치"
|
aria-label={t('floorplan.myLocation')}
|
||||||
style={{ padding: 12 }}
|
style={{ padding: 12 }}
|
||||||
>
|
>
|
||||||
<IconTarget />
|
<IconTarget />
|
||||||
@ -296,15 +300,15 @@ export function PublicFloorplanPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 범례 */}
|
{/* 범례 */}
|
||||||
<div className="kxp-fp__legend" aria-label="범례">
|
<div className="kxp-fp__legend" aria-label={t('floorplan.legendAria')}>
|
||||||
<span>
|
<span>
|
||||||
<span className="kxp-fp__dot kxp-fp__dot--avail" /> 판매 (Available)
|
<span className="kxp-fp__dot kxp-fp__dot--avail" /> {t('floorplan.legendAvailable')}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="kxp-fp__dot kxp-fp__dot--reserved" /> 예약 (Reserved)
|
<span className="kxp-fp__dot kxp-fp__dot--reserved" /> {t('floorplan.legendReserved')}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="kxp-fp__dot kxp-fp__dot--public" /> 공용부 (Public)
|
<span className="kxp-fp__dot kxp-fp__dot--public" /> {t('floorplan.legendPublic')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
* 데이터: GET /api/public/events (공개 카탈로그, 비인증).
|
* 데이터: GET /api/public/events (공개 카탈로그, 비인증).
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { PublicShell } from './PublicShell';
|
import { PublicShell } from './PublicShell';
|
||||||
import { publicApi, type PublicEvent } from './publicApi';
|
import { publicApi, type PublicEvent } from './publicApi';
|
||||||
@ -36,6 +37,7 @@ function pickFeatured(events: PublicEvent[], max = 6): PublicEvent[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PublicHomePage() {
|
export function PublicHomePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [events, setEvents] = useState<PublicEvent[]>([]);
|
const [events, setEvents] = useState<PublicEvent[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@ -63,7 +65,7 @@ export function PublicHomePage() {
|
|||||||
{/* 히어로 */}
|
{/* 히어로 */}
|
||||||
<section
|
<section
|
||||||
className="kxp-hero"
|
className="kxp-hero"
|
||||||
aria-label="킨텍스 소개"
|
aria-label={t('home.heroAria')}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--color-canvas-bg)',
|
backgroundColor: 'var(--color-canvas-bg)',
|
||||||
}}
|
}}
|
||||||
@ -80,23 +82,20 @@ export function PublicHomePage() {
|
|||||||
<div className="kxp-hero__inner">
|
<div className="kxp-hero__inner">
|
||||||
<div className="kxp-hero__content">
|
<div className="kxp-hero__content">
|
||||||
<span className="kxp-hero__eyebrow">
|
<span className="kxp-hero__eyebrow">
|
||||||
<IconSparkles width={16} height={16} /> Global Exhibition Platform
|
<IconSparkles width={16} height={16} /> {t('home.eyebrow')}
|
||||||
</span>
|
</span>
|
||||||
<h1 className="kxp-hero__title">
|
<h1 className="kxp-hero__title">
|
||||||
세계로 통하는 전시,
|
{t('home.heroTitleLine1')}
|
||||||
<br />
|
<br />
|
||||||
<em>킨텍스</em>
|
<em>{t('home.heroTitleEm')}</em>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="kxp-hero__lead">
|
<p className="kxp-hero__lead">{t('home.heroLead')}</p>
|
||||||
대한민국 최대 규모의 전시 컨벤션 센터 KINTEX에서 펼쳐지는 비즈니스와 문화의
|
|
||||||
새로운 혁신을 경험하세요.
|
|
||||||
</p>
|
|
||||||
<div className="kxp-hero__actions">
|
<div className="kxp-hero__actions">
|
||||||
<a className="kxp-btn kxp-btn--primary kxp-btn--lg" href="#events">
|
<a className="kxp-btn kxp-btn--primary kxp-btn--lg" href="#events">
|
||||||
관람 사전등록
|
{t('home.ctaRegister')}
|
||||||
</a>
|
</a>
|
||||||
<a className="kxp-btn kxp-btn--ghost kxp-btn--lg" href="#transport">
|
<a className="kxp-btn kxp-btn--ghost kxp-btn--lg" href="#transport">
|
||||||
시설 안내 보기
|
{t('home.ctaFacility')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -104,21 +103,21 @@ export function PublicHomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 진행/예정 행사 */}
|
{/* 진행/예정 행사 */}
|
||||||
<section className="kxp-section" id="events" aria-label="진행중 예정 행사">
|
<section className="kxp-section" id="events" aria-label={t('home.eventsAria')}>
|
||||||
<div className="kxp-wrap">
|
<div className="kxp-wrap">
|
||||||
<div className="kxp-section__head">
|
<div className="kxp-section__head">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="kxp-section__title">진행중 · 예정 행사</h2>
|
<h2 className="kxp-section__title">{t('home.eventsTitle')}</h2>
|
||||||
<p className="kxp-section__sub">지금 킨텍스에서 열리는 주요 소식을 확인하세요.</p>
|
<p className="kxp-section__sub">{t('home.eventsSub')}</p>
|
||||||
</div>
|
</div>
|
||||||
<a className="kxp-section__more" href="#events">
|
<a className="kxp-section__more" href="#events">
|
||||||
전체 일정 보기 <IconArrowRight width={16} height={16} />
|
{t('home.eventsMore')} <IconArrowRight width={16} height={16} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<p className="kxp-empty" role="status">
|
<p className="kxp-empty" role="status">
|
||||||
행사 정보를 불러오는 중입니다…
|
{t('home.loading')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!loading && error && (
|
{!loading && error && (
|
||||||
@ -127,7 +126,7 @@ export function PublicHomePage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!loading && !error && events.length === 0 && (
|
{!loading && !error && events.length === 0 && (
|
||||||
<p className="kxp-empty">현재 진행 중이거나 예정된 행사가 없습니다.</p>
|
<p className="kxp-empty">{t('home.empty')}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && events.length > 0 && (
|
{!loading && !error && events.length > 0 && (
|
||||||
@ -155,24 +154,24 @@ export function PublicHomePage() {
|
|||||||
/>
|
/>
|
||||||
{accentAi && (
|
{accentAi && (
|
||||||
<span className="kxp-ecard__topright kxp-chip kxp-chip--ai">
|
<span className="kxp-ecard__topright kxp-chip kxp-chip--ai">
|
||||||
<IconSparkles width={13} height={13} /> AI 최적화
|
<IconSparkles width={13} height={13} /> {t('home.aiOptimized')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{status === 'ongoing' ? (
|
{status === 'ongoing' ? (
|
||||||
<span className="kxp-ecard__botleft kxp-dday kxp-dday--soon">진행중</span>
|
<span className="kxp-ecard__botleft kxp-dday kxp-dday--soon">{t('home.ongoing')}</span>
|
||||||
) : status === 'ended' ? (
|
) : status === 'ended' ? (
|
||||||
<span className="kxp-ecard__botleft kxp-dday">종료</span>
|
<span className="kxp-ecard__botleft kxp-dday">{t('home.ended')}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className={`kxp-ecard__botleft ${ddayClass(d)}`}>
|
<span className={`kxp-ecard__botleft ${ddayClass(d)}`}>
|
||||||
{d === 0 ? 'D-DAY' : `D-${d}`}
|
{d === 0 ? t('home.ddayZero') : t('home.dday', { n: d })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-ecard__body">
|
<div className="kxp-ecard__body">
|
||||||
<div className="kxp-ecard__tags">
|
<div className="kxp-ecard__tags">
|
||||||
<span className="kxp-tag">EXHIBITION</span>
|
<span className="kxp-tag">{t('home.tagExhibition')}</span>
|
||||||
{ev.boothCount != null && ev.boothCount > 0 && (
|
{ev.boothCount != null && ev.boothCount > 0 && (
|
||||||
<span className="kxp-tag">부스 {ev.boothCount}</span>
|
<span className="kxp-tag">{t('home.booth', { count: ev.boothCount })}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="kxp-ecard__title">{ev.name}</h3>
|
<h3 className="kxp-ecard__title">{ev.name}</h3>
|
||||||
@ -181,14 +180,14 @@ export function PublicHomePage() {
|
|||||||
<IconCalendar width={18} height={18} /> {formatRange(ev.startDate, ev.endDate)}
|
<IconCalendar width={18} height={18} /> {formatRange(ev.startDate, ev.endDate)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<IconPin width={18} height={18} /> {ev.hallLabel ?? '홀 배정 예정'}
|
<IconPin width={18} height={18} /> {ev.hallLabel ?? t('home.hallTbd')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
className="kxp-btn kxp-btn--primary kxp-btn--block"
|
className="kxp-btn kxp-btn--primary kxp-btn--block"
|
||||||
to={`/public/events/${encodeURIComponent(ev.id)}/register`}
|
to={`/public/events/${encodeURIComponent(ev.id)}/register`}
|
||||||
>
|
>
|
||||||
사전등록
|
{t('home.register')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@ -200,38 +199,38 @@ export function PublicHomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 교통 */}
|
{/* 교통 */}
|
||||||
<section className="kxp-section kxp-section--alt" id="transport" aria-label="오시는 길">
|
<section className="kxp-section kxp-section--alt" id="transport" aria-label={t('home.transportAria')}>
|
||||||
<div className="kxp-wrap">
|
<div className="kxp-wrap">
|
||||||
<div className="kxp-transport">
|
<div className="kxp-transport">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="kxp-section__title">오시는 길</h2>
|
<h2 className="kxp-section__title">{t('home.transportTitle')}</h2>
|
||||||
<p className="kxp-section__sub">더욱 빨라진 접근성으로 킨텍스를 만나보세요.</p>
|
<p className="kxp-section__sub">{t('home.transportSub')}</p>
|
||||||
|
|
||||||
<div className="kxp-transport__gtx">
|
<div className="kxp-transport__gtx">
|
||||||
<span className="kxp-transport__gtx-ic">
|
<span className="kxp-transport__gtx-ic">
|
||||||
<IconTrain width={30} height={30} />
|
<IconTrain width={30} height={30} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h4>GTX-A 킨텍스역 개통</h4>
|
<h4>{t('home.gtxTitle')}</h4>
|
||||||
<p>킨텍스역에서 도보 3분 거리</p>
|
<p>{t('home.gtxDesc')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="kxp-transport__opts">
|
<div className="kxp-transport__opts">
|
||||||
<div className="kxp-transport__opt">
|
<div className="kxp-transport__opt">
|
||||||
<IconBus />
|
<IconBus />
|
||||||
<h5>대중교통</h5>
|
<h5>{t('home.transit')}</h5>
|
||||||
<span>광역버스 및 지하철</span>
|
<span>{t('home.transitDesc')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-transport__opt">
|
<div className="kxp-transport__opt">
|
||||||
<IconParking />
|
<IconParking />
|
||||||
<h5>주차안내</h5>
|
<h5>{t('home.parking')}</h5>
|
||||||
<span>4,000대 동시 주차</span>
|
<span>{t('home.parkingDesc')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-transport__opt">
|
<div className="kxp-transport__opt">
|
||||||
<IconShuttle />
|
<IconShuttle />
|
||||||
<h5>셔틀버스</h5>
|
<h5>{t('home.shuttle')}</h5>
|
||||||
<span>주요 거점 순환</span>
|
<span>{t('home.shuttleDesc')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -243,17 +242,17 @@ export function PublicHomePage() {
|
|||||||
'linear-gradient(135deg, #d9eaf7 0%, #eff6fc 60%, #ffffff 100%)',
|
'linear-gradient(135deg, #d9eaf7 0%, #eff6fc 60%, #ffffff 100%)',
|
||||||
}}
|
}}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="킨텍스 위치 지도"
|
aria-label={t('home.mapAria')}
|
||||||
>
|
>
|
||||||
<div className="kxp-transport__mapcard">
|
<div className="kxp-transport__mapcard">
|
||||||
<div>
|
<div>
|
||||||
<small>고양시 일산서구 킨텍스로 217-60</small>
|
<small>{t('home.mapAddress')}</small>
|
||||||
<strong>KINTEX 제1, 2전시장</strong>
|
<strong>{t('home.mapPlace')}</strong>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
className="kxp-btn kxp-btn--primary"
|
className="kxp-btn kxp-btn--primary"
|
||||||
href="#transport"
|
href="#transport"
|
||||||
aria-label="지도 보기"
|
aria-label={t('home.mapViewAria')}
|
||||||
style={{ padding: 12, borderRadius: '9999px' }}
|
style={{ padding: 12, borderRadius: '9999px' }}
|
||||||
>
|
>
|
||||||
<IconMap />
|
<IconMap />
|
||||||
|
|||||||
@ -4,33 +4,20 @@
|
|||||||
* 제출: POST /api/public/inquiries (접수번호 반환). 개인정보 동의(필수) 미체크 시 차단.
|
* 제출: POST /api/public/inquiries (접수번호 반환). 개인정보 동의(필수) 미체크 시 차단.
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PublicShell } from './PublicShell';
|
import { PublicShell } from './PublicShell';
|
||||||
import { publicApi } from './publicApi';
|
import { publicApi } from './publicApi';
|
||||||
import { errorMessage } from './publicFormat';
|
import { errorMessage } from './publicFormat';
|
||||||
import { IconPhone, IconMail, IconPin, IconSend, IconArrowRight, IconCheckCircle } from './publicIcons';
|
import { IconPhone, IconMail, IconPin, IconSend, IconArrowRight, IconCheckCircle } from './publicIcons';
|
||||||
|
|
||||||
const BOOTH_TYPES = [
|
const BOOTH_TYPES = [
|
||||||
{
|
{ tierKey: 'inquiry.booth1Tier', titleKey: 'inquiry.booth1Title', descKey: 'inquiry.booth1Desc', priceKey: 'inquiry.booth1Price' },
|
||||||
tier: 'Entry Tier',
|
{ tierKey: 'inquiry.booth2Tier', titleKey: 'inquiry.booth2Title', descKey: 'inquiry.booth2Desc', priceKey: 'inquiry.booth2Price' },
|
||||||
title: '조립부스 (Shell Scheme)',
|
{ tierKey: 'inquiry.booth3Tier', titleKey: 'inquiry.booth3Title', descKey: 'inquiry.booth3Desc', priceKey: 'inquiry.booth3Price' },
|
||||||
desc: '기본 벽체와 바닥이 제공되는 가장 경제적인 선택. 소규모 기업에 추천합니다.',
|
|
||||||
price: '₩ 2,500,000 / unit',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tier: 'Design Freedom',
|
|
||||||
title: '독립부스 (Raw Space)',
|
|
||||||
desc: '공간만 제공되어 브랜드에 맞는 자유로운 디자인 설계가 가능합니다.',
|
|
||||||
price: '₩ 2,000,000 / unit',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tier: 'Full Solution',
|
|
||||||
title: '프리미엄 (Premium)',
|
|
||||||
desc: 'AI 최적화 동선 설계 및 맞춤형 브랜딩 패키지가 포함된 올인원 솔루션입니다.',
|
|
||||||
price: '별도 문의',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function PublicInquiryPage() {
|
export function PublicInquiryPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [company, setCompany] = useState('');
|
const [company, setCompany] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [contact, setContact] = useState('');
|
const [contact, setContact] = useState('');
|
||||||
@ -46,19 +33,19 @@ export function PublicInquiryPage() {
|
|||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
if (!agree) {
|
if (!agree) {
|
||||||
setError('개인정보 수집 및 이용에 동의해 주세요.');
|
setError(t('inquiry.errAgree'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError('담당자 이름을 입력해 주세요.');
|
setError(t('inquiry.errName'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!contact.trim()) {
|
if (!contact.trim()) {
|
||||||
setError('연락 가능한 이메일 또는 전화번호를 입력해 주세요.');
|
setError(t('inquiry.errContact'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!message.trim()) {
|
if (!message.trim()) {
|
||||||
setError('문의 내용을 입력해 주세요.');
|
setError(t('inquiry.errMessage'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isEmail = contact.includes('@');
|
const isEmail = contact.includes('@');
|
||||||
@ -84,11 +71,11 @@ export function PublicInquiryPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicShell active="exhibit" cta="참가 문의" search={false}>
|
<PublicShell active="exhibit" cta={t('inquiry.cta')} search={false}>
|
||||||
{/* 히어로 */}
|
{/* 히어로 */}
|
||||||
<section
|
<section
|
||||||
className="kxp-hero"
|
className="kxp-hero"
|
||||||
aria-label="참가 문의"
|
aria-label={t('inquiry.heroAria')}
|
||||||
style={{ backgroundColor: 'var(--color-canvas-bg)', minHeight: 360 }}
|
style={{ backgroundColor: 'var(--color-canvas-bg)', minHeight: 360 }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -100,36 +87,33 @@ export function PublicInquiryPage() {
|
|||||||
<div className="kxp-hero__inner">
|
<div className="kxp-hero__inner">
|
||||||
<div className="kxp-hero__content">
|
<div className="kxp-hero__content">
|
||||||
<h1 className="kxp-hero__title" style={{ fontSize: 'clamp(28px, 5vw, 40px)' }}>
|
<h1 className="kxp-hero__title" style={{ fontSize: 'clamp(28px, 5vw, 40px)' }}>
|
||||||
참가 문의
|
{t('inquiry.heroTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="kxp-hero__lead">
|
<p className="kxp-hero__lead">{t('inquiry.heroLead')}</p>
|
||||||
KINTEX와 함께 귀사의 비즈니스를 글로벌 시장으로 확장하세요. 전문 컨설턴트가 최적의 전시
|
|
||||||
공간을 제안해 드립니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 참가 안내 */}
|
{/* 참가 안내 */}
|
||||||
<section className="kxp-section" aria-label="참가 안내">
|
<section className="kxp-section" aria-label={t('inquiry.guideAria')}>
|
||||||
<div className="kxp-wrap">
|
<div className="kxp-wrap">
|
||||||
<div className="kxp-section__head" style={{ justifyContent: 'center', flexDirection: 'column', textAlign: 'center' }}>
|
<div className="kxp-section__head" style={{ justifyContent: 'center', flexDirection: 'column', textAlign: 'center' }}>
|
||||||
<h2 className="kxp-section__title">참가 안내</h2>
|
<h2 className="kxp-section__title">{t('inquiry.guideTitle')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-boothtypes">
|
<div className="kxp-boothtypes">
|
||||||
{BOOTH_TYPES.map((b) => (
|
{BOOTH_TYPES.map((b) => (
|
||||||
<article className="kxp-boothtype" key={b.title}>
|
<article className="kxp-boothtype" key={b.titleKey}>
|
||||||
<div
|
<div
|
||||||
className="kxp-boothtype__media"
|
className="kxp-boothtype__media"
|
||||||
style={{ background: 'linear-gradient(135deg, #d9eaf7 0%, #eff6fc 100%)' }}
|
style={{ background: 'linear-gradient(135deg, #d9eaf7 0%, #eff6fc 100%)' }}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`${b.title} 예시`}
|
aria-label={t('inquiry.boothExampleAria', { title: t(b.titleKey) })}
|
||||||
/>
|
/>
|
||||||
<div className="kxp-boothtype__body">
|
<div className="kxp-boothtype__body">
|
||||||
<span className="kxp-tag">{b.tier}</span>
|
<span className="kxp-tag">{t(b.tierKey)}</span>
|
||||||
<h3>{b.title}</h3>
|
<h3>{t(b.titleKey)}</h3>
|
||||||
<p>{b.desc}</p>
|
<p>{t(b.descKey)}</p>
|
||||||
<div className="kxp-boothtype__price">{b.price}</div>
|
<div className="kxp-boothtype__price">{t(b.priceKey)}</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@ -138,17 +122,16 @@ export function PublicInquiryPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 문의 폼 */}
|
{/* 문의 폼 */}
|
||||||
<section className="kxp-section kxp-section--alt" aria-label="문의하기">
|
<section className="kxp-section kxp-section--alt" aria-label={t('inquiry.formAria')}>
|
||||||
<div className="kxp-wrap">
|
<div className="kxp-wrap">
|
||||||
<div className="kxp-inquiry">
|
<div className="kxp-inquiry">
|
||||||
{/* 좌: 안내·연락처 */}
|
{/* 좌: 안내·연락처 */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="kxp-section__title" style={{ fontSize: 24 }}>
|
<h2 className="kxp-section__title" style={{ fontSize: 24 }}>
|
||||||
문의하기
|
{t('inquiry.formTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="kxp-section__sub" style={{ marginBottom: 20 }}>
|
<p className="kxp-section__sub" style={{ marginBottom: 20 }}>
|
||||||
참가 신청 및 전시 관련 궁금한 점을 남겨주세요. 담당자가 영업일 기준 24시간 이내에
|
{t('inquiry.formSub')}
|
||||||
답변드립니다.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="kxp-contacts">
|
<div className="kxp-contacts">
|
||||||
<div className="kxp-contact">
|
<div className="kxp-contact">
|
||||||
@ -156,8 +139,8 @@ export function PublicInquiryPage() {
|
|||||||
<IconPhone width={18} height={18} />
|
<IconPhone width={18} height={18} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<small>전화 문의</small>
|
<small>{t('inquiry.contactPhone')}</small>
|
||||||
<strong>+82-31-810-8114</strong>
|
<strong>{t('inquiry.contactPhoneVal')}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-contact">
|
<div className="kxp-contact">
|
||||||
@ -165,8 +148,8 @@ export function PublicInquiryPage() {
|
|||||||
<IconMail width={18} height={18} />
|
<IconMail width={18} height={18} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<small>이메일 문의</small>
|
<small>{t('inquiry.contactEmail')}</small>
|
||||||
<strong>exhibit@kintex.com</strong>
|
<strong>{t('inquiry.contactEmailVal')}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-contact">
|
<div className="kxp-contact">
|
||||||
@ -174,15 +157,15 @@ export function PublicInquiryPage() {
|
|||||||
<IconPin width={18} height={18} />
|
<IconPin width={18} height={18} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<small>위치 안내</small>
|
<small>{t('inquiry.contactLoc')}</small>
|
||||||
<strong>경기도 고양시 일산서구 킨텍스로 217-60</strong>
|
<strong>{t('inquiry.contactLocVal')}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-login-hint" style={{ marginTop: 16 }}>
|
<div className="kxp-login-hint" style={{ marginTop: 16 }}>
|
||||||
<p>이미 계정이 있으신가요?</p>
|
<p>{t('inquiry.loginHintQ')}</p>
|
||||||
<a href="#top">
|
<a href="#top">
|
||||||
로그인하여 빠른 문의하기 <IconArrowRight width={16} height={16} />
|
{t('inquiry.loginHintA')} <IconArrowRight width={16} height={16} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -191,66 +174,66 @@ export function PublicInquiryPage() {
|
|||||||
<div className="kxp-formcard">
|
<div className="kxp-formcard">
|
||||||
{sent && (
|
{sent && (
|
||||||
<div className="kxp-alert kxp-alert--success" role="status">
|
<div className="kxp-alert kxp-alert--success" role="status">
|
||||||
<IconCheckCircle width={18} height={18} /> 문의가 접수되었습니다. 접수번호 {receiptNo}
|
<IconCheckCircle width={18} height={18} /> {t('inquiry.success', { no: receiptNo })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="kxp-formgrid kxp-formgrid--2">
|
<div className="kxp-formgrid kxp-formgrid--2">
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="iq-company">회사명</label>
|
<label htmlFor="iq-company">{t('inquiry.company')}</label>
|
||||||
<input
|
<input
|
||||||
id="iq-company"
|
id="iq-company"
|
||||||
className="kxp-input"
|
className="kxp-input"
|
||||||
placeholder="회사명을 입력하세요"
|
placeholder={t('inquiry.companyPh')}
|
||||||
value={company}
|
value={company}
|
||||||
onChange={(e) => setCompany(e.target.value)}
|
onChange={(e) => setCompany(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="iq-name">담당자</label>
|
<label htmlFor="iq-name">{t('inquiry.name')}</label>
|
||||||
<input
|
<input
|
||||||
id="iq-name"
|
id="iq-name"
|
||||||
className="kxp-input"
|
className="kxp-input"
|
||||||
placeholder="성함을 입력하세요"
|
placeholder={t('inquiry.namePh')}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="iq-contact">연락처 (이메일 또는 전화번호)</label>
|
<label htmlFor="iq-contact">{t('inquiry.contact')}</label>
|
||||||
<input
|
<input
|
||||||
id="iq-contact"
|
id="iq-contact"
|
||||||
className="kxp-input"
|
className="kxp-input"
|
||||||
placeholder="연락 가능한 정보를 입력하세요"
|
placeholder={t('inquiry.contactPh')}
|
||||||
value={contact}
|
value={contact}
|
||||||
onChange={(e) => setContact(e.target.value)}
|
onChange={(e) => setContact(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-formgrid kxp-formgrid--2">
|
<div className="kxp-formgrid kxp-formgrid--2">
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="iq-scale">예상 규모</label>
|
<label htmlFor="iq-scale">{t('inquiry.scale')}</label>
|
||||||
<select
|
<select
|
||||||
id="iq-scale"
|
id="iq-scale"
|
||||||
className="kxp-select"
|
className="kxp-select"
|
||||||
value={scale}
|
value={scale}
|
||||||
onChange={(e) => setScale(e.target.value)}
|
onChange={(e) => setScale(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">선택하세요</option>
|
<option value="">{t('inquiry.scaleSelect')}</option>
|
||||||
<option>1개 부스 (9sqm)</option>
|
<option>{t('inquiry.scale1')}</option>
|
||||||
<option>2-4개 부스 (18-36sqm)</option>
|
<option>{t('inquiry.scale2')}</option>
|
||||||
<option>5-9개 부스 (45-81sqm)</option>
|
<option>{t('inquiry.scale3')}</option>
|
||||||
<option>10개 이상</option>
|
<option>{t('inquiry.scale4')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="iq-hall">희망 홀</label>
|
<label htmlFor="iq-hall">{t('inquiry.hall')}</label>
|
||||||
<select
|
<select
|
||||||
id="iq-hall"
|
id="iq-hall"
|
||||||
className="kxp-select"
|
className="kxp-select"
|
||||||
value={hall}
|
value={hall}
|
||||||
onChange={(e) => setHall(e.target.value)}
|
onChange={(e) => setHall(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">홀 선택</option>
|
<option value="">{t('inquiry.hallSelect')}</option>
|
||||||
{Array.from({ length: 10 }, (_, i) => (
|
{Array.from({ length: 10 }, (_, i) => (
|
||||||
<option key={i}>Hall {i + 1}</option>
|
<option key={i}>Hall {i + 1}</option>
|
||||||
))}
|
))}
|
||||||
@ -258,11 +241,11 @@ export function PublicInquiryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="iq-msg">문의 내용</label>
|
<label htmlFor="iq-msg">{t('inquiry.message')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="iq-msg"
|
id="iq-msg"
|
||||||
className="kxp-textarea"
|
className="kxp-textarea"
|
||||||
placeholder="참가 목적이나 특별 요청사항이 있다면 남겨주세요."
|
placeholder={t('inquiry.messagePh')}
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -270,7 +253,7 @@ export function PublicInquiryPage() {
|
|||||||
<label className="kxp-check">
|
<label className="kxp-check">
|
||||||
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
|
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
|
||||||
<span>
|
<span>
|
||||||
개인정보 수집 및 이용에 동의합니다. <span className="kxp-check__req">(필수)</span>
|
{t('inquiry.agree')} <span className="kxp-check__req">{t('inquiry.required')}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{error && (
|
{error && (
|
||||||
@ -284,7 +267,7 @@ export function PublicInquiryPage() {
|
|||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
{submitting ? '전송 중…' : '문의 보내기'} <IconSend width={18} height={18} />
|
{submitting ? t('inquiry.submitting') : t('inquiry.submit')} <IconSend width={18} height={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,35 +4,16 @@
|
|||||||
* 데이터: GET /api/public/microsites/{exhibitorId} (cms 팀 계약). shape 확정 전 관용적 옵셔널 처리 — 미배포/부분 응답 시 안내 콘텐츠로 폴백.
|
* 데이터: GET /api/public/microsites/{exhibitorId} (cms 팀 계약). shape 확정 전 관용적 옵셔널 처리 — 미배포/부분 응답 시 안내 콘텐츠로 폴백.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { PublicShell } from './PublicShell';
|
import { PublicShell } from './PublicShell';
|
||||||
import { publicApi, type PublicMicrosite } from './publicApi';
|
import { publicApi, type PublicMicrosite } from './publicApi';
|
||||||
import { IconPin, IconMap, IconArrowRight } from './publicIcons';
|
import { IconPin, IconMap, IconArrowRight } from './publicIcons';
|
||||||
|
|
||||||
const PRODUCTS = [
|
const PRODUCT_ACCENTS = ['#0066b3', '#6d4aff', '#0e8a5f'];
|
||||||
{
|
|
||||||
tag: 'Industrial COBOT',
|
|
||||||
title: 'HR-A1 협동 로봇',
|
|
||||||
desc: '고도의 정밀도와 안전성을 갖춘 산업용 협동 로봇. 인간과의 협업을 최우선으로 설계했습니다.',
|
|
||||||
accent: '#0066b3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: 'Smart Logistics',
|
|
||||||
title: 'HR-M3 자율주행 AMR',
|
|
||||||
desc: '스마트 팩토리 물류 효율을 극대화하는 자율 이동 로봇. 실시간 장애물 회피와 경로 최적화.',
|
|
||||||
accent: '#6d4aff',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: 'AI Vision',
|
|
||||||
title: 'HR-Q Vision 스캐너',
|
|
||||||
desc: 'AI 딥러닝 기반 완벽한 품질 검수. 미세 결함도 놓치지 않는 초정밀 3D 스캐닝.',
|
|
||||||
accent: '#0e8a5f',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const BRAND = '(주)한빛로보틱스';
|
|
||||||
|
|
||||||
export function PublicMicrositePage() {
|
export function PublicMicrositePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { exhibitorId } = useParams<{ exhibitorId: string }>();
|
const { exhibitorId } = useParams<{ exhibitorId: string }>();
|
||||||
const [site, setSite] = useState<PublicMicrosite | null>(null);
|
const [site, setSite] = useState<PublicMicrosite | null>(null);
|
||||||
|
|
||||||
@ -51,28 +32,33 @@ export function PublicMicrositePage() {
|
|||||||
};
|
};
|
||||||
}, [exhibitorId]);
|
}, [exhibitorId]);
|
||||||
|
|
||||||
const brand = site?.brandName ?? BRAND;
|
const brand = site?.brandName ?? t('microsite.defaultBrand');
|
||||||
const tagline = site?.tagline ?? '미래를 움직이는 지능형 로보틱스 솔루션';
|
const tagline = site?.tagline ?? t('microsite.defaultTagline');
|
||||||
const boothLoc =
|
const boothLoc =
|
||||||
site?.boothNo || site?.hallLabel
|
site?.boothNo || site?.hallLabel
|
||||||
? `부스 ${site?.boothNo ?? '미정'}${site?.hallLabel ? ` · ${site.hallLabel}` : ''}`
|
? `${t('microsite.boothPrefix')} ${site?.boothNo ?? t('microsite.boothUndetermined')}${site?.hallLabel ? ` · ${site.hallLabel}` : ''}`
|
||||||
: '부스 A-102 · 홀7';
|
: t('microsite.defaultBoothLoc');
|
||||||
|
const defaultProducts = [
|
||||||
|
{ tag: t('microsite.product1Tag'), title: t('microsite.product1Title'), desc: t('microsite.product1Desc'), accent: PRODUCT_ACCENTS[0] },
|
||||||
|
{ tag: t('microsite.product2Tag'), title: t('microsite.product2Title'), desc: t('microsite.product2Desc'), accent: PRODUCT_ACCENTS[1] },
|
||||||
|
{ tag: t('microsite.product3Tag'), title: t('microsite.product3Title'), desc: t('microsite.product3Desc'), accent: PRODUCT_ACCENTS[2] },
|
||||||
|
];
|
||||||
const products =
|
const products =
|
||||||
site?.products && site.products.length > 0
|
site?.products && site.products.length > 0
|
||||||
? site.products.map((p, i) => ({
|
? site.products.map((p, i) => ({
|
||||||
tag: p.tag ?? '제품',
|
tag: p.tag ?? t('microsite.product'),
|
||||||
title: p.title ?? '제품',
|
title: p.title ?? t('microsite.product'),
|
||||||
desc: p.description ?? '',
|
desc: p.description ?? '',
|
||||||
accent: ['#0066b3', '#6d4aff', '#0e8a5f'][i % 3],
|
accent: PRODUCT_ACCENTS[i % 3],
|
||||||
}))
|
}))
|
||||||
: PRODUCTS;
|
: defaultProducts;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicShell active="exhibit" brand={brand} cta="문의하기" search={false}>
|
<PublicShell active="exhibit" brand={brand} cta={t('shell.footer.supportContact')} search={false}>
|
||||||
{/* 브랜드 히어로 */}
|
{/* 브랜드 히어로 */}
|
||||||
<section
|
<section
|
||||||
className="kxp-hero"
|
className="kxp-hero"
|
||||||
aria-label={`${brand} 소개`}
|
aria-label={t('microsite.introAria', { brand })}
|
||||||
style={{ backgroundColor: 'var(--color-canvas-bg)' }}
|
style={{ backgroundColor: 'var(--color-canvas-bg)' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -83,15 +69,15 @@ export function PublicMicrositePage() {
|
|||||||
<div className="kxp-hero__scrim" aria-hidden />
|
<div className="kxp-hero__scrim" aria-hidden />
|
||||||
<div className="kxp-hero__inner">
|
<div className="kxp-hero__inner">
|
||||||
<div className="kxp-hero__content">
|
<div className="kxp-hero__content">
|
||||||
<span className="kxp-hero__eyebrow">KINTEX Exhibitor</span>
|
<span className="kxp-hero__eyebrow">{t('microsite.eyebrow')}</span>
|
||||||
<h1 className="kxp-hero__title">{brand}</h1>
|
<h1 className="kxp-hero__title">{brand}</h1>
|
||||||
<p className="kxp-hero__lead">{tagline}</p>
|
<p className="kxp-hero__lead">{tagline}</p>
|
||||||
<div className="kxp-hero__actions">
|
<div className="kxp-hero__actions">
|
||||||
<a className="kxp-btn kxp-btn--primary kxp-btn--lg" href="#meeting">
|
<a className="kxp-btn kxp-btn--primary kxp-btn--lg" href="#meeting">
|
||||||
미팅 예약
|
{t('microsite.ctaMeeting')}
|
||||||
</a>
|
</a>
|
||||||
<a className="kxp-btn kxp-btn--ghost kxp-btn--lg" href="#products">
|
<a className="kxp-btn kxp-btn--ghost kxp-btn--lg" href="#products">
|
||||||
기업 소개서
|
{t('microsite.ctaBrochure')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -107,24 +93,24 @@ export function PublicMicrositePage() {
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)' }}>
|
<div style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)' }}>
|
||||||
KINTEX 전시장 부스 위치
|
{t('microsite.boothLocLabel')}
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-ms-booth__loc">{boothLoc}</div>
|
<div className="kxp-ms-booth__loc">{boothLoc}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a className="kxp-btn kxp-btn--outline" href="#top">
|
<a className="kxp-btn kxp-btn--outline" href="#top">
|
||||||
<IconMap width={18} height={18} /> 전시장 도면 보기
|
<IconMap width={18} height={18} /> {t('microsite.viewMap')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 제품 라인업 */}
|
{/* 제품 라인업 */}
|
||||||
<section className="kxp-section" id="products" aria-label="핵심 제품 라인업">
|
<section className="kxp-section" id="products" aria-label={t('microsite.productsAria')}>
|
||||||
<div className="kxp-wrap">
|
<div className="kxp-wrap">
|
||||||
<div className="kxp-section__head">
|
<div className="kxp-section__head">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="kxp-section__title">핵심 제품 라인업</h2>
|
<h2 className="kxp-section__title">{t('microsite.productsTitle')}</h2>
|
||||||
<p className="kxp-section__sub">현장에서 만나보실 대표 제품입니다.</p>
|
<p className="kxp-section__sub">{t('microsite.productsSub')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-products">
|
<div className="kxp-products">
|
||||||
@ -134,14 +120,14 @@ export function PublicMicrositePage() {
|
|||||||
className="kxp-product__media"
|
className="kxp-product__media"
|
||||||
style={{ background: `linear-gradient(135deg, ${p.accent} 0%, #101828 170%)` }}
|
style={{ background: `linear-gradient(135deg, ${p.accent} 0%, #101828 170%)` }}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`${p.title} 이미지`}
|
aria-label={t('microsite.productImgAria', { title: p.title })}
|
||||||
/>
|
/>
|
||||||
<div className="kxp-product__body">
|
<div className="kxp-product__body">
|
||||||
<span className="kxp-tag">{p.tag}</span>
|
<span className="kxp-tag">{p.tag}</span>
|
||||||
<h4>{p.title}</h4>
|
<h4>{p.title}</h4>
|
||||||
<p>{p.desc}</p>
|
<p>{p.desc}</p>
|
||||||
<a className="kxp-btn kxp-btn--outline kxp-btn--block" href="#top">
|
<a className="kxp-btn kxp-btn--outline kxp-btn--block" href="#top">
|
||||||
자세히 보기
|
{t('common.actions.viewMore')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@ -151,27 +137,25 @@ export function PublicMicrositePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* AI 부스 예상샷 갤러리 (워터마크 고지) */}
|
{/* AI 부스 예상샷 갤러리 (워터마크 고지) */}
|
||||||
<section className="kxp-section kxp-section--alt" aria-label="부스 미리보기">
|
<section className="kxp-section kxp-section--alt" aria-label={t('microsite.previewAria')}>
|
||||||
<div className="kxp-wrap">
|
<div className="kxp-wrap">
|
||||||
<div className="kxp-section__head" style={{ justifyContent: 'center', textAlign: 'center', flexDirection: 'column' }}>
|
<div className="kxp-section__head" style={{ justifyContent: 'center', textAlign: 'center', flexDirection: 'column' }}>
|
||||||
<h2 className="kxp-section__title">부스 미리보기</h2>
|
<h2 className="kxp-section__title">{t('microsite.previewTitle')}</h2>
|
||||||
<p className="kxp-section__sub">
|
<p className="kxp-section__sub">{t('microsite.previewSub')}</p>
|
||||||
AI가 생성한 예상 부스 이미지입니다. 실제 시공 결과와 다를 수 있습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-gallery">
|
<div className="kxp-gallery">
|
||||||
{[
|
{[
|
||||||
{ g: 'linear-gradient(135deg, #1f3a5f 0%, #0066b3 100%)', label: '메인 전시 존' },
|
{ g: 'linear-gradient(135deg, #1f3a5f 0%, #0066b3 100%)', label: t('microsite.galleryMain') },
|
||||||
{ g: 'linear-gradient(135deg, #2a2350 0%, #6d4aff 120%)', label: 'VIP 라운지' },
|
{ g: 'linear-gradient(135deg, #2a2350 0%, #6d4aff 120%)', label: t('microsite.galleryVip') },
|
||||||
].map((it) => (
|
].map((it) => (
|
||||||
<div
|
<div
|
||||||
key={it.label}
|
key={it.label}
|
||||||
className="kxp-gallery__item"
|
className="kxp-gallery__item"
|
||||||
style={{ background: it.g }}
|
style={{ background: it.g }}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`${it.label} AI 생성 예상 이미지`}
|
aria-label={t('microsite.galleryAria', { label: it.label })}
|
||||||
>
|
>
|
||||||
<span className="kxp-aiwm">AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음</span>
|
<span className="kxp-aiwm">{t('microsite.watermark')}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -179,10 +163,10 @@ export function PublicMicrositePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 미팅 예약 CTA */}
|
{/* 미팅 예약 CTA */}
|
||||||
<section className="kxp-cta-banner" id="meeting" aria-label="미팅 예약">
|
<section className="kxp-cta-banner" id="meeting" aria-label={t('microsite.meetingAria')}>
|
||||||
<h2>귀사의 비즈니스에 맞는 맞춤형 로봇 솔루션을 제안해 드립니다.</h2>
|
<h2>{t('microsite.ctaBanner')}</h2>
|
||||||
<a className="kxp-btn kxp-btn--outline kxp-btn--lg" href="#top">
|
<a className="kxp-btn kxp-btn--outline kxp-btn--lg" href="#top">
|
||||||
지금 미팅 예약하기 <IconArrowRight width={18} height={18} />
|
{t('microsite.ctaBannerBtn')} <IconArrowRight width={18} height={18} />
|
||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
||||||
</PublicShell>
|
</PublicShell>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
* 개인정보 동의(필수) 미체크 시 진행 차단. 완료 시 모바일 배지 QR(발급 배지코드).
|
* 개인정보 동의(필수) 미체크 시 진행 차단. 완료 시 모바일 배지 QR(발급 배지코드).
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { PublicShell } from './PublicShell';
|
import { PublicShell } from './PublicShell';
|
||||||
import { publicApi, type RegistrationReceipt } from './publicApi';
|
import { publicApi, type RegistrationReceipt } from './publicApi';
|
||||||
@ -20,23 +21,30 @@ import {
|
|||||||
IconBell,
|
IconBell,
|
||||||
} from './publicIcons';
|
} from './publicIcons';
|
||||||
|
|
||||||
const STEPS = ['유형', '정보', '관심분야', '완료'];
|
const STEP_KEYS = ['register.steps.type', 'register.steps.info', 'register.steps.interest', 'register.steps.done'];
|
||||||
|
|
||||||
const TYPES = [
|
const TYPES = [
|
||||||
{ key: 'general', ic: <IconPerson width={26} height={26} />, title: '일반 관람객', desc: '전시 및 공개 세션 참관이 가능한 일반 방문객' },
|
{ key: 'general', ic: <IconPerson width={26} height={26} />, titleKey: 'register.type.general', descKey: 'register.type.generalDesc' },
|
||||||
{ key: 'buyer', ic: <IconBuyer width={26} height={26} />, title: '바이어', desc: 'B2B 매칭 및 비즈니스 상담을 위한 기업 담당자' },
|
{ key: 'buyer', ic: <IconBuyer width={26} height={26} />, titleKey: 'register.type.buyer', descKey: 'register.type.buyerDesc' },
|
||||||
{ key: 'vip', ic: <IconVip width={26} height={26} />, title: 'VIP', desc: '초청 인사 전용 라운지 및 전 세션 입장 귀빈' },
|
{ key: 'vip', ic: <IconVip width={26} height={26} />, titleKey: 'register.type.vip', descKey: 'register.type.vipDesc' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const INTERESTS = ['AI 솔루션', '스마트 팩토리', '로보틱스', '빅데이터', '클라우드'];
|
const INTERESTS = [
|
||||||
|
'register.interest.ai',
|
||||||
|
'register.interest.factory',
|
||||||
|
'register.interest.robotics',
|
||||||
|
'register.interest.bigdata',
|
||||||
|
'register.interest.cloud',
|
||||||
|
];
|
||||||
|
|
||||||
const TYPE_LABEL: Record<string, string> = {
|
const TYPE_LABEL_KEY: Record<string, string> = {
|
||||||
general: '일반 관람객',
|
general: 'register.type.general',
|
||||||
buyer: '바이어',
|
buyer: 'register.type.buyer',
|
||||||
vip: 'VIP',
|
vip: 'register.type.vip',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PublicRegistrationPage() {
|
export function PublicRegistrationPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { eventId } = useParams<{ eventId: string }>();
|
const { eventId } = useParams<{ eventId: string }>();
|
||||||
const [step, setStep] = useState(0); // 0..3
|
const [step, setStep] = useState(0); // 0..3
|
||||||
const [type, setType] = useState('general');
|
const [type, setType] = useState('general');
|
||||||
@ -57,15 +65,15 @@ export function PublicRegistrationPage() {
|
|||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
if (!agreePrivacy) {
|
if (!agreePrivacy) {
|
||||||
setError('개인정보 수집·이용 동의(필수)에 체크해 주세요.');
|
setError(t('register.errPrivacy'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError('이름을 입력해 주세요.');
|
setError(t('register.errName'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!eventId) {
|
if (!eventId) {
|
||||||
setError('행사 정보를 확인할 수 없습니다.');
|
setError(t('register.errEvent'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@ -95,7 +103,7 @@ export function PublicRegistrationPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (step === 1 && !name.trim()) {
|
if (step === 1 && !name.trim()) {
|
||||||
setError('이름을 입력해 주세요.');
|
setError(t('register.errName'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStep((s) => Math.min(3, s + 1));
|
setStep((s) => Math.min(3, s + 1));
|
||||||
@ -105,24 +113,24 @@ export function PublicRegistrationPage() {
|
|||||||
setStep((s) => Math.max(0, s - 1));
|
setStep((s) => Math.max(0, s - 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const fillPct = (step / (STEPS.length - 1)) * 100;
|
const fillPct = (step / (STEP_KEYS.length - 1)) * 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicShell active="visit" cta="사전등록" search={false}>
|
<PublicShell active="visit" cta={t('shell.cta.register')} search={false}>
|
||||||
<div className="kxp-reg">
|
<div className="kxp-reg">
|
||||||
{/* 진행 인디케이터 */}
|
{/* 진행 인디케이터 */}
|
||||||
<div className="kxp-steps" role="list" aria-label="사전등록 단계">
|
<div className="kxp-steps" role="list" aria-label={t('register.stepsAria')}>
|
||||||
<div className="kxp-steps__track" aria-hidden />
|
<div className="kxp-steps__track" aria-hidden />
|
||||||
<div className="kxp-steps__fill" style={{ width: `calc(${fillPct}% - 48px * ${fillPct / 100})` }} aria-hidden />
|
<div className="kxp-steps__fill" style={{ width: `calc(${fillPct}% - 48px * ${fillPct / 100})` }} aria-hidden />
|
||||||
{STEPS.map((label, i) => (
|
{STEP_KEYS.map((labelKey, i) => (
|
||||||
<div
|
<div
|
||||||
key={label}
|
key={labelKey}
|
||||||
className={`kxp-step${i === step ? ' is-active' : ''}${i < step ? ' is-done' : ''}`}
|
className={`kxp-step${i === step ? ' is-active' : ''}${i < step ? ' is-done' : ''}`}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
aria-current={i === step ? 'step' : undefined}
|
aria-current={i === step ? 'step' : undefined}
|
||||||
>
|
>
|
||||||
<span className="kxp-step__dot">{i < step ? <IconCheckCircle width={20} height={20} /> : i + 1}</span>
|
<span className="kxp-step__dot">{i < step ? <IconCheckCircle width={20} height={20} /> : i + 1}</span>
|
||||||
<span className="kxp-step__label">{label}</span>
|
<span className="kxp-step__label">{t(labelKey)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -131,21 +139,21 @@ export function PublicRegistrationPage() {
|
|||||||
{/* Step 1: 유형 */}
|
{/* Step 1: 유형 */}
|
||||||
{step === 0 && (
|
{step === 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="kxp-regcard__title">참가 유형 선택</h2>
|
<h2 className="kxp-regcard__title">{t('register.step0Title')}</h2>
|
||||||
<p className="kxp-regcard__sub">KINTEX 전시회에 참여하실 자격을 선택해 주세요.</p>
|
<p className="kxp-regcard__sub">{t('register.step0Sub')}</p>
|
||||||
<div className="kxp-typegrid" role="radiogroup" aria-label="참가 유형">
|
<div className="kxp-typegrid" role="radiogroup" aria-label={t('register.typeGroupAria')}>
|
||||||
{TYPES.map((t) => (
|
{TYPES.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={type === t.key}
|
aria-checked={type === item.key}
|
||||||
className={`kxp-typecard${type === t.key ? ' is-active' : ''}`}
|
className={`kxp-typecard${type === item.key ? ' is-active' : ''}`}
|
||||||
onClick={() => setType(t.key)}
|
onClick={() => setType(item.key)}
|
||||||
>
|
>
|
||||||
<span className="kxp-typecard__ic">{t.ic}</span>
|
<span className="kxp-typecard__ic">{item.ic}</span>
|
||||||
<h3>{t.title}</h3>
|
<h3>{t(item.titleKey)}</h3>
|
||||||
<p>{t.desc}</p>
|
<p>{t(item.descKey)}</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -156,22 +164,22 @@ export function PublicRegistrationPage() {
|
|||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="kxp-regcard__title" style={{ fontSize: 'var(--fs-h2)' }}>
|
<h2 className="kxp-regcard__title" style={{ fontSize: 'var(--fs-h2)' }}>
|
||||||
기본 정보 입력
|
{t('register.step1Title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="kxp-regcard__sub">연락 및 출입증 발급을 위한 정보를 입력해 주세요.</p>
|
<p className="kxp-regcard__sub">{t('register.step1Sub')}</p>
|
||||||
<div className="kxp-formgrid kxp-formgrid--2">
|
<div className="kxp-formgrid kxp-formgrid--2">
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="reg-name">이름</label>
|
<label htmlFor="reg-name">{t('register.name')}</label>
|
||||||
<input
|
<input
|
||||||
id="reg-name"
|
id="reg-name"
|
||||||
className="kxp-input"
|
className="kxp-input"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="성함을 입력하세요"
|
placeholder={t('register.namePh')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="reg-tel">연락처 (Mobile)</label>
|
<label htmlFor="reg-tel">{t('register.phone')}</label>
|
||||||
<input
|
<input
|
||||||
id="reg-tel"
|
id="reg-tel"
|
||||||
className="kxp-input"
|
className="kxp-input"
|
||||||
@ -182,22 +190,22 @@ export function PublicRegistrationPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-field kxp-formgrid__full">
|
<div className="kxp-field kxp-formgrid__full">
|
||||||
<label htmlFor="reg-email">이메일</label>
|
<label htmlFor="reg-email">{t('register.email')}</label>
|
||||||
<input
|
<input
|
||||||
id="reg-email"
|
id="reg-email"
|
||||||
className="kxp-input"
|
className="kxp-input"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="example@domain.com"
|
placeholder={t('register.emailPh')}
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-field kxp-formgrid__full">
|
<div className="kxp-field kxp-formgrid__full">
|
||||||
<label htmlFor="reg-org">소속 (회사/기관)</label>
|
<label htmlFor="reg-org">{t('register.org')}</label>
|
||||||
<input
|
<input
|
||||||
id="reg-org"
|
id="reg-org"
|
||||||
className="kxp-input"
|
className="kxp-input"
|
||||||
placeholder="회사명 또는 학교명"
|
placeholder={t('register.orgPh')}
|
||||||
value={org}
|
value={org}
|
||||||
onChange={(e) => setOrg(e.target.value)}
|
onChange={(e) => setOrg(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -210,21 +218,24 @@ export function PublicRegistrationPage() {
|
|||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="kxp-regcard__title" style={{ fontSize: 'var(--fs-h2)' }}>
|
<h2 className="kxp-regcard__title" style={{ fontSize: 'var(--fs-h2)' }}>
|
||||||
관심 분야 선택
|
{t('register.step2Title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="kxp-regcard__sub">관심 분야를 선택하시면 맞춤형 정보를 제공해 드립니다.</p>
|
<p className="kxp-regcard__sub">{t('register.step2Sub')}</p>
|
||||||
<div className="kxp-interests" role="group" aria-label="관심 분야">
|
<div className="kxp-interests" role="group" aria-label={t('register.interestsAria')}>
|
||||||
{INTERESTS.map((v) => (
|
{INTERESTS.map((vKey) => {
|
||||||
|
const label = t(vKey);
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={vKey}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={interests.includes(v)}
|
aria-pressed={interests.includes(label)}
|
||||||
className={`kxp-interest${interests.includes(v) ? ' is-active' : ''}`}
|
className={`kxp-interest${interests.includes(label) ? ' is-active' : ''}`}
|
||||||
onClick={() => toggleInterest(v)}
|
onClick={() => toggleInterest(label)}
|
||||||
>
|
>
|
||||||
{v}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-consent">
|
<div className="kxp-consent">
|
||||||
<div className="kxp-consent__row">
|
<div className="kxp-consent__row">
|
||||||
@ -235,20 +246,20 @@ export function PublicRegistrationPage() {
|
|||||||
onChange={(e) => setAgreePrivacy(e.target.checked)}
|
onChange={(e) => setAgreePrivacy(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
개인정보 수집·이용 동의 <span className="kxp-check__req">(필수)</span>
|
{t('register.consentPrivacy')} <span className="kxp-check__req">{t('register.required')}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<a className="kxp-consent__more" href="#top">
|
<a className="kxp-consent__more" href="#top">
|
||||||
상세보기
|
{t('register.consentMore')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-consent__row">
|
<div className="kxp-consent__row">
|
||||||
<label className="kxp-check">
|
<label className="kxp-check">
|
||||||
<input type="checkbox" checked={agreeMkt} onChange={(e) => setAgreeMkt(e.target.checked)} />
|
<input type="checkbox" checked={agreeMkt} onChange={(e) => setAgreeMkt(e.target.checked)} />
|
||||||
<span>마케팅 수신 동의 (선택)</span>
|
<span>{t('register.consentMarketing')}</span>
|
||||||
</label>
|
</label>
|
||||||
<a className="kxp-consent__more" href="#top">
|
<a className="kxp-consent__more" href="#top">
|
||||||
상세보기
|
{t('register.consentMore')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -266,31 +277,31 @@ export function PublicRegistrationPage() {
|
|||||||
<div className="kxp-done__ic">
|
<div className="kxp-done__ic">
|
||||||
<IconCheckCircle width={40} height={40} />
|
<IconCheckCircle width={40} height={40} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="kxp-regcard__title">사전등록이 접수되었습니다</h2>
|
<h2 className="kxp-regcard__title">{t('register.doneTitle')}</h2>
|
||||||
<p className="kxp-regcard__sub">
|
<p className="kxp-regcard__sub">
|
||||||
{name || '관람객'}님의 사전등록이 완료되었습니다. 모바일 배지를 입구 키오스크에서 사용하세요.
|
{t('register.doneSub', { name: name || t('register.visitorDefault') })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="kxp-badge" aria-label="모바일 배지 미리보기">
|
<div className="kxp-badge" aria-label={t('register.badgeAria')}>
|
||||||
<div className="kxp-badge__top">
|
<div className="kxp-badge__top">
|
||||||
<span className="kxp-badge__pass">PASS</span>
|
<span className="kxp-badge__pass">PASS</span>
|
||||||
<span className="kxp-badge__role">{TYPE_LABEL[type]}</span>
|
<span className="kxp-badge__role">{t(TYPE_LABEL_KEY[type])}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-badge__name">{name || '홍길동'}</div>
|
<div className="kxp-badge__name">{name || t('register.badgeName')}</div>
|
||||||
<div className="kxp-badge__qr">
|
<div className="kxp-badge__qr">
|
||||||
<div className="kxp-badge__qrbox">
|
<div className="kxp-badge__qrbox">
|
||||||
<IconQr />
|
<IconQr />
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-badge__id">Badge Code</div>
|
<div className="kxp-badge__id">{t('register.badgeCodeLabel')}</div>
|
||||||
<div className="kxp-badge__idval">{receipt?.badgeCode ?? receipt?.registrationId ?? '—'}</div>
|
<div className="kxp-badge__idval">{receipt?.badgeCode ?? receipt?.registrationId ?? '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="kxp-donenote">
|
<div className="kxp-donenote">
|
||||||
<span style={{ display: 'inline-flex', gap: 8, alignItems: 'center' }}>
|
<span style={{ display: 'inline-flex', gap: 8, alignItems: 'center' }}>
|
||||||
<IconBell width={18} height={18} /> 입구 키오스크에서 QR을 인식해 실물 배지를 출력할 수 있습니다.
|
<IconBell width={18} height={18} /> {t('register.doneNote')}
|
||||||
</span>
|
</span>
|
||||||
{receipt?.registrationId && <span>등록번호: {receipt.registrationId}</span>}
|
{receipt?.registrationId && <span>{t('register.regNo', { id: receipt.registrationId })}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -300,7 +311,7 @@ export function PublicRegistrationPage() {
|
|||||||
<div className="kxp-regnav">
|
<div className="kxp-regnav">
|
||||||
{step > 0 && (
|
{step > 0 && (
|
||||||
<button type="button" className="kxp-btn kxp-btn--outline" onClick={prev}>
|
<button type="button" className="kxp-btn kxp-btn--outline" onClick={prev}>
|
||||||
이전
|
{t('common.actions.prev')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@ -309,25 +320,29 @@ export function PublicRegistrationPage() {
|
|||||||
onClick={next}
|
onClick={next}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
{step === 2 ? (submitting ? '등록 중…' : '등록하기') : '다음'}{' '}
|
{step === 2
|
||||||
|
? submitting
|
||||||
|
? t('register.submitting')
|
||||||
|
: t('register.submit')
|
||||||
|
: t('common.actions.next')}{' '}
|
||||||
<IconChevronRight width={18} height={18} />
|
<IconChevronRight width={18} height={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="kxp-regnav">
|
<div className="kxp-regnav">
|
||||||
<Link className="kxp-btn kxp-btn--outline" to="/public">
|
<Link className="kxp-btn kxp-btn--outline" to="/public">
|
||||||
홈으로 이동
|
{t('common.actions.home')}
|
||||||
</Link>
|
</Link>
|
||||||
{eventId ? (
|
{eventId ? (
|
||||||
<Link
|
<Link
|
||||||
className="kxp-btn kxp-btn--primary"
|
className="kxp-btn kxp-btn--primary"
|
||||||
to={`/public/events/${encodeURIComponent(eventId)}/floorplan`}
|
to={`/public/events/${encodeURIComponent(eventId)}/floorplan`}
|
||||||
>
|
>
|
||||||
전시장 도면 보기 <IconArrowRight width={18} height={18} />
|
{t('register.floorplan')} <IconArrowRight width={18} height={18} />
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link className="kxp-btn kxp-btn--primary" to="/public">
|
<Link className="kxp-btn kxp-btn--primary" to="/public">
|
||||||
행사 둘러보기 <IconArrowRight width={18} height={18} />
|
{t('register.eventBrowse')} <IconArrowRight width={18} height={18} />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,25 +1,25 @@
|
|||||||
/*
|
/*
|
||||||
* 공개 홍보 사이트 공통 셸 — 헤더 GNB + 푸터. (AppShell 미사용, 비로그인 공개 영역)
|
* 공개 홍보 사이트 공통 셸 — 헤더 GNB + 푸터. (AppShell 미사용, 비로그인 공개 영역)
|
||||||
* design.md §3B: 전통 페이지 내비 + SEO·다국어 토글(자리표시).
|
* design.md §3B: 전통 페이지 내비 + SEO·다국어 토글(react-i18next LanguageSwitcher).
|
||||||
*/
|
*/
|
||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { IconGlobe, IconMenu, IconClose, IconSearch, IconChevronDown } from './publicIcons';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { IconMenu, IconClose, IconSearch } from './publicIcons';
|
||||||
|
import { LanguageSwitcher } from '../../components/ui/LanguageSwitcher';
|
||||||
import './public.css';
|
import './public.css';
|
||||||
|
|
||||||
const GNB = [
|
const GNB = [
|
||||||
{ key: 'events', label: '행사' },
|
{ key: 'events', i18n: 'shell.nav.events' },
|
||||||
{ key: 'exhibit', label: '참가안내' },
|
{ key: 'exhibit', i18n: 'shell.nav.exhibit' },
|
||||||
{ key: 'visit', label: '관람안내' },
|
{ key: 'visit', i18n: 'shell.nav.visit' },
|
||||||
{ key: 'transport', label: '교통' },
|
{ key: 'transport', i18n: 'shell.nav.transport' },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const LANGS = ['한국어', 'English', '中文', '日本語'];
|
|
||||||
|
|
||||||
interface PublicShellProps {
|
interface PublicShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/** 현재 활성 GNB 키 (하이라이트) */
|
/** 현재 활성 GNB 키 (하이라이트) */
|
||||||
active?: string;
|
active?: string;
|
||||||
/** 헤더 우측 주요 CTA 라벨 (없으면 사전등록) */
|
/** 헤더 우측 주요 CTA 라벨 (없으면 사전등록 기본) */
|
||||||
cta?: string;
|
cta?: string;
|
||||||
/** 헤더에 검색 노출 여부 */
|
/** 헤더에 검색 노출 여부 */
|
||||||
search?: boolean;
|
search?: boolean;
|
||||||
@ -30,23 +30,23 @@ interface PublicShellProps {
|
|||||||
export function PublicShell({
|
export function PublicShell({
|
||||||
children,
|
children,
|
||||||
active,
|
active,
|
||||||
cta = '관람 사전등록',
|
cta,
|
||||||
search = true,
|
search = true,
|
||||||
brand = 'KINTEX',
|
brand = 'KINTEX',
|
||||||
}: PublicShellProps) {
|
}: PublicShellProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [langOpen, setLangOpen] = useState(false);
|
const ctaLabel = cta ?? t('shell.cta.register');
|
||||||
const [lang, setLang] = useState('한국어');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kxp">
|
<div className="kxp">
|
||||||
<header className="kxp-header">
|
<header className="kxp-header">
|
||||||
<div className="kxp-header__inner">
|
<div className="kxp-header__inner">
|
||||||
<div className="kxp-header__left">
|
<div className="kxp-header__left">
|
||||||
<a className="kxp-brand" href="#top" aria-label={`${brand} 홈`}>
|
<a className="kxp-brand" href="#top" aria-label={t('shell.homeAria', { brand })}>
|
||||||
{brand}
|
{brand}
|
||||||
</a>
|
</a>
|
||||||
<nav className="kxp-gnb" aria-label="주요 메뉴">
|
<nav className="kxp-gnb" aria-label={t('shell.primaryMenu')}>
|
||||||
{GNB.map((it) => (
|
{GNB.map((it) => (
|
||||||
<a
|
<a
|
||||||
key={it.key}
|
key={it.key}
|
||||||
@ -54,7 +54,7 @@ export function PublicShell({
|
|||||||
className={`kxp-gnb__link${active === it.key ? ' is-active' : ''}`}
|
className={`kxp-gnb__link${active === it.key ? ' is-active' : ''}`}
|
||||||
aria-current={active === it.key ? 'page' : undefined}
|
aria-current={active === it.key ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{it.label}
|
{t(it.i18n)}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@ -67,52 +67,22 @@ export function PublicShell({
|
|||||||
<input
|
<input
|
||||||
className="kxp-search__input"
|
className="kxp-search__input"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="행사 검색"
|
placeholder={t('shell.searchPlaceholder')}
|
||||||
aria-label="행사 검색"
|
aria-label={t('shell.searchPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="kxp-lang">
|
<LanguageSwitcher variant="light" className="kxp-lang__btn" />
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="kxp-lang__btn"
|
|
||||||
aria-haspopup="listbox"
|
|
||||||
aria-expanded={langOpen}
|
|
||||||
onClick={() => setLangOpen((v) => !v)}
|
|
||||||
>
|
|
||||||
<IconGlobe width={18} height={18} />
|
|
||||||
<span>{lang}</span>
|
|
||||||
<IconChevronDown width={16} height={16} />
|
|
||||||
</button>
|
|
||||||
{langOpen && (
|
|
||||||
<ul className="kxp-lang__menu" role="listbox" aria-label="언어 선택">
|
|
||||||
{LANGS.map((l) => (
|
|
||||||
<li key={l} role="option" aria-selected={l === lang}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="kxp-lang__opt"
|
|
||||||
onClick={() => {
|
|
||||||
setLang(l);
|
|
||||||
setLangOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{l}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a className="kxp-btn kxp-btn--primary kxp-header__cta" href="#top">
|
<a className="kxp-btn kxp-btn--primary kxp-header__cta" href="#top">
|
||||||
{cta}
|
{ctaLabel}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="kxp-header__burger"
|
className="kxp-header__burger"
|
||||||
aria-label={menuOpen ? '메뉴 닫기' : '메뉴 열기'}
|
aria-label={menuOpen ? t('shell.menuClose') : t('shell.menuOpen')}
|
||||||
aria-expanded={menuOpen}
|
aria-expanded={menuOpen}
|
||||||
onClick={() => setMenuOpen((v) => !v)}
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
>
|
>
|
||||||
@ -122,14 +92,14 @@ export function PublicShell({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<nav className="kxp-mobilenav" aria-label="모바일 메뉴">
|
<nav className="kxp-mobilenav" aria-label={t('shell.mobileMenu')}>
|
||||||
{GNB.map((it) => (
|
{GNB.map((it) => (
|
||||||
<a key={it.key} href="#top" className="kxp-mobilenav__link">
|
<a key={it.key} href="#top" className="kxp-mobilenav__link">
|
||||||
{it.label}
|
{t(it.i18n)}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
<a className="kxp-btn kxp-btn--primary kxp-mobilenav__cta" href="#top">
|
<a className="kxp-btn kxp-btn--primary kxp-mobilenav__cta" href="#top">
|
||||||
{cta}
|
{ctaLabel}
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
@ -143,35 +113,31 @@ export function PublicShell({
|
|||||||
<div className="kxp-footer__inner">
|
<div className="kxp-footer__inner">
|
||||||
<div className="kxp-footer__brand">
|
<div className="kxp-footer__brand">
|
||||||
<div className="kxp-footer__logo">KINTEX</div>
|
<div className="kxp-footer__logo">KINTEX</div>
|
||||||
<p className="kxp-footer__desc">
|
<p className="kxp-footer__desc">{t('shell.footer.brandDesc')}</p>
|
||||||
대한민국 최대 규모의 전시 컨벤션 센터. 비즈니스와 문화의 혁신을 잇습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-footer__cols">
|
<div className="kxp-footer__cols">
|
||||||
<div className="kxp-footer__col">
|
<div className="kxp-footer__col">
|
||||||
<h4>바로가기</h4>
|
<h4>{t('shell.footer.quickTitle')}</h4>
|
||||||
<a href="#top">행사 일정</a>
|
<a href="#top">{t('shell.footer.quickSchedule')}</a>
|
||||||
<a href="#top">참가 안내</a>
|
<a href="#top">{t('shell.footer.quickExhibit')}</a>
|
||||||
<a href="#top">오시는 길</a>
|
<a href="#top">{t('shell.footer.quickDirections')}</a>
|
||||||
<a href="#top">사이트맵</a>
|
<a href="#top">{t('shell.footer.quickSitemap')}</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-footer__col">
|
<div className="kxp-footer__col">
|
||||||
<h4>고객지원</h4>
|
<h4>{t('shell.footer.supportTitle')}</h4>
|
||||||
<a href="#top">개인정보처리방침</a>
|
<a href="#top">{t('shell.footer.supportPrivacy')}</a>
|
||||||
<a href="#top">이용약관</a>
|
<a href="#top">{t('shell.footer.supportTerms')}</a>
|
||||||
<a href="#top">문의하기</a>
|
<a href="#top">{t('shell.footer.supportContact')}</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-footer__col">
|
<div className="kxp-footer__col">
|
||||||
<h4>연락처</h4>
|
<h4>{t('shell.footer.contactTitle')}</h4>
|
||||||
<span>경기도 고양시 일산서구 킨텍스로 217-60</span>
|
<span>{t('shell.footer.contactAddress')}</span>
|
||||||
<span>대표번호 1899-1001</span>
|
<span>{t('shell.footer.contactTel')}</span>
|
||||||
<span>exhibition@kintex.com</span>
|
<span>{t('shell.footer.contactEmail')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-footer__bottom">
|
<div className="kxp-footer__bottom">{t('shell.footer.bottom')}</div>
|
||||||
© KINTEX. All Rights Reserved. (10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si
|
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
* 데이터: 티켓·재고·결제 API 부재 → 샘플 데이터.
|
* 데이터: 티켓·재고·결제 API 부재 → 샘플 데이터.
|
||||||
*/
|
*/
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PublicShell } from './PublicShell';
|
import { PublicShell } from './PublicShell';
|
||||||
import {
|
import {
|
||||||
IconTicket,
|
IconTicket,
|
||||||
@ -23,42 +24,43 @@ import {
|
|||||||
|
|
||||||
interface TicketType {
|
interface TicketType {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
nameKey: string;
|
||||||
desc: string;
|
descKey: string;
|
||||||
price: number;
|
price: number;
|
||||||
priceLabel: string;
|
priceKey: string;
|
||||||
badge?: { label: string; kind: 'ai' | 'off' };
|
badge?: { labelKey: string; kind: 'ai' | 'off' };
|
||||||
soldout?: boolean;
|
soldout?: boolean;
|
||||||
accent: string;
|
accent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TICKETS: TicketType[] = [
|
const TICKETS: TicketType[] = [
|
||||||
{ key: 'general', name: '일반권', desc: '일반 관람객 및 개인 참가자', price: 0, priceLabel: '₩0 (사전등록 할인가)', accent: '#0066b3' },
|
{ key: 'general', nameKey: 'ticket.type.general', descKey: 'ticket.type.generalDesc', price: 0, priceKey: 'ticket.type.generalPrice', accent: '#0066b3' },
|
||||||
{ key: 'buyer', name: '바이어권', desc: '유관 기업 종사자 및 바이어 전용', price: 0, priceLabel: '무료 · 자격심사 필요', badge: { label: 'AI OPTIMIZED', kind: 'ai' }, accent: '#6d4aff' },
|
{ key: 'buyer', nameKey: 'ticket.type.buyer', descKey: 'ticket.type.buyerDesc', price: 0, priceKey: 'ticket.type.buyerPrice', badge: { labelKey: 'ticket.aiOptimized', kind: 'ai' }, accent: '#6d4aff' },
|
||||||
{ key: 'group', name: '단체권 (10매 이상)', desc: '기업 단체 관람 전용', price: 9000, priceLabel: '₩9,000 / 1인', badge: { label: '10% OFF', kind: 'off' }, accent: '#0e8a5f' },
|
{ key: 'group', nameKey: 'ticket.type.group', descKey: 'ticket.type.groupDesc', price: 9000, priceKey: 'ticket.type.groupPrice', badge: { labelKey: 'ticket.off10', kind: 'off' }, accent: '#0e8a5f' },
|
||||||
{ key: 'vip', name: 'VIP권', desc: '전시회 올패스 + VIP 라운지 이용권', price: 50000, priceLabel: '₩50,000', soldout: true, accent: '#101828' },
|
{ key: 'vip', nameKey: 'ticket.type.vip', descKey: 'ticket.type.vipDesc', price: 50000, priceKey: 'ticket.type.vipPrice', soldout: true, accent: '#101828' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STEP_LABELS = ['행사·권종', '예매자', '결제', '완료'];
|
const STEP_KEYS = ['ticket.steps.s1', 'ticket.steps.s2', 'ticket.steps.s3', 'ticket.steps.s4'];
|
||||||
const won = (n: number) => `₩${n.toLocaleString('ko-KR')}`;
|
const won = (n: number) => `₩${n.toLocaleString('ko-KR')}`;
|
||||||
|
|
||||||
function EventBanner() {
|
function EventBanner() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="kxp-ticket__banner">
|
<div className="kxp-ticket__banner">
|
||||||
<div
|
<div
|
||||||
className="kxp-ticket__poster"
|
className="kxp-ticket__poster"
|
||||||
style={{ background: 'linear-gradient(135deg, #0066b3 0%, #6d4aff 130%)' }}
|
style={{ background: 'linear-gradient(135deg, #0066b3 0%, #6d4aff 130%)' }}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="행사 포스터"
|
aria-label={t('ticket.posterAria')}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h1>스마트팩토리 코리아 2026</h1>
|
<h1>{t('ticket.bannerTitle')}</h1>
|
||||||
<div className="kxp-ticket__facts">
|
<div className="kxp-ticket__facts">
|
||||||
<span>
|
<span>
|
||||||
<IconCalendar width={16} height={16} /> 2026.09.05 – 09.08
|
<IconCalendar width={16} height={16} /> {t('ticket.bannerDate')}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<IconPin width={16} height={16} /> 제2전시장 홀7
|
<IconPin width={16} height={16} /> {t('ticket.bannerPlace')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -67,6 +69,7 @@ function EventBanner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PurchaseView() {
|
function PurchaseView() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [qty, setQty] = useState<Record<string, number>>({});
|
const [qty, setQty] = useState<Record<string, number>>({});
|
||||||
const [pay, setPay] = useState('card');
|
const [pay, setPay] = useState('card');
|
||||||
@ -118,8 +121,8 @@ function PurchaseView() {
|
|||||||
<EventBanner />
|
<EventBanner />
|
||||||
|
|
||||||
{/* 스텝 인디케이터 */}
|
{/* 스텝 인디케이터 */}
|
||||||
<div className="kxp-ticket__steps" role="list" aria-label="예매 단계">
|
<div className="kxp-ticket__steps" role="list" aria-label={t('ticket.stepsAria')}>
|
||||||
{STEP_LABELS.map((l, i) => (
|
{STEP_KEYS.map((l, i) => (
|
||||||
<span
|
<span
|
||||||
key={l}
|
key={l}
|
||||||
className={`kxp-ticket__step${i === step ? ' is-active' : ''}`}
|
className={`kxp-ticket__step${i === step ? ' is-active' : ''}`}
|
||||||
@ -127,15 +130,15 @@ function PurchaseView() {
|
|||||||
aria-current={i === step ? 'step' : undefined}
|
aria-current={i === step ? 'step' : undefined}
|
||||||
>
|
>
|
||||||
{`0${i + 1} `}
|
{`0${i + 1} `}
|
||||||
{l}
|
{t(l)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 로그인 배너 */}
|
{/* 로그인 배너 */}
|
||||||
<div className="kxp-loginbanner">
|
<div className="kxp-loginbanner">
|
||||||
<IconInfo width={18} height={18} /> 로그인하면 예매 내역이 자동 저장됩니다.
|
<IconInfo width={18} height={18} /> {t('ticket.loginBanner')}
|
||||||
<a href="#top">로그인하기</a>
|
<a href="#top">{t('ticket.loginLink')}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="kxp-ticket__grid">
|
<div className="kxp-ticket__grid">
|
||||||
@ -144,47 +147,47 @@ function PurchaseView() {
|
|||||||
{step === 0 && (
|
{step === 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="kxp-ticket__h2">
|
<h2 className="kxp-ticket__h2">
|
||||||
<IconTicket width={20} height={20} /> 티켓 권종 선택
|
<IconTicket width={20} height={20} /> {t('ticket.h2Type')}
|
||||||
<span className="kxp-sample" style={{ marginLeft: 'auto' }}>
|
<span className="kxp-sample" style={{ marginLeft: 'auto' }}>
|
||||||
샘플 데이터
|
{t('ticket.sample')}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="kxp-tickrows">
|
<div className="kxp-tickrows">
|
||||||
{TICKETS.map((t) => (
|
{TICKETS.map((tk) => (
|
||||||
<div
|
<div
|
||||||
key={t.key}
|
key={tk.key}
|
||||||
className={`kxp-tickrow${t.soldout ? ' kxp-tickrow--soldout' : ''}`}
|
className={`kxp-tickrow${tk.soldout ? ' kxp-tickrow--soldout' : ''}`}
|
||||||
style={{ borderLeftColor: t.soldout ? undefined : t.accent }}
|
style={{ borderLeftColor: tk.soldout ? undefined : tk.accent }}
|
||||||
>
|
>
|
||||||
<div className="kxp-tickrow__info">
|
<div className="kxp-tickrow__info">
|
||||||
<h3>
|
<h3>
|
||||||
{t.name}
|
{t(tk.nameKey)}
|
||||||
{t.badge && (
|
{tk.badge && (
|
||||||
<span className={t.badge.kind === 'ai' ? 'kxp-chip kxp-chip--ai' : 'kxp-chip'}>
|
<span className={tk.badge.kind === 'ai' ? 'kxp-chip kxp-chip--ai' : 'kxp-chip'}>
|
||||||
{t.badge.kind === 'ai' && <IconSparkles width={12} height={12} />}
|
{tk.badge.kind === 'ai' && <IconSparkles width={12} height={12} />}
|
||||||
{t.badge.label}
|
{t(tk.badge.labelKey)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{t.soldout && <span className="kxp-status kxp-status--soldout">매진</span>}
|
{tk.soldout && <span className="kxp-status kxp-status--soldout">{t('ticket.soldout')}</span>}
|
||||||
</h3>
|
</h3>
|
||||||
<p>{t.desc}</p>
|
<p>{t(tk.descKey)}</p>
|
||||||
<div className="kxp-tickrow__price">{t.priceLabel}</div>
|
<div className="kxp-tickrow__price">{t(tk.priceKey)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-stepper" aria-label={`${t.name} 수량`}>
|
<div className="kxp-stepper" aria-label={t('ticket.qtyAria', { name: t(tk.nameKey) })}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="감소"
|
aria-label={t('ticket.decrease')}
|
||||||
disabled={t.soldout}
|
disabled={tk.soldout}
|
||||||
onClick={() => setCount(t.key, -1)}
|
onClick={() => setCount(tk.key, -1)}
|
||||||
>
|
>
|
||||||
<IconMinus width={16} height={16} />
|
<IconMinus width={16} height={16} />
|
||||||
</button>
|
</button>
|
||||||
<span>{qty[t.key] ?? 0}</span>
|
<span>{qty[tk.key] ?? 0}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="증가"
|
aria-label={t('ticket.increase')}
|
||||||
disabled={t.soldout}
|
disabled={tk.soldout}
|
||||||
onClick={() => setCount(t.key, 1)}
|
onClick={() => setCount(tk.key, 1)}
|
||||||
>
|
>
|
||||||
<IconPlus width={16} height={16} />
|
<IconPlus width={16} height={16} />
|
||||||
</button>
|
</button>
|
||||||
@ -199,20 +202,20 @@ function PurchaseView() {
|
|||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="kxp-ticket__h2">
|
<h2 className="kxp-ticket__h2">
|
||||||
<IconPerson width={20} height={20} /> 예매자 정보
|
<IconPerson width={20} height={20} /> {t('ticket.h2Booker')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="kxp-formcard">
|
<div className="kxp-formcard">
|
||||||
<div className="kxp-formgrid kxp-formgrid--2">
|
<div className="kxp-formgrid kxp-formgrid--2">
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="tk-name">이름</label>
|
<label htmlFor="tk-name">{t('ticket.bookerName')}</label>
|
||||||
<input id="tk-name" className="kxp-input" placeholder="성함" />
|
<input id="tk-name" className="kxp-input" placeholder={t('ticket.bookerNamePh')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="tk-tel">연락처</label>
|
<label htmlFor="tk-tel">{t('ticket.bookerTel')}</label>
|
||||||
<input id="tk-tel" className="kxp-input" type="tel" placeholder="010-0000-0000" />
|
<input id="tk-tel" className="kxp-input" type="tel" placeholder="010-0000-0000" />
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-field kxp-formgrid__full">
|
<div className="kxp-field kxp-formgrid__full">
|
||||||
<label htmlFor="tk-email">이메일 (티켓 발송)</label>
|
<label htmlFor="tk-email">{t('ticket.bookerEmail')}</label>
|
||||||
<input id="tk-email" className="kxp-input" type="email" placeholder="example@domain.com" />
|
<input id="tk-email" className="kxp-input" type="email" placeholder="example@domain.com" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -220,12 +223,12 @@ function PurchaseView() {
|
|||||||
<label className="kxp-check">
|
<label className="kxp-check">
|
||||||
<input type="checkbox" />
|
<input type="checkbox" />
|
||||||
<span>
|
<span>
|
||||||
개인정보 수집·이용 동의 <span className="kxp-check__req">(필수)</span>
|
{t('ticket.consentPrivacy')} <span className="kxp-check__req">{t('ticket.required')}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="kxp-check">
|
<label className="kxp-check">
|
||||||
<input type="checkbox" />
|
<input type="checkbox" />
|
||||||
<span>마케팅 수신 동의 (선택)</span>
|
<span>{t('ticket.consentMarketing')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -236,13 +239,13 @@ function PurchaseView() {
|
|||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="kxp-ticket__h2">
|
<h2 className="kxp-ticket__h2">
|
||||||
<IconShield width={20} height={20} /> 결제 수단
|
<IconShield width={20} height={20} /> {t('ticket.h2Pay')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="kxp-paychips" role="group" aria-label="결제 수단">
|
<div className="kxp-paychips" role="group" aria-label={t('ticket.payGroupAria')}>
|
||||||
{[
|
{[
|
||||||
['card', '신용카드'],
|
['card', t('ticket.payCard')],
|
||||||
['easy', '간편결제'],
|
['easy', t('ticket.payEasy')],
|
||||||
['bank', '계좌이체'],
|
['bank', t('ticket.payBank')],
|
||||||
].map(([k, label]) => (
|
].map(([k, label]) => (
|
||||||
<button
|
<button
|
||||||
key={k}
|
key={k}
|
||||||
@ -257,10 +260,7 @@ function PurchaseView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="kxp-pgnote">
|
<div className="kxp-pgnote">
|
||||||
<IconShield width={18} height={18} />
|
<IconShield width={18} height={18} />
|
||||||
<span>
|
<span>{t('ticket.pgNote')}</span>
|
||||||
결제는 PG사 보안 페이지에서 진행되며, 카드정보는 본 사이트에 저장되지 않습니다. 모든
|
|
||||||
거래는 256비트 SSL 암호화로 보호됩니다. (PG 미연동 — 데모 플로우)
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@ -271,21 +271,21 @@ function PurchaseView() {
|
|||||||
<div className="kxp-done__ic" style={{ margin: '0 auto 16px' }}>
|
<div className="kxp-done__ic" style={{ margin: '0 auto 16px' }}>
|
||||||
<IconCheckCircle width={40} height={40} />
|
<IconCheckCircle width={40} height={40} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="kxp-regcard__title">예매가 접수되었습니다</h2>
|
<h2 className="kxp-regcard__title">{t('ticket.doneTitle')}</h2>
|
||||||
<div className="kxp-tikdone__no">KTX-2026-0000-DEMO</div>
|
<div className="kxp-tikdone__no">{t('ticket.doneNo')}</div>
|
||||||
<p className="kxp-regcard__sub">
|
<p className="kxp-regcard__sub">
|
||||||
이메일로 티켓을 보내드립니다. (실서비스 연동 시) 총 {count}매 · 결제 {won(total)}
|
{t('ticket.doneSub', { count, total: won(total) })}
|
||||||
</p>
|
</p>
|
||||||
<div className="kxp-tikdone__qr">
|
<div className="kxp-tikdone__qr">
|
||||||
<IconQr width={110} height={110} />
|
<IconQr width={110} height={110} />
|
||||||
</div>
|
</div>
|
||||||
<span className="kxp-sample">샘플 데이터 · 결제 준비 중</span>
|
<span className="kxp-sample">{t('ticket.sampleReady')}</span>
|
||||||
<div className="kxp-tikdone__actions">
|
<div className="kxp-tikdone__actions">
|
||||||
<a className="kxp-btn kxp-btn--outline" href="#top">
|
<a className="kxp-btn kxp-btn--outline" href="#top">
|
||||||
모바일 티켓 지갑 열기
|
{t('ticket.walletBtn')}
|
||||||
</a>
|
</a>
|
||||||
<a className="kxp-btn kxp-btn--primary" href="#top">
|
<a className="kxp-btn kxp-btn--primary" href="#top">
|
||||||
예매 확인 / 취소 <IconArrowRight width={18} height={18} />
|
{t('ticket.manageBtn')} <IconArrowRight width={18} height={18} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -294,22 +294,22 @@ function PurchaseView() {
|
|||||||
|
|
||||||
{/* 우측 요약 */}
|
{/* 우측 요약 */}
|
||||||
{step < 3 && (
|
{step < 3 && (
|
||||||
<aside className="kxp-ticket__summary" aria-label="예매 내역 요약">
|
<aside className="kxp-ticket__summary" aria-label={t('ticket.summaryAria')}>
|
||||||
<h3>예매 내역 요약</h3>
|
<h3>{t('ticket.summaryTitle')}</h3>
|
||||||
<div className="kxp-sumrow">
|
<div className="kxp-sumrow">
|
||||||
<span>선택 매수</span>
|
<span>{t('ticket.summaryCount')}</span>
|
||||||
<span>{count}매</span>
|
<span>{t('ticket.count', { n: count })}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-sumrow">
|
<div className="kxp-sumrow">
|
||||||
<span>소계</span>
|
<span>{t('ticket.subtotal')}</span>
|
||||||
<span>{won(subtotal)}</span>
|
<span>{won(subtotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-sumrow kxp-sumrow--discount">
|
<div className="kxp-sumrow kxp-sumrow--discount">
|
||||||
<span>할인</span>
|
<span>{t('ticket.discount')}</span>
|
||||||
<span>-{won(discount)}</span>
|
<span>-{won(discount)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-sumtotal">
|
<div className="kxp-sumtotal">
|
||||||
<small>최종 결제 금액</small>
|
<small>{t('ticket.finalTotal')}</small>
|
||||||
<strong>{won(total)}</strong>
|
<strong>{won(total)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -318,7 +318,13 @@ function PurchaseView() {
|
|||||||
disabled={count === 0}
|
disabled={count === 0}
|
||||||
onClick={next}
|
onClick={next}
|
||||||
>
|
>
|
||||||
{step === 0 ? '다음 단계로 이동' : step === 1 ? (isFree ? '무료 예매 완료' : '결제하기') : '결제 진행'}
|
{step === 0
|
||||||
|
? t('ticket.nextStep')
|
||||||
|
: step === 1
|
||||||
|
? isFree
|
||||||
|
? t('ticket.freeDone')
|
||||||
|
: t('ticket.pay')
|
||||||
|
: t('ticket.payProceed')}
|
||||||
</button>
|
</button>
|
||||||
{step > 0 && (
|
{step > 0 && (
|
||||||
<button
|
<button
|
||||||
@ -327,11 +333,11 @@ function PurchaseView() {
|
|||||||
style={{ marginTop: 10 }}
|
style={{ marginTop: 10 }}
|
||||||
onClick={prev}
|
onClick={prev}
|
||||||
>
|
>
|
||||||
이전
|
{t('common.actions.prev')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<p style={{ marginTop: 14, fontSize: 11, textAlign: 'center', color: 'var(--color-neutral-500)' }}>
|
<p style={{ marginTop: 14, fontSize: 11, textAlign: 'center', color: 'var(--color-neutral-500)' }}>
|
||||||
고객센터 1588-0000 (평일 09–18시)
|
{t('ticket.customerCenter')}
|
||||||
</p>
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
@ -341,15 +347,16 @@ function PurchaseView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ManageView() {
|
function ManageView() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [looked, setLooked] = useState(false);
|
const [looked, setLooked] = useState(false);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header style={{ textAlign: 'center', marginBottom: 24 }}>
|
<header style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||||
<h1 className="kxp-regcard__title">예매 확인 · 취소</h1>
|
<h1 className="kxp-regcard__title">{t('ticket.manageTitle')}</h1>
|
||||||
<p className="kxp-regcard__sub" style={{ marginBottom: 0 }}>
|
<p className="kxp-regcard__sub" style={{ marginBottom: 0 }}>
|
||||||
비회원 예매 내역을 조회하고 취소하실 수 있습니다.
|
{t('ticket.manageSub')}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -357,26 +364,26 @@ function ManageView() {
|
|||||||
<div className="kxp-manage__lookup">
|
<div className="kxp-manage__lookup">
|
||||||
<div className="kxp-manage__lookrow">
|
<div className="kxp-manage__lookrow">
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="mg-no">예매번호</label>
|
<label htmlFor="mg-no">{t('ticket.lookNo')}</label>
|
||||||
<input id="mg-no" className="kxp-input" placeholder="KTX-YYYY-XXXXXX" />
|
<input id="mg-no" className="kxp-input" placeholder={t('ticket.lookNoPh')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="mg-email">이메일</label>
|
<label htmlFor="mg-email">{t('ticket.lookEmail')}</label>
|
||||||
<input id="mg-email" className="kxp-input" type="email" placeholder="example@email.com" />
|
<input id="mg-email" className="kxp-input" type="email" placeholder={t('ticket.lookEmailPh')} />
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="kxp-btn kxp-btn--primary" onClick={() => setLooked(true)}>
|
<button type="button" className="kxp-btn kxp-btn--primary" onClick={() => setLooked(true)}>
|
||||||
조회하기
|
{t('ticket.lookBtn')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'center', marginTop: 14 }}>
|
<div style={{ textAlign: 'center', marginTop: 14 }}>
|
||||||
<a href="#top" style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)', textDecoration: 'underline' }}>
|
<a href="#top" style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)', textDecoration: 'underline' }}>
|
||||||
로그인하여 내 예매 보기
|
{t('ticket.loginToView')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!looked ? (
|
{!looked ? (
|
||||||
<p className="kxp-empty">예매번호와 이메일을 입력해 조회하세요. (샘플: 아무 값이나 입력 후 조회)</p>
|
<p className="kxp-empty">{t('ticket.notLooked')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="kxp-manage__grid">
|
<div className="kxp-manage__grid">
|
||||||
{/* 예매 상세 */}
|
{/* 예매 상세 */}
|
||||||
@ -384,14 +391,14 @@ function ManageView() {
|
|||||||
<article className="kxp-bookcard">
|
<article className="kxp-bookcard">
|
||||||
<div className="kxp-bookcard__head">
|
<div className="kxp-bookcard__head">
|
||||||
<div>
|
<div>
|
||||||
<span className="kxp-status kxp-status--done">예매완료</span>
|
<span className="kxp-status kxp-status--done">{t('ticket.booked')}</span>
|
||||||
<h2>스마트팩토리 코리아 2026</h2>
|
<h2>{t('ticket.bannerTitle')}</h2>
|
||||||
<span className="kxp-bookcard__no">
|
<span className="kxp-bookcard__no">
|
||||||
예매번호 <b>KTX-2026-018245</b>
|
{t('ticket.bookNoLabel')} <b>KTX-2026-018245</b>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-bookcard__paid">
|
<div className="kxp-bookcard__paid">
|
||||||
<small>결제일시</small>
|
<small>{t('ticket.paidAt')}</small>
|
||||||
<br />
|
<br />
|
||||||
<b>2025-10-24 14:32</b>
|
<b>2025-10-24 14:32</b>
|
||||||
</div>
|
</div>
|
||||||
@ -400,9 +407,9 @@ function ManageView() {
|
|||||||
<table className="kxp-ticktable">
|
<table className="kxp-ticktable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>권종</th>
|
<th>{t('ticket.colType')}</th>
|
||||||
<th>수량</th>
|
<th>{t('ticket.colQty')}</th>
|
||||||
<th>금액</th>
|
<th>{t('ticket.colAmount')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -437,13 +444,13 @@ function ManageView() {
|
|||||||
<div className="kxp-booker__id">
|
<div className="kxp-booker__id">
|
||||||
<IconPerson width={20} height={20} />
|
<IconPerson width={20} height={20} />
|
||||||
<div>
|
<div>
|
||||||
<small>예매자 정보</small>
|
<small>{t('ticket.bookerInfo')}</small>
|
||||||
<br />
|
<br />
|
||||||
<b>jo***@ex***.com · 010-****-1234</b>
|
<b>jo***@ex***.com · 010-****-1234</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<small style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)' }}>총 결제 금액</small>
|
<small style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)' }}>{t('ticket.totalPaid')}</small>
|
||||||
<div style={{ fontSize: 'var(--fs-h2)', fontWeight: 800, color: 'var(--color-primary-700)' }}>
|
<div style={{ fontSize: 'var(--fs-h2)', fontWeight: 800, color: 'var(--color-primary-700)' }}>
|
||||||
₩ 40,000
|
₩ 40,000
|
||||||
</div>
|
</div>
|
||||||
@ -456,24 +463,24 @@ function ManageView() {
|
|||||||
<aside>
|
<aside>
|
||||||
<div className="kxp-refund">
|
<div className="kxp-refund">
|
||||||
<h3>
|
<h3>
|
||||||
<IconInfo width={18} height={18} /> 취소·환불 정책
|
<IconInfo width={18} height={18} /> {t('ticket.refundTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="kxp-refund__table">
|
<div className="kxp-refund__table">
|
||||||
<div className="kxp-refund__row kxp-refund__row--full">
|
<div className="kxp-refund__row kxp-refund__row--full">
|
||||||
<span>방문 7일 전 (D-7)</span>
|
<span>{t('ticket.refundD7')}</span>
|
||||||
<b>100% 환불</b>
|
<b>{t('ticket.refundD7Val')}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-refund__row kxp-refund__row--half">
|
<div className="kxp-refund__row kxp-refund__row--half">
|
||||||
<span>방문 3일 전 (D-3)</span>
|
<span>{t('ticket.refundD3')}</span>
|
||||||
<b>50% 환불</b>
|
<b>{t('ticket.refundD3Val')}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-refund__row kxp-refund__row--none">
|
<div className="kxp-refund__row kxp-refund__row--none">
|
||||||
<span>방문 당일</span>
|
<span>{t('ticket.refundDay')}</span>
|
||||||
<b>0% (환불불가)</b>
|
<b>{t('ticket.refundDayVal')}</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-refund__est">
|
<div className="kxp-refund__est">
|
||||||
<span>현재 예상 환불액</span>
|
<span>{t('ticket.refundEst')}</span>
|
||||||
<strong>₩ 40,000</strong>
|
<strong>₩ 40,000</strong>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -481,19 +488,16 @@ function ManageView() {
|
|||||||
className="kxp-btn kxp-btn--danger kxp-btn--block"
|
className="kxp-btn kxp-btn--danger kxp-btn--block"
|
||||||
onClick={() => setConfirmOpen(true)}
|
onClick={() => setConfirmOpen(true)}
|
||||||
>
|
>
|
||||||
예매 취소
|
{t('ticket.cancelBooking')}
|
||||||
</button>
|
</button>
|
||||||
<p className="kxp-refund__note">환불은 PG사를 통해 원결제수단으로 처리됩니다.</p>
|
<p className="kxp-refund__note">{t('ticket.refundNote')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="kxp-ai-helper">
|
<div className="kxp-ai-helper">
|
||||||
<IconSparkles width={18} height={18} />
|
<IconSparkles width={18} height={18} />
|
||||||
<div>
|
<div>
|
||||||
<strong>AI 정산 도우미</strong>
|
<strong>{t('ticket.aiHelper')}</strong>
|
||||||
<p>
|
<p>{t('ticket.aiHelperDesc')}</p>
|
||||||
신용카드 할부 결제 건은 취소 시 카드사를 통해 한도 복구까지 약 3–5영업일이 소요될 수
|
|
||||||
있습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -501,20 +505,17 @@ function ManageView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{confirmOpen && (
|
{confirmOpen && (
|
||||||
<div className="kxp-modal" role="dialog" aria-modal="true" aria-label="예매 취소 확인">
|
<div className="kxp-modal" role="dialog" aria-modal="true" aria-label={t('ticket.cancelModalAria')}>
|
||||||
<div className="kxp-modal__scrim" onClick={() => setConfirmOpen(false)} aria-hidden />
|
<div className="kxp-modal__scrim" onClick={() => setConfirmOpen(false)} aria-hidden />
|
||||||
<div className="kxp-modal__box">
|
<div className="kxp-modal__box">
|
||||||
<h3>예매를 취소하시겠습니까?</h3>
|
<h3>{t('ticket.cancelModalTitle')}</h3>
|
||||||
<p>
|
<p>{t('ticket.cancelModalDesc')}</p>
|
||||||
D-15 기준 100% 환불(₩40,000)이 적용됩니다. 환불은 원결제수단으로 PG사를 통해 처리되며,
|
|
||||||
실서비스 연동 시 실제 환불이 진행됩니다.
|
|
||||||
</p>
|
|
||||||
<div className="kxp-modal__actions">
|
<div className="kxp-modal__actions">
|
||||||
<button type="button" className="kxp-btn kxp-btn--outline" onClick={() => setConfirmOpen(false)}>
|
<button type="button" className="kxp-btn kxp-btn--outline" onClick={() => setConfirmOpen(false)}>
|
||||||
닫기
|
{t('common.actions.close')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="kxp-btn kxp-btn--danger" onClick={() => setConfirmOpen(false)}>
|
<button type="button" className="kxp-btn kxp-btn--danger" onClick={() => setConfirmOpen(false)}>
|
||||||
취소하기
|
{t('ticket.cancelConfirm')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -525,13 +526,14 @@ function ManageView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PublicTicketPage() {
|
export function PublicTicketPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [view, setView] = useState<'purchase' | 'manage'>('purchase');
|
const [view, setView] = useState<'purchase' | 'manage'>('purchase');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicShell active="visit" cta="입장권 예매" search={false}>
|
<PublicShell active="visit" cta={t('ticket.viewPurchase')} search={false}>
|
||||||
<div className={view === 'purchase' ? 'kxp-ticket' : 'kxp-manage'}>
|
<div className={view === 'purchase' ? 'kxp-ticket' : 'kxp-manage'}>
|
||||||
{/* 뷰 전환 */}
|
{/* 뷰 전환 */}
|
||||||
<div className="kxp-daytabs" style={{ justifyContent: 'center', marginBottom: 24 }} role="tablist" aria-label="티켓 화면 전환">
|
<div className="kxp-daytabs" style={{ justifyContent: 'center', marginBottom: 24 }} role="tablist" aria-label={t('ticket.viewSwitchAria')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
@ -539,7 +541,7 @@ export function PublicTicketPage() {
|
|||||||
className={`kxp-daytab${view === 'purchase' ? ' is-active' : ''}`}
|
className={`kxp-daytab${view === 'purchase' ? ' is-active' : ''}`}
|
||||||
onClick={() => setView('purchase')}
|
onClick={() => setView('purchase')}
|
||||||
>
|
>
|
||||||
입장권 예매
|
{t('ticket.viewPurchase')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -548,7 +550,7 @@ export function PublicTicketPage() {
|
|||||||
className={`kxp-daytab${view === 'manage' ? ' is-active' : ''}`}
|
className={`kxp-daytab${view === 'manage' ? ' is-active' : ''}`}
|
||||||
onClick={() => setView('manage')}
|
onClick={() => setView('manage')}
|
||||||
>
|
>
|
||||||
예매 확인 · 취소
|
{t('ticket.viewManage')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -122,18 +122,18 @@
|
|||||||
}
|
}
|
||||||
.kx-delta--up {
|
.kx-delta--up {
|
||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
background: #e6f4ee;
|
background: var(--color-success-bg);
|
||||||
}
|
}
|
||||||
.kx-delta--down {
|
.kx-delta--down {
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
background: #fef3f2;
|
background: var(--color-error-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* degraded 배지 */
|
/* degraded 배지 */
|
||||||
.kx-bi__degraded {
|
.kx-bi__degraded {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--color-warning);
|
color: var(--color-warning);
|
||||||
background: #fff4e5;
|
background: var(--color-warning-bg);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -192,7 +192,7 @@
|
|||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
}
|
}
|
||||||
.kx-grade--excellent {
|
.kx-grade--excellent {
|
||||||
background: #e6f4ee;
|
background: var(--color-success-bg);
|
||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
.kx-grade--good {
|
.kx-grade--good {
|
||||||
@ -200,10 +200,10 @@
|
|||||||
color: var(--color-primary-700);
|
color: var(--color-primary-700);
|
||||||
}
|
}
|
||||||
.kx-grade--fair {
|
.kx-grade--fair {
|
||||||
background: #fff4e5;
|
background: var(--color-warning-bg);
|
||||||
color: var(--color-warning);
|
color: var(--color-warning);
|
||||||
}
|
}
|
||||||
.kx-grade--review {
|
.kx-grade--review {
|
||||||
background: #fef3f2;
|
background: var(--color-error-bg);
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|||||||
78
src/frontend/src/store/themeStore.ts
Normal file
78
src/frontend/src/store/themeStore.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* 테마 상태 (zustand) — 라이트/다크/시스템 선호 + localStorage 지속.
|
||||||
|
* document.documentElement[data-theme] 를 항상 스탬프해 셸 밖(로그인 등)에도 적용된다.
|
||||||
|
* 값 정의는 CSS(global.css :root[data-theme='dark'] + @media prefers-color-scheme).
|
||||||
|
* 라이트가 기본 — 저장값이 없으면 'system'(OS 선호 추종, 대부분 라이트).
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||||
|
type ResolvedTheme = 'light' | 'dark';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'kx-theme';
|
||||||
|
const DARK_QUERY = '(prefers-color-scheme: dark)';
|
||||||
|
|
||||||
|
function readStored(): ThemeMode {
|
||||||
|
if (typeof localStorage === 'undefined') return 'system';
|
||||||
|
const v = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return v === 'light' || v === 'dark' || v === 'system' ? v : 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemPrefersDark(): boolean {
|
||||||
|
return (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof window.matchMedia === 'function' &&
|
||||||
|
window.matchMedia(DARK_QUERY).matches
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 모드 → 실제 적용 테마(system 은 OS 선호로 해소). */
|
||||||
|
export function resolveTheme(mode: ThemeMode): ResolvedTheme {
|
||||||
|
return mode === 'system' ? (systemPrefersDark() ? 'dark' : 'light') : mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** documentElement 에 테마를 스탬프(셸 밖 화면 포함 전역 적용). */
|
||||||
|
function stamp(mode: ThemeMode): ResolvedTheme {
|
||||||
|
const resolved = resolveTheme(mode);
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.dataset.theme = resolved;
|
||||||
|
document.documentElement.style.colorScheme = resolved;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeState {
|
||||||
|
mode: ThemeMode;
|
||||||
|
resolved: ResolvedTheme;
|
||||||
|
/** 명시 모드 설정(light|dark|system) — 지속 + 스탬프. */
|
||||||
|
setMode: (mode: ThemeMode) => void;
|
||||||
|
/** 현재 표시 테마의 반대편으로 명시 고정(라이트↔다크). */
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useThemeStore = create<ThemeState>((set, get) => ({
|
||||||
|
mode: readStored(),
|
||||||
|
resolved: resolveTheme(readStored()),
|
||||||
|
|
||||||
|
setMode: (mode) => {
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, mode);
|
||||||
|
set({ mode, resolved: stamp(mode) });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle: () => {
|
||||||
|
const next: ThemeMode = get().resolved === 'dark' ? 'light' : 'dark';
|
||||||
|
get().setMode(next);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── 앱 진입 즉시 스탬프(모듈 import 시점 — main.tsx 가 렌더 전에 로드) ──
|
||||||
|
stamp(useThemeStore.getState().mode);
|
||||||
|
|
||||||
|
// ── 시스템 선호 변경 반영(system 모드일 때만 재해소) ──
|
||||||
|
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
||||||
|
window.matchMedia(DARK_QUERY).addEventListener('change', () => {
|
||||||
|
if (useThemeStore.getState().mode === 'system') {
|
||||||
|
useThemeStore.setState({ resolved: stamp('system') });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,5 +1,122 @@
|
|||||||
@import './tokens.css';
|
@import './tokens.css';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ── 다크 테마 (W5 NFR) ──
|
||||||
|
* 값 단일 출처: `--dk-*` 상수를 :root 에 정의 → 명시 선택(data-theme=dark)과
|
||||||
|
* 시스템 선호(@media, 명시 선택 없을 때) 두 경로가 동일 값을 remap 한다.
|
||||||
|
* 라이트가 기본 — data-theme 미지정 + OS 라이트면 tokens.css :root 원본을 그대로 사용.
|
||||||
|
* 런타임은 themeStore 가 document.documentElement[data-theme] 를 항상 스탬프한다.
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
/* 다크 팔레트 상수 (slate — 캔버스 #1C2536 계열 정렬) */
|
||||||
|
--dk-neutral-050: #10151f; /* 앱 배경 */
|
||||||
|
--dk-surface: #1a2130; /* 카드/화이트 서피스 */
|
||||||
|
--dk-neutral-100: #232c3d;
|
||||||
|
--dk-neutral-200: #2f3a4c; /* 보더·구분선 */
|
||||||
|
--dk-neutral-500: #8b94a4;
|
||||||
|
--dk-neutral-700: #c6ced9;
|
||||||
|
--dk-neutral-900: #f0f3f8;
|
||||||
|
--dk-primary-600: #3f9ae2;
|
||||||
|
--dk-primary-700: #62b0ea;
|
||||||
|
--dk-primary-900: #2d7cc0;
|
||||||
|
--dk-primary-100: #16344c; /* 활성 사이드바 */
|
||||||
|
--dk-primary-050: #14202f; /* 서브 배경·테이블 호버 */
|
||||||
|
--dk-ai-accent: #9a80ff;
|
||||||
|
--dk-ai-surface: #221d39;
|
||||||
|
--dk-ai-bg: #2a2247;
|
||||||
|
--dk-ai-accent-strong: #7c5cff;
|
||||||
|
--dk-success: #34c88b;
|
||||||
|
--dk-warning: #e0983a;
|
||||||
|
--dk-error: #f4796d;
|
||||||
|
--dk-success-bg: #122a20;
|
||||||
|
--dk-warning-bg: #2c2413;
|
||||||
|
--dk-error-bg: #2e1a19;
|
||||||
|
--dk-violation-warn-bg: #3a2c10;
|
||||||
|
--dk-violation-warn-text: #e5a54a;
|
||||||
|
--dk-row-hover: #222c3c;
|
||||||
|
--dk-zebra: #151c28;
|
||||||
|
--dk-skeleton-a: #1e2634;
|
||||||
|
--dk-skeleton-b: #28323f;
|
||||||
|
--dk-ai-skeleton-a: #221d39;
|
||||||
|
--dk-ai-skeleton-b: #2c2547;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* remap 리스트 — 명시 다크 선택 */
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--color-neutral-050: var(--dk-neutral-050);
|
||||||
|
--color-white: var(--dk-surface);
|
||||||
|
--color-neutral-100: var(--dk-neutral-100);
|
||||||
|
--color-neutral-200: var(--dk-neutral-200);
|
||||||
|
--color-neutral-500: var(--dk-neutral-500);
|
||||||
|
--color-neutral-700: var(--dk-neutral-700);
|
||||||
|
--color-neutral-900: var(--dk-neutral-900);
|
||||||
|
--color-primary-600: var(--dk-primary-600);
|
||||||
|
--color-primary-700: var(--dk-primary-700);
|
||||||
|
--color-primary-900: var(--dk-primary-900);
|
||||||
|
--color-primary-100: var(--dk-primary-100);
|
||||||
|
--color-primary-050: var(--dk-primary-050);
|
||||||
|
--color-ai-accent: var(--dk-ai-accent);
|
||||||
|
--color-ai-surface: var(--dk-ai-surface);
|
||||||
|
--color-ai-bg: var(--dk-ai-bg);
|
||||||
|
--color-ai-accent-strong: var(--dk-ai-accent-strong);
|
||||||
|
--color-success: var(--dk-success);
|
||||||
|
--color-warning: var(--dk-warning);
|
||||||
|
--color-error: var(--dk-error);
|
||||||
|
--color-success-bg: var(--dk-success-bg);
|
||||||
|
--color-warning-bg: var(--dk-warning-bg);
|
||||||
|
--color-error-bg: var(--dk-error-bg);
|
||||||
|
--color-violation-warn-bg: var(--dk-violation-warn-bg);
|
||||||
|
--color-violation-warn-text: var(--dk-violation-warn-text);
|
||||||
|
--row-hover: var(--dk-row-hover);
|
||||||
|
--zebra: var(--dk-zebra);
|
||||||
|
--skeleton-a: var(--dk-skeleton-a);
|
||||||
|
--skeleton-b: var(--dk-skeleton-b);
|
||||||
|
--ai-skeleton-a: var(--dk-ai-skeleton-a);
|
||||||
|
--ai-skeleton-b: var(--dk-ai-skeleton-b);
|
||||||
|
--focus-ring: var(--dk-primary-700);
|
||||||
|
--shadow-level2: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 시스템 선호 폴백 — 명시 선택(data-theme) 없고 OS 다크일 때 (pre-JS/no-JS 대비) */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme]) {
|
||||||
|
color-scheme: dark;
|
||||||
|
--color-neutral-050: var(--dk-neutral-050);
|
||||||
|
--color-white: var(--dk-surface);
|
||||||
|
--color-neutral-100: var(--dk-neutral-100);
|
||||||
|
--color-neutral-200: var(--dk-neutral-200);
|
||||||
|
--color-neutral-500: var(--dk-neutral-500);
|
||||||
|
--color-neutral-700: var(--dk-neutral-700);
|
||||||
|
--color-neutral-900: var(--dk-neutral-900);
|
||||||
|
--color-primary-600: var(--dk-primary-600);
|
||||||
|
--color-primary-700: var(--dk-primary-700);
|
||||||
|
--color-primary-900: var(--dk-primary-900);
|
||||||
|
--color-primary-100: var(--dk-primary-100);
|
||||||
|
--color-primary-050: var(--dk-primary-050);
|
||||||
|
--color-ai-accent: var(--dk-ai-accent);
|
||||||
|
--color-ai-surface: var(--dk-ai-surface);
|
||||||
|
--color-ai-bg: var(--dk-ai-bg);
|
||||||
|
--color-ai-accent-strong: var(--dk-ai-accent-strong);
|
||||||
|
--color-success: var(--dk-success);
|
||||||
|
--color-warning: var(--dk-warning);
|
||||||
|
--color-error: var(--dk-error);
|
||||||
|
--color-success-bg: var(--dk-success-bg);
|
||||||
|
--color-warning-bg: var(--dk-warning-bg);
|
||||||
|
--color-error-bg: var(--dk-error-bg);
|
||||||
|
--color-violation-warn-bg: var(--dk-violation-warn-bg);
|
||||||
|
--color-violation-warn-text: var(--dk-violation-warn-text);
|
||||||
|
--row-hover: var(--dk-row-hover);
|
||||||
|
--zebra: var(--dk-zebra);
|
||||||
|
--skeleton-a: var(--dk-skeleton-a);
|
||||||
|
--skeleton-b: var(--dk-skeleton-b);
|
||||||
|
--ai-skeleton-a: var(--dk-ai-skeleton-a);
|
||||||
|
--ai-skeleton-b: var(--dk-ai-skeleton-b);
|
||||||
|
--focus-ring: var(--dk-primary-700);
|
||||||
|
--shadow-level2: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@ -60,9 +177,9 @@ textarea {
|
|||||||
font-size: var(--fs-mono);
|
font-size: var(--fs-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 접근성: 키보드 포커스 링 (WCAG AA) */
|
/* 접근성: 키보드 포커스 링 (WCAG AA) — 토큰화(테마별 대비 확보) */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--color-primary-600);
|
outline: 2px solid var(--focus-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,12 +196,36 @@ textarea {
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* prefers-reduced-motion 존중 */
|
/* prefers-reduced-motion 존중 — 전역 (셸·화면 공통) */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
transition-duration: 0.01ms !important;
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 스킵 링크 — 키보드 사용자용 "본문 바로가기" (포커스 시 노출) */
|
||||||
|
.kx-skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: var(--space-3);
|
||||||
|
top: -48px;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-primary-600);
|
||||||
|
color: var(--color-white);
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: top 0.15s ease;
|
||||||
|
}
|
||||||
|
.kx-skip-link:focus-visible {
|
||||||
|
top: var(--space-3);
|
||||||
|
color: var(--color-white);
|
||||||
|
}
|
||||||
|
|||||||
@ -96,4 +96,21 @@
|
|||||||
--row-h-mobile: 48px;
|
--row-h-mobile: 48px;
|
||||||
--zebra: var(--color-neutral-050);
|
--zebra: var(--color-neutral-050);
|
||||||
--row-hover: #f1f5f9;
|
--row-hover: #f1f5f9;
|
||||||
|
|
||||||
|
/* ── 시맨틱 상태 배경 톤 (배지·칩·상태 카드) — 라이트 기본값, 다크는 global.css 에서 재정의 ── */
|
||||||
|
--color-success-bg: #e6f4ee;
|
||||||
|
--color-warning-bg: #fff4e5;
|
||||||
|
--color-error-bg: #fef3f2;
|
||||||
|
--color-ai-bg: #ede9fe; /* AI검토·시공중 배지 배경(보라 옅음) */
|
||||||
|
--color-ai-accent-strong: #5b3aeb; /* AI 버튼 hover 진한 톤 */
|
||||||
|
--ai-glow: rgba(109, 74, 255, 0.3); /* AI 버튼 글로우 */
|
||||||
|
|
||||||
|
/* 스켈레톤 시머 (states·ai-image) */
|
||||||
|
--skeleton-a: #eceff3;
|
||||||
|
--skeleton-b: #f6f8fa;
|
||||||
|
--ai-skeleton-a: #ece9fb;
|
||||||
|
--ai-skeleton-b: #f5f3ff;
|
||||||
|
|
||||||
|
/* 접근성 — 포커스 링 (라이트 기본, 다크 재정의) */
|
||||||
|
--focus-ring: var(--color-primary-600);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user