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:
zio 2026-07-12 10:52:52 +09:00
parent 4049b7c222
commit 48572d261a
47 changed files with 3728 additions and 666 deletions

View File

@ -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 로컬) — 삭제는 소유자 확인 필요 규칙상 **미커밋·보류** |

View File

@ -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);
} }

View File

@ -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) {

View File

@ -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();

View File

@ -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;
} }

View File

@ -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);

View File

@ -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));
}
}

View File

@ -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;
}
}
}

View File

@ -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();
}
}

View File

@ -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(),
"알 수 없는 테넌트입니다."))));
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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
) {
}

View File

@ -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
) {
}

View File

@ -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);

View File

@ -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>
<!-- 홀 트렌치 좌표(가정 그리드 포함). --> <!-- 홀 트렌치 좌표(가정 그리드 포함). -->

View File

@ -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",

View File

@ -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",

View File

@ -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>

View File

@ -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;

View 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,
};

View File

@ -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;

View File

@ -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);
} }

View File

@ -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);
} }

View File

@ -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 (

View File

@ -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;
} }

View 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;

View 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 0918)",
"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 35 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"
}
}

View 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 (平日 0918時)",
"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": "クレジットカード分割払いの場合、キャンセル後カード会社を通じた限度復元まで約35営業日かかることがあります。",
"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": "キャンセル"
}
}

View 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 (평일 0918시)",
"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": "신용카드 할부 결제 건은 취소 시 카드사를 통해 한도 복구까지 약 35영업일이 소요될 수 있습니다.",
"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": "취소"
}
}

View 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 (工作日 0918时)",
"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": "信用卡分期付款的订单取消后通过卡公司恢复额度约需35个工作日。",
"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": "取消"
}
}

View File

@ -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({

View File

@ -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>

View File

@ -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)} />}

View File

@ -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>

View File

@ -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>
); );

View File

@ -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>

View File

@ -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 />

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>
); );

View File

@ -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 ( 0918) {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>
35
.
</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>

View File

@ -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);
} }

View 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') });
}
});
}

View File

@ -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);
}

View File

@ -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);
} }