From 48572d261a09bc13112362cf31f474cd1aa3a8c5 Mon Sep 17 00:00:00 2001 From: zio Date: Sun, 12 Jul 2026 10:52:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(nfr,tenant):=20wave=205+6=20=E2=80=94=20da?= =?UTF-8?q?rk=20theme=20toggle,=20i18n=20(ko/en/zh/ja),=20multitenancy=20p?= =?UTF-8?q?hase=201=20(V26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/WORK_STATUS.md | 4 +- .../kintex/catalog/EventCatalogMapper.java | 14 +- .../catalog/EventCatalogServiceImpl.java | 10 +- .../zioinfo/kintex/config/SecurityConfig.java | 6 + .../module/m2/FloorplanServiceImpl.java | 2 +- .../kintex/module/m2/mapper/HallMapper.java | 4 +- .../kintex/tenant/TenantAdminController.java | 50 ++ .../kintex/tenant/TenantAdminService.java | 86 +++ .../zioinfo/kintex/tenant/TenantContext.java | 41 ++ .../kintex/tenant/TenantContextFilter.java | 69 +++ .../zioinfo/kintex/tenant/TenantMapper.java | 54 ++ .../zioinfo/kintex/tenant/TenantResolver.java | 90 +++ .../kintex/tenant/dto/TenantAdminDto.java | 16 + .../tenant/dto/TenantCreateRequest.java | 17 + .../db/migration/V26__multitenancy_base.sql | 36 ++ .../resources/mybatis/mapper/HallMapper.xml | 1 + src/frontend/package-lock.json | 93 +++- src/frontend/package.json | 3 + .../src/components/layout/AppShell.tsx | 23 +- .../src/components/layout/app-shell.css | 18 + .../src/components/ui/LanguageSwitcher.tsx | 101 ++++ src/frontend/src/components/ui/ai-image.css | 6 +- src/frontend/src/components/ui/badge.css | 10 +- src/frontend/src/components/ui/button.css | 4 +- src/frontend/src/components/ui/icons.tsx | 26 + src/frontend/src/components/ui/states.css | 2 +- src/frontend/src/i18n/index.ts | 72 +++ src/frontend/src/i18n/locales/en.json | 521 ++++++++++++++++++ src/frontend/src/i18n/locales/ja.json | 521 ++++++++++++++++++ src/frontend/src/i18n/locales/ko.json | 521 ++++++++++++++++++ src/frontend/src/i18n/locales/zh.json | 521 ++++++++++++++++++ src/frontend/src/main.tsx | 2 + .../screens/login/ForgotPasswordDialog.tsx | 46 +- src/frontend/src/screens/login/LoginPage.tsx | 58 +- .../src/screens/login/SignupDialog.tsx | 57 +- .../screens/public/PublicEventDetailPage.tsx | 164 +++--- .../screens/public/PublicFloorplanPage.tsx | 80 +-- .../src/screens/public/PublicHomePage.tsx | 77 ++- .../src/screens/public/PublicInquiryPage.tsx | 119 ++-- .../screens/public/PublicMicrositePage.tsx | 92 ++-- .../screens/public/PublicRegistrationPage.tsx | 155 +++--- .../src/screens/public/PublicShell.tsx | 112 ++-- .../src/screens/public/PublicTicketPage.tsx | 236 ++++---- src/frontend/src/screens/shared.css | 12 +- src/frontend/src/store/themeStore.ts | 78 +++ src/frontend/src/styles/global.css | 147 ++++- src/frontend/src/styles/tokens.css | 17 + 47 files changed, 3728 insertions(+), 666 deletions(-) create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminController.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminService.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantContext.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantContextFilter.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantMapper.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantResolver.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/tenant/dto/TenantAdminDto.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/tenant/dto/TenantCreateRequest.java create mode 100644 src/backend/src/main/resources/db/migration/V26__multitenancy_base.sql create mode 100644 src/frontend/src/components/ui/LanguageSwitcher.tsx create mode 100644 src/frontend/src/i18n/index.ts create mode 100644 src/frontend/src/i18n/locales/en.json create mode 100644 src/frontend/src/i18n/locales/ja.json create mode 100644 src/frontend/src/i18n/locales/ko.json create mode 100644 src/frontend/src/i18n/locales/zh.json create mode 100644 src/frontend/src/store/themeStore.ts diff --git a/docs/WORK_STATUS.md b/docs/WORK_STATUS.md index 0fa53c5..1c2d020 100644 --- a/docs/WORK_STATUS.md +++ b/docs/WORK_STATUS.md @@ -17,7 +17,8 @@ ## 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`). - 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하면 자동배포. ## 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 | **실데이터·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 | **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 | **3차 웨이브(백로그 3트랙) + 배포 크래시 근본수정** — ①크래시 규명: c6f3a8d jar가 MyBatis ` """) - List> findEvents(@Param("year") String year, + List> findEvents(@Param("tenantId") String tenantId, + @Param("year") String year, @Param("status") String status, @Param("category") String category, @Param("limit") int limit, @@ -79,11 +81,13 @@ public interface EventCatalogMapper { LIMIT 1 ) pa ON true 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.end_date >= CAST(#{monthStart} AS date) """) - List> findEventsOverlappingMonth(@Param("monthStart") String monthStart, + List> findEventsOverlappingMonth(@Param("tenantId") String tenantId, + @Param("monthStart") String monthStart, @Param("monthEnd") String monthEnd); /** 필터 조건 총 건수(페이징 메타). */ @@ -91,6 +95,7 @@ public interface EventCatalogMapper { """) - long countEvents(@Param("year") String year, @Param("status") String status); + long countEvents(@Param("tenantId") String tenantId, + @Param("year") String year, @Param("status") String status); } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogServiceImpl.java b/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogServiceImpl.java index 3cb9351..d297510 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogServiceImpl.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogServiceImpl.java @@ -3,6 +3,7 @@ package com.zioinfo.kintex.catalog; import com.zioinfo.kintex.catalog.dto.CalendarDayDto; import com.zioinfo.kintex.catalog.dto.ExhibitionDto; import com.zioinfo.kintex.catalog.dto.HallUtilizationDto; +import com.zioinfo.kintex.tenant.TenantContext; import org.springframework.stereotype.Service; import java.time.LocalDate; @@ -38,7 +39,8 @@ public class EventCatalogServiceImpl implements EventCatalogService { @Override public List list(String year, String status, String category, int limit, int offset) { List> rows = - mapper.findEvents(nullIfBlank(year), nullIfBlank(status), nullIfBlank(category), limit, offset); + mapper.findEvents(TenantContext.currentTenantId(), + nullIfBlank(year), nullIfBlank(status), nullIfBlank(category), limit, offset); List out = new ArrayList<>(rows == null ? 0 : rows.size()); if (rows != null) { for (Map r : rows) { @@ -54,7 +56,8 @@ public class EventCatalogServiceImpl implements EventCatalogService { @Override public List hallUtilization(int year, int month) { MonthRange mr = MonthRange.of(year, month); - List> rows = mapper.findEventsOverlappingMonth(mr.startYmd(), mr.endYmd()); + List> rows = + mapper.findEventsOverlappingMonth(TenantContext.currentTenantId(), mr.startYmd(), mr.endYmd()); // 홀 그룹별 점유 일자 집합 List busy = new ArrayList<>(); @@ -107,7 +110,8 @@ public class EventCatalogServiceImpl implements EventCatalogService { @Override public List calendar(int year, int month) { MonthRange mr = MonthRange.of(year, month); - List> rows = mapper.findEventsOverlappingMonth(mr.startYmd(), mr.endYmd()); + List> rows = + mapper.findEventsOverlappingMonth(TenantContext.currentTenantId(), mr.startYmd(), mr.endYmd()); int[] counts = new int[mr.days() + 1]; // 1-based if (rows != null) { for (Map r : rows) { diff --git a/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java b/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java index e6675f5..81308f4 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java @@ -2,6 +2,8 @@ package com.zioinfo.kintex.config; import com.zioinfo.kintex.auth.JwtAuthenticationFilter; 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.ApiError; import com.zioinfo.kintex.common.error.ErrorCode; @@ -26,6 +28,7 @@ public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService, + TenantResolver tenantResolver, ObjectMapper objectMapper) throws Exception { http .csrf(AbstractHttpConfigurer::disable) @@ -50,6 +53,9 @@ public class SecurityConfig { ApiResponse.fail(new ApiError(ErrorCode.UNAUTHORIZED.name(), ErrorCode.UNAUTHORIZED.defaultMessage())))); })) + // 테넌트 컨텍스트 해소(§1A-3) → 이후 JWT 인증. 미해소 시 기준 테넌트 폴백(회귀 0). + .addFilterBefore(new TenantContextFilter(tenantResolver, objectMapper), + UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new JwtAuthenticationFilter(jwtService), UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java index 1147149..b20b5e4 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java @@ -316,7 +316,7 @@ public class FloorplanServiceImpl implements FloorplanService { } private HallInfo loadHallInfo(String hallId) { - Map h = hallMapper.findHall(hallId); + Map h = hallMapper.findHall(hallId, com.zioinfo.kintex.tenant.TenantContext.currentTenantId()); if (h == null) { return null; } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/HallMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/HallMapper.java index e678683..df446ec 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/HallMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/HallMapper.java @@ -15,8 +15,8 @@ import java.util.Map; @Mapper public interface HallMapper { - /** 홀 마스터 헤더(치수·천장·목표 부스 수·바닥하중·가정 트렌치 여부). 없으면 null. */ - Map findHall(@Param("hallId") String hallId); + /** 홀 마스터 헤더(치수·천장·목표 부스 수·바닥하중·가정 트렌치 여부) — 테넌트 스코프. 없으면 null. */ + Map findHall(@Param("hallId") String hallId, @Param("tenantId") String tenantId); /** 홀 트렌치 좌표 목록(가정 그리드 포함) — 캔버스 오버레이·배선 참조용. */ List> findTrenches(@Param("hallId") String hallId); diff --git a/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminController.java b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminController.java new file mode 100644 index 0000000..9b93af6 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminController.java @@ -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). 플랫폼 슈퍼관리자 전용. + *

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(@AuthenticationPrincipal KintexPrincipal principal) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.list()); + } + + /** POST — 테넌트 등록(온보딩 1단계). */ + @Audited(action = "TENANT_CREATE", targetType = "tenant") + @PostMapping + public ApiResponse create(@AuthenticationPrincipal KintexPrincipal principal, + @RequestBody TenantCreateRequest req) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.create(req)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminService.java b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminService.java new file mode 100644 index 0000000..0d469c6 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminService.java @@ -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 list() { + List> rows = mapper.findAdminList(); + List out = new ArrayList<>(rows == null ? 0 : rows.size()); + if (rows != null) { + for (Map 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; + } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantContext.java b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantContext.java new file mode 100644 index 0000000..1c2838f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantContext.java @@ -0,0 +1,41 @@ +package com.zioinfo.kintex.tenant; + +/** + * 요청별 테넌트(전시관) 컨텍스트 — ThreadLocal (PLANNING §1A-3 · §8-2). + *

{@link TenantContextFilter}가 요청 처리 전에 해소한 {@code tenant_id}를 주입하고, 처리 후 정리한다. + * 서비스/매퍼는 클라이언트 파라미터가 아닌 이 컨텍스트를 신뢰 원천으로 스코프한다(위조 차단, §8-2). + * + *

미설정(비 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 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(); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantContextFilter.java b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantContextFilter.java new file mode 100644 index 0000000..61f9ec9 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantContextFilter.java @@ -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 필터 계층). + *

해소 우선순위: ① {@code X-Tenant-Id} 헤더 → ② Host 도메인 매칭 → ③ 기준 테넌트('kintex'). + * 명시적 {@code X-Tenant-Id}가 존재하지 않는 테넌트를 가리키면 400(오접속·위조 차단). + * 그 외(헤더 없음·도메인 미매칭)는 기준 테넌트로 폴백해 기존 동작을 완전히 보존한다(회귀 0, 1단계). + *

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(), + "알 수 없는 테넌트입니다.")))); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantMapper.java new file mode 100644 index 0000000..9fbcfe5 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantMapper.java @@ -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}) — 컨텍스트 해소 + 플랫폼 관리자 온보딩용. + *

★ 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> 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> 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); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantResolver.java b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantResolver.java new file mode 100644 index 0000000..c64a8ec --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantResolver.java @@ -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 조회를 피한다(소규모 마스터). + *

해소 우선순위(§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 idSet = Map.of(); + /** 소문자 domain → tenant id. */ + private volatile Map 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> rows = mapper.findAllForResolve(); + Map ids = new HashMap<>(); + Map domains = new HashMap<>(); + if (rows != null) { + for (Map 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); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/tenant/dto/TenantAdminDto.java b/src/backend/src/main/java/com/zioinfo/kintex/tenant/dto/TenantAdminDto.java new file mode 100644 index 0000000..36701e1 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/tenant/dto/TenantAdminDto.java @@ -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 +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/tenant/dto/TenantCreateRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/tenant/dto/TenantCreateRequest.java new file mode 100644 index 0000000..a8c3894 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/tenant/dto/TenantCreateRequest.java @@ -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 +) { +} diff --git a/src/backend/src/main/resources/db/migration/V26__multitenancy_base.sql b/src/backend/src/main/resources/db/migration/V26__multitenancy_base.sql new file mode 100644 index 0000000..f8e17bd --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V26__multitenancy_base.sql @@ -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); diff --git a/src/backend/src/main/resources/mybatis/mapper/HallMapper.xml b/src/backend/src/main/resources/mybatis/mapper/HallMapper.xml index d5b3a21..40f2039 100644 --- a/src/backend/src/main/resources/mybatis/mapper/HallMapper.xml +++ b/src/backend/src/main/resources/mybatis/mapper/HallMapper.xml @@ -20,6 +20,7 @@ is_assumed_trench AS "assumedTrench" FROM hall WHERE id = #{hallId} + AND tenant_id = #{tenantId} diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 89fa22b..7579ccc 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -10,8 +10,11 @@ "dependencies": { "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.59.0", + "i18next": "^23.16.8", + "i18next-browser-languagedetector": "^8.2.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^15.7.4", "react-router-dom": "^6.26.2", "recharts": "^3.9.2", "sockjs-client": "^1.6.1", @@ -262,6 +265,15 @@ "@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": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -1731,18 +1743,59 @@ "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": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", "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": { "version": "11.1.11", "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz", "integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -1921,6 +1974,32 @@ "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": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", @@ -2184,8 +2263,9 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "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": { "version": "0.7.5", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index f7651b4..ab54316 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -14,8 +14,11 @@ "dependencies": { "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.59.0", + "i18next": "^23.16.8", + "i18next-browser-languagedetector": "^8.2.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^15.7.4", "react-router-dom": "^6.26.2", "recharts": "^3.9.2", "sockjs-client": "^1.6.1", diff --git a/src/frontend/src/components/layout/AppShell.tsx b/src/frontend/src/components/layout/AppShell.tsx index 3719850..84d551b 100644 --- a/src/frontend/src/components/layout/AppShell.tsx +++ b/src/frontend/src/components/layout/AppShell.tsx @@ -1,8 +1,10 @@ import type { ComponentType } from 'react'; import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { useAuthStore } from '../../store/authStore'; +import { useThemeStore } from '../../store/themeStore'; import { useResolvedEventId } from '../../hooks/useResolvedEventId'; import { DdayChip } from '../ui/Badge'; +import { LanguageSwitcher } from '../ui/LanguageSwitcher'; import { IconAnalytics, IconBell, @@ -12,10 +14,12 @@ import { IconDocument, IconExhibitors, IconFloorplan, + IconMoon, IconOperations, IconSearch, IconSettings, IconSettlement, + IconSun, IconUsers, type IconProps, } from '../ui/icons'; @@ -92,8 +96,11 @@ export function AppShell() { const user = useAuthStore((s) => s.user); const workspace = useAuthStore((s) => s.currentWorkspace()); const logout = useAuthStore((s) => s.logout); + const themeResolved = useThemeStore((s) => s.resolved); + const toggleTheme = useThemeStore((s) => s.toggle); const navigate = useNavigate(); const resolvedEventId = useResolvedEventId(); + const isDark = themeResolved === 'dark'; /** 행사 스코프 항목 진입 — 대상 행사 해소 시 계산 경로로, 미해소 시 행사 선택(/login)으로. */ function goScoped(key: string) { @@ -107,6 +114,9 @@ export function AppShell() { return (

+ + 본문 바로가기 +
diff --git a/src/frontend/src/components/layout/app-shell.css b/src/frontend/src/components/layout/app-shell.css index 5bab569..ec6cae1 100644 --- a/src/frontend/src/components/layout/app-shell.css +++ b/src/frontend/src/components/layout/app-shell.css @@ -137,6 +137,24 @@ font-weight: 500; 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 { background: none; border: none; diff --git a/src/frontend/src/components/ui/LanguageSwitcher.tsx b/src/frontend/src/components/ui/LanguageSwitcher.tsx new file mode 100644 index 0000000..356159a --- /dev/null +++ b/src/frontend/src/components/ui/LanguageSwitcher.tsx @@ -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 ( + + ); +} + +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 ( + + ); +} + +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, +}; diff --git a/src/frontend/src/components/ui/ai-image.css b/src/frontend/src/components/ui/ai-image.css index f001fae..3749cb4 100644 --- a/src/frontend/src/components/ui/ai-image.css +++ b/src/frontend/src/components/ui/ai-image.css @@ -31,7 +31,7 @@ font-size: var(--fs-caption); } .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%; animation: kx-shimmer 1.4s infinite; } @@ -52,7 +52,7 @@ } .kx-aiimg__state--failed { color: var(--color-error); - background: #fef3f2; + background: var(--color-error-bg); } .kx-aiimg__retry { border: 1px solid var(--color-error); @@ -121,7 +121,7 @@ box-shadow: var(--shadow-level2); } .kx-aiimg__gen:hover { - background: #5b3aeb; + background: var(--color-ai-accent-strong); } .kx-aiimg__notice { font-size: 11px; diff --git a/src/frontend/src/components/ui/badge.css b/src/frontend/src/components/ui/badge.css index e4eaf02..2f44a10 100644 --- a/src/frontend/src/components/ui/badge.css +++ b/src/frontend/src/components/ui/badge.css @@ -24,15 +24,15 @@ color: var(--color-ai-accent); } .kx-badge--status-approved { - background: #e6f4ee; + background: var(--color-success-bg); color: var(--color-success); } .kx-badge--status-rejected { - background: #fef3f2; + background: var(--color-error-bg); color: var(--color-error); } .kx-badge--status-building { - background: #ede9fe; + background: var(--color-ai-bg); color: var(--status-building); } .kx-badge--status-inspected { @@ -51,11 +51,11 @@ color: var(--color-neutral-700); } .kx-badge--dday-warn { - background: #fff4e5; + background: var(--color-warning-bg); color: var(--color-warning); } .kx-badge--dday-over { - background: #fef3f2; + background: var(--color-error-bg); color: var(--color-error); } diff --git a/src/frontend/src/components/ui/button.css b/src/frontend/src/components/ui/button.css index 6e8905b..60230d8 100644 --- a/src/frontend/src/components/ui/button.css +++ b/src/frontend/src/components/ui/button.css @@ -45,7 +45,7 @@ .kx-btn--ai { background: var(--color-ai-accent); 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 { opacity: 0.92; @@ -65,5 +65,5 @@ border-color: var(--color-error); } .kx-btn--danger:not(:disabled):hover { - background: #fef3f2; + background: var(--color-error-bg); } diff --git a/src/frontend/src/components/ui/icons.tsx b/src/frontend/src/components/ui/icons.tsx index c15b4e0..b45ed56 100644 --- a/src/frontend/src/components/ui/icons.tsx +++ b/src/frontend/src/components/ui/icons.tsx @@ -431,6 +431,32 @@ export function IconGrid(p: IconProps) { ); } +/** ☀ 해(라이트 테마). */ +export function IconSun(p: IconProps) { + return ( + + + + + + + + + + + + ); +} + +/** ☾ 달(다크 테마). */ +export function IconMoon(p: IconProps) { + return ( + + + + ); +} + /** ● 오프라인(연결 끊김). */ export function IconWifiOff(p: IconProps) { return ( diff --git a/src/frontend/src/components/ui/states.css b/src/frontend/src/components/ui/states.css index e2f36d7..8ee125a 100644 --- a/src/frontend/src/components/ui/states.css +++ b/src/frontend/src/components/ui/states.css @@ -1,6 +1,6 @@ .kx-skeleton { 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%; animation: kx-skel 1.4s infinite; } diff --git a/src/frontend/src/i18n/index.ts b/src/frontend/src/i18n/index.ts new file mode 100644 index 0000000..017a987 --- /dev/null +++ b/src/frontend/src/i18n/index.ts @@ -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(); + *

{t('public.home.heroTitle')}

+ * 키 네이밍: `<영역>.<화면>.<요소>` (예: 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 = { + 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; diff --git a/src/frontend/src/i18n/locales/en.json b/src/frontend/src/i18n/locales/en.json new file mode 100644 index 0000000..57adcce --- /dev/null +++ b/src/frontend/src/i18n/locales/en.json @@ -0,0 +1,521 @@ +{ + "common": { + "brand": "KINTEX", + "appSuffix": "AI Exhibition Management", + "actions": { + "prev": "Back", + "next": "Next", + "cancel": "Cancel", + "close": "Close", + "home": "Go to Home", + "viewMore": "View Details", + "confirm": "Confirm" + }, + "states": { + "loading": "Loading…", + "empty": "Nothing to display." + } + }, + "lang": { + "select": "Select language" + }, + "shell": { + "nav": { + "events": "Events", + "exhibit": "Exhibitors", + "visit": "Visit", + "transport": "Directions" + }, + "searchPlaceholder": "Search events", + "cta": { "register": "Visitor Pre-registration" }, + "menuOpen": "Open menu", + "menuClose": "Close menu", + "homeAria": "{{brand}} home", + "primaryMenu": "Main menu", + "mobileMenu": "Mobile menu", + "footer": { + "brandDesc": "Korea's largest exhibition and convention center, connecting innovation in business and culture.", + "quickTitle": "Quick Links", + "quickSchedule": "Event Schedule", + "quickExhibit": "Exhibitor Info", + "quickDirections": "Directions", + "quickSitemap": "Sitemap", + "supportTitle": "Support", + "supportPrivacy": "Privacy Policy", + "supportTerms": "Terms of Use", + "supportContact": "Contact Us", + "contactTitle": "Contact", + "contactAddress": "217-60 Kintex-ro, Ilsanseo-gu, Goyang-si, Gyeonggi-do", + "contactTel": "Main line 1899-1001", + "contactEmail": "exhibition@kintex.com", + "bottom": "© KINTEX. All Rights Reserved. (10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si" + } + }, + "home": { + "heroAria": "About KINTEX", + "eyebrow": "Global Exhibition Platform", + "heroTitleLine1": "Exhibitions that connect the world,", + "heroTitleEm": "KINTEX", + "heroLead": "Experience new innovation in business and culture unfolding at KINTEX, Korea's largest exhibition and convention center.", + "ctaRegister": "Visitor Pre-registration", + "ctaFacility": "View Facilities", + "eventsAria": "Ongoing and upcoming events", + "eventsTitle": "Ongoing · Upcoming Events", + "eventsSub": "Check out the latest happenings at KINTEX right now.", + "eventsMore": "View Full Schedule", + "loading": "Loading event information…", + "empty": "There are no ongoing or upcoming events at this time.", + "tagExhibition": "EXHIBITION", + "booth": "{{count}} booths", + "aiOptimized": "AI Optimized", + "ongoing": "Ongoing", + "ended": "Ended", + "dday": "D-{{n}}", + "ddayZero": "D-DAY", + "register": "Pre-register", + "hallTbd": "Hall to be assigned", + "transportAria": "Directions", + "transportTitle": "Directions", + "transportSub": "Reach KINTEX with even faster accessibility.", + "gtxTitle": "GTX-A KINTEX Station Now Open", + "gtxDesc": "A 3-minute walk from KINTEX Station", + "transit": "Public Transit", + "transitDesc": "Metropolitan buses and subway", + "parking": "Parking", + "parkingDesc": "4,000 vehicles simultaneously", + "shuttle": "Shuttle Bus", + "shuttleDesc": "Circulating major hubs", + "mapAria": "KINTEX location map", + "mapAddress": "217-60 Kintex-ro, Ilsanseo-gu, Goyang-si", + "mapPlace": "KINTEX Exhibition Center 1 & 2", + "mapViewAria": "View map" + }, + "detail": { + "tabs": { + "intro": "Overview", + "program": "Program", + "speakers": "Speakers", + "exhibitors": "Exhibitors", + "transport": "Directions", + "faq": "FAQ" + }, + "eyebrow": "KINTEX Exhibition", + "loadingTitle": "Loading event information…", + "fallbackTitle": "Event Details", + "dateTbd": "Dates to be confirmed", + "placeTbd": "Hall to be assigned", + "lead": "Check the exhibition details at KINTEX and pre-register.", + "factDate": "Date", + "factPlace": "Venue", + "ctaRegister": "Pre-register", + "ctaFloorplan": "View Floor Plan", + "navAria": "Event detail navigation", + "shareAria": "Share", + "share": "Share", + "copyLink": "Copy link", + "overviewAria": "Exhibition overview", + "overviewTitle": "Exhibition Overview", + "overviewLead": "Smart Factory Korea 2026 is the culmination of next-generation manufacturing technology. Discover the latest solutions combining AI, robotics, IoT, and digital twin technology in one place, with over 500 leading companies from around the world participating.", + "statBooth": "Booths", + "statVisitors": "Expected Visitors", + "statVisitorsValue": "30,000+", + "themesTitle": "Key Exhibition Themes", + "theme1Title": "AI & Autonomous Robots", + "theme1Desc": "Smart robot solutions that learn and decide on their own", + "theme2Title": "Digital Twin & IoT", + "theme2Desc": "Real-time sync between virtual worlds and real manufacturing sites", + "theme3Title": "Carbon-Neutral Smart Manufacturing", + "theme3Desc": "Eco-friendly processes maximizing energy efficiency", + "programAria": "Program schedule", + "programTitle": "Program Schedule", + "day1": "Sep 5 (Day 1)", + "day2": "Sep 6 (Day 2)", + "agenda1Title": "The Future of Manufacturing: AI-Driven Productivity Revolution", + "agenda1Desc": "2030 manufacturing strategies and real-world case studies proposed by global AI scholars", + "agenda1Room": "Main Conference Hall", + "agenda2Title": "Combining Industrial 5G and Edge Computing", + "agenda2Desc": "Real-time process control technology demonstration via lag-free data transmission", + "agenda2Room": "Seminar Room A", + "agenda3Title": "Global Buyers Matching Day", + "agenda3Desc": "One-on-one business meetings between promising overseas companies and domestic suppliers", + "agenda3Room": "Business Lounge", + "speakersTitle": "Keynote Speakers", + "speaker1Role": "AI Research Director | TechCorp", + "speaker2Role": "CTO | Automatech", + "speaker3Role": "Chair Professor of Robotics | Korea Univ.", + "speaker4Role": "Global VP | Digitron", + "profileAria": "{{name}} profile", + "faqAria": "Frequently asked questions", + "faqTitle": "Frequently Asked Questions", + "faq1Q": "How do I pre-register?", + "faq1A": "Click the \"Pre-register\" button at the top, choose your type, and enter your information to receive a mobile badge.", + "faq2Q": "Is there an entry fee?", + "faq2A": "General visitors are free with pre-registration. On-site registration may be charged.", + "faq3Q": "Is parking available?", + "faq3A": "Up to 4,000 vehicles can park simultaneously at KINTEX Exhibition Centers 1 and 2.", + "fabCta": "Go to Pre-registration" + }, + "register": { + "steps": { "type": "Type", "info": "Info", "interest": "Interests", "done": "Done" }, + "stepsAria": "Pre-registration steps", + "type": { + "general": "General Visitor", + "generalDesc": "General visitors able to attend exhibitions and open sessions", + "buyer": "Buyer", + "buyerDesc": "Corporate representatives for B2B matching and business consultations", + "vip": "VIP", + "vipDesc": "Invited guests with exclusive lounge and full session access" + }, + "step0Title": "Select Participant Type", + "step0Sub": "Please choose your eligibility to participate in the KINTEX exhibition.", + "typeGroupAria": "Participant type", + "step1Title": "Enter Basic Information", + "step1Sub": "Please enter your information for contact and badge issuance.", + "name": "Name", + "namePh": "Enter your name", + "phone": "Phone (Mobile)", + "email": "Email", + "emailPh": "example@domain.com", + "org": "Organization (Company/Institution)", + "orgPh": "Company or school name", + "step2Title": "Select Interests", + "step2Sub": "Select your interests and we'll provide tailored information.", + "interestsAria": "Interests", + "interest": { + "ai": "AI Solutions", + "factory": "Smart Factory", + "robotics": "Robotics", + "bigdata": "Big Data", + "cloud": "Cloud" + }, + "consentPrivacy": "Consent to collection and use of personal information", + "required": "(required)", + "consentMarketing": "Consent to marketing communications (optional)", + "consentMore": "View details", + "errPrivacy": "Please check consent to collection and use of personal information (required).", + "errName": "Please enter your name.", + "errEvent": "Event information could not be verified.", + "doneTitle": "Your pre-registration has been received", + "doneSub": "{{name}}, your pre-registration is complete. Use your mobile badge at the entrance kiosk.", + "visitorDefault": "Visitor", + "badgeAria": "Mobile badge preview", + "badgeName": "John Doe", + "badgeCodeLabel": "Badge Code", + "doneNote": "Scan the QR at the entrance kiosk to print a physical badge.", + "regNo": "Registration No.: {{id}}", + "submitting": "Registering…", + "submit": "Register", + "eventBrowse": "Browse Events", + "floorplan": "View Floor Plan" + }, + "floorplan": { + "sideAria": "Booth search and list", + "filtersTitle": "Industry · Zone · Keyword", + "searchPh": "Search exhibitor or booth number", + "searchAria": "Search booths", + "typeFilter": "Booth type filter", + "hallSelect": "Select hall", + "allHalls": "All halls", + "listAria": "Booth list (non-visual alternative)", + "loading": "Loading booth information…", + "searchResults": "Search results ({{count}})", + "noMatch": "No matching booths.", + "reservedBooth": "Reserved booth", + "availableBooth": "Available", + "type": { + "assembled": "Shell Scheme", + "independent": "Raw Space", + "corner": "Corner Booth", + "island": "Island", + "default": "Booth" + }, + "stageAria": "Interactive hall map", + "boothLayoutAria": "Booth layout", + "boothAria": "Booth {{no}} {{name}}", + "loadFail": "Failed to load the floor plan.", + "noBooth": "No booths to display.", + "popInfoAria": "{{name}} information", + "popDetail": "Booth details", + "directions": "Directions", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "reset": "Reset", + "myLocation": "My location", + "legendAria": "Legend", + "legendAvailable": "Available", + "legendReserved": "Reserved", + "legendPublic": "Public" + }, + "inquiry": { + "heroAria": "Exhibitor inquiry", + "heroTitle": "Exhibitor Inquiry", + "heroLead": "Expand your business to global markets with KINTEX. Our specialist consultants will propose the optimal exhibition space.", + "cta": "Exhibitor Inquiry", + "guideAria": "Exhibitor guide", + "guideTitle": "Exhibitor Guide", + "booth1Tier": "Entry Tier", + "booth1Title": "Shell Scheme", + "booth1Desc": "The most economical choice with basic walls and flooring provided. Recommended for small businesses.", + "booth1Price": "₩ 2,500,000 / unit", + "booth2Tier": "Design Freedom", + "booth2Title": "Raw Space", + "booth2Desc": "Only space is provided, allowing free design tailored to your brand.", + "booth2Price": "₩ 2,000,000 / unit", + "booth3Tier": "Full Solution", + "booth3Title": "Premium", + "booth3Desc": "An all-in-one solution including AI-optimized traffic flow design and a custom branding package.", + "booth3Price": "Inquire separately", + "boothExampleAria": "{{title}} example", + "formAria": "Contact us", + "formTitle": "Contact Us", + "formSub": "Leave any questions about participation or the exhibition. Our staff will reply within 24 business hours.", + "contactPhone": "Phone Inquiry", + "contactPhoneVal": "+82-31-810-8114", + "contactEmail": "Email Inquiry", + "contactEmailVal": "exhibit@kintex.com", + "contactLoc": "Location", + "contactLocVal": "217-60 Kintex-ro, Ilsanseo-gu, Goyang-si, Gyeonggi-do", + "loginHintQ": "Already have an account?", + "loginHintA": "Log in for a faster inquiry", + "company": "Company", + "companyPh": "Enter company name", + "name": "Contact Person", + "namePh": "Enter your name", + "contact": "Contact (Email or Phone)", + "contactPh": "Enter reachable contact information", + "scale": "Estimated Size", + "scaleSelect": "Please select", + "scale1": "1 booth (9sqm)", + "scale2": "2-4 booths (18-36sqm)", + "scale3": "5-9 booths (45-81sqm)", + "scale4": "10 or more", + "hall": "Preferred Hall", + "hallSelect": "Select hall", + "message": "Message", + "messagePh": "Let us know your participation goals or any special requests.", + "agree": "I agree to the collection and use of personal information.", + "required": "(required)", + "errAgree": "Please agree to the collection and use of personal information.", + "errName": "Please enter the contact person's name.", + "errContact": "Please enter a reachable email or phone number.", + "errMessage": "Please enter your message.", + "success": "Your inquiry has been received. Reference No. {{no}}", + "submitting": "Sending…", + "submit": "Send Inquiry" + }, + "microsite": { + "introAria": "About {{brand}}", + "eyebrow": "KINTEX Exhibitor", + "ctaMeeting": "Book a Meeting", + "ctaBrochure": "Company Brochure", + "defaultBrand": "Hanbit Robotics Inc.", + "defaultTagline": "Intelligent robotics solutions that move the future", + "defaultBoothLoc": "Booth A-102 · Hall 7", + "boothUndetermined": "TBD", + "boothPrefix": "Booth", + "boothLocLabel": "KINTEX exhibition booth location", + "viewMap": "View Floor Plan", + "productsAria": "Core product lineup", + "productsTitle": "Core Product Lineup", + "productsSub": "Featured products you can meet on site.", + "product": "Product", + "product1Tag": "Industrial COBOT", + "product1Title": "HR-A1 Collaborative Robot", + "product1Desc": "An industrial collaborative robot with high precision and safety, designed with human collaboration as the top priority.", + "product2Tag": "Smart Logistics", + "product2Title": "HR-M3 Autonomous AMR", + "product2Desc": "An autonomous mobile robot maximizing smart factory logistics efficiency, with real-time obstacle avoidance and route optimization.", + "product3Tag": "AI Vision", + "product3Title": "HR-Q Vision Scanner", + "product3Desc": "Flawless quality inspection based on AI deep learning. Ultra-precise 3D scanning that catches even the tiniest defects.", + "productImgAria": "{{title}} image", + "previewAria": "Booth preview", + "previewTitle": "Booth Preview", + "previewSub": "AI-generated preview booth images. Actual construction results may differ.", + "galleryMain": "Main Exhibition Zone", + "galleryVip": "VIP Lounge", + "galleryAria": "{{label}} AI-generated preview image", + "watermark": "AI-generated preview image — actual construction results may differ", + "meetingAria": "Book a meeting", + "ctaBanner": "We propose customized robotics solutions tailored to your business.", + "ctaBannerBtn": "Book a Meeting Now" + }, + "ticket": { + "viewPurchase": "Buy Tickets", + "viewManage": "Manage · Cancel", + "viewSwitchAria": "Switch ticket view", + "type": { + "general": "General", + "generalDesc": "General visitors and individual participants", + "generalPrice": "₩0 (pre-registration discount)", + "buyer": "Buyer", + "buyerDesc": "For industry professionals and buyers only", + "buyerPrice": "Free · Screening required", + "group": "Group (10+ tickets)", + "groupDesc": "For corporate group visits only", + "groupPrice": "₩9,000 / person", + "vip": "VIP", + "vipDesc": "Exhibition all-pass + VIP lounge access", + "vipPrice": "₩50,000" + }, + "steps": { "s1": "Event · Type", "s2": "Buyer", "s3": "Payment", "s4": "Done" }, + "stepsAria": "Booking steps", + "bannerTitle": "Smart Factory Korea 2026", + "bannerDate": "2026.09.05 – 09.08", + "bannerPlace": "Exhibition Center 2, Hall 7", + "posterAria": "Event poster", + "loginBanner": "Log in to automatically save your booking history.", + "loginLink": "Log in", + "sample": "Sample data", + "h2Type": "Select Ticket Type", + "aiOptimized": "AI OPTIMIZED", + "off10": "10% OFF", + "soldout": "Sold Out", + "qtyAria": "{{name}} quantity", + "decrease": "Decrease", + "increase": "Increase", + "h2Booker": "Booker Information", + "bookerName": "Name", + "bookerNamePh": "Full name", + "bookerTel": "Phone", + "bookerEmail": "Email (ticket delivery)", + "consentPrivacy": "Consent to collection and use of personal information", + "required": "(required)", + "consentMarketing": "Consent to marketing communications (optional)", + "h2Pay": "Payment Method", + "payCard": "Credit Card", + "payEasy": "Easy Pay", + "payBank": "Bank Transfer", + "payGroupAria": "Payment method", + "pgNote": "Payment is processed on the PG provider's secure page, and card information is not stored on this site. All transactions are protected by 256-bit SSL encryption. (PG not integrated — demo flow)", + "doneTitle": "Your booking has been received", + "doneNo": "KTX-2026-0000-DEMO", + "doneSub": "We'll email you the ticket (upon live integration). Total {{count}} tickets · Payment {{total}}", + "sampleReady": "Sample data · Payment coming soon", + "walletBtn": "Open Mobile Ticket Wallet", + "manageBtn": "Manage / Cancel Booking", + "summaryAria": "Booking summary", + "summaryTitle": "Booking Summary", + "summaryCount": "Selected quantity", + "count": "{{n}} tickets", + "subtotal": "Subtotal", + "discount": "Discount", + "finalTotal": "Final payment amount", + "nextStep": "Continue to next step", + "freeDone": "Complete Free Booking", + "pay": "Pay", + "payProceed": "Proceed to Payment", + "customerCenter": "Customer center 1588-0000 (weekdays 09–18)", + "manageTitle": "Manage · Cancel Booking", + "manageSub": "You can look up and cancel non-member bookings.", + "lookNo": "Booking number", + "lookNoPh": "KTX-YYYY-XXXXXX", + "lookEmail": "Email", + "lookEmailPh": "example@email.com", + "lookBtn": "Look up", + "loginToView": "Log in to view my bookings", + "notLooked": "Enter your booking number and email to look up. (Sample: enter any value and look up)", + "booked": "Booking complete", + "bookNoLabel": "Booking No.", + "paidAt": "Paid at", + "colType": "Type", + "colQty": "Qty", + "colAmount": "Amount", + "bookerInfo": "Booker info", + "totalPaid": "Total paid", + "refundTitle": "Cancellation · Refund Policy", + "refundD7": "7 days before visit (D-7)", + "refundD7Val": "100% refund", + "refundD3": "3 days before visit (D-3)", + "refundD3Val": "50% refund", + "refundDay": "Day of visit", + "refundDayVal": "0% (non-refundable)", + "refundEst": "Current estimated refund", + "cancelBooking": "Cancel Booking", + "refundNote": "Refunds are processed to the original payment method via the PG provider.", + "aiHelper": "AI Settlement Assistant", + "aiHelperDesc": "For credit card installment payments, restoring your limit through the card company may take about 3–5 business days after cancellation.", + "cancelModalAria": "Confirm booking cancellation", + "cancelModalTitle": "Cancel this booking?", + "cancelModalDesc": "A 100% refund (₩40,000) applies based on D-15. Refunds are processed to the original payment method via the PG provider, with actual refunds upon live integration.", + "cancelConfirm": "Cancel" + }, + "login": { + "title": "Log In", + "sub": "Welcome to the KINTEX Exhibition Management System.", + "email": "Email", + "emailPh": "name@company.com", + "password": "Password", + "remember": "Remember me", + "forgot": "Forgot password", + "errResponse": "We couldn't process the login response. Please try again.", + "errLocked": "Login attempts are temporarily restricted. Please try again shortly.", + "errNotImpl": "The login service is being prepared. Please try again shortly.", + "errGeneric": "An error occurred while logging in.", + "submitting": "Logging in…", + "submit": "Log In", + "signupPrompt": "Don't have an account yet?", + "signup": "Sign Up", + "or": "or", + "internalLogin": "Log in with KINTEX internal account", + "inviteLink": "Join with an invite code →", + "copyright": "© 2026 KINTEX. All rights reserved.", + "wsTitle": "Event Workspace", + "wsSub": "Select an event you are participating in.", + "wsEmptyTitle": "No events joined", + "wsEmptyDesc": "Check your invite link or join with an invite code.", + "wsInvite": "Join with an invite code" + }, + "signup": { + "title": "Sign Up", + "desc": "Create a KINTEX AI Exhibition Management account. Company name and invite code are optional.", + "email": "Email", + "emailPh": "name@company.com", + "name": "Name", + "namePh": "John Doe", + "password": "Password", + "passwordPh": "8+ characters", + "password2": "Confirm Password", + "password2Ph": "Re-enter password", + "company": "Company (optional)", + "companyPh": "KINTEX Inc.", + "invite": "Invite Code (optional)", + "invitePh": "INV-XXXX", + "mismatch": "Passwords do not match.", + "errNotImpl": "The sign-up service is being prepared. Please try again shortly.", + "errConflict": "This email is already registered.", + "errGeneric": "An error occurred while signing up.", + "noticePending": "Your account has been created. You can log in after administrator approval.", + "noticeActive": "Your account has been created. Please log in now.", + "doneTitle": "Sign-up Complete", + "goLogin": "Go to Login", + "cancel": "Cancel", + "submitting": "Signing up…", + "submit": "Sign Up" + }, + "forgot": { + "requestTitle": "Forgot Password", + "requestDesc": "We'll send a verification code to your registered email. If the account exists, an email will be sent.", + "email": "Email", + "emailPh": "name@company.com", + "sending": "Sending…", + "requestBtn": "Get Verification Code", + "resetTitle": "Reset Password", + "resetDesc": "Enter the verification code sent to <0>{{email}} 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" + } +} diff --git a/src/frontend/src/i18n/locales/ja.json b/src/frontend/src/i18n/locales/ja.json new file mode 100644 index 0000000..51f60e4 --- /dev/null +++ b/src/frontend/src/i18n/locales/ja.json @@ -0,0 +1,521 @@ +{ + "common": { + "brand": "KINTEX", + "appSuffix": "AI 展示会管理", + "actions": { + "prev": "戻る", + "next": "次へ", + "cancel": "キャンセル", + "close": "閉じる", + "home": "ホームへ", + "viewMore": "詳細を見る", + "confirm": "確認" + }, + "states": { + "loading": "読み込み中…", + "empty": "表示する内容がありません。" + } + }, + "lang": { + "select": "言語を選択" + }, + "shell": { + "nav": { + "events": "イベント", + "exhibit": "出展案内", + "visit": "来場案内", + "transport": "交通" + }, + "searchPlaceholder": "イベントを検索", + "cta": { "register": "来場事前登録" }, + "menuOpen": "メニューを開く", + "menuClose": "メニューを閉じる", + "homeAria": "{{brand}} ホーム", + "primaryMenu": "メインメニュー", + "mobileMenu": "モバイルメニュー", + "footer": { + "brandDesc": "韓国最大規模の展示コンベンションセンター。ビジネスと文化の革新をつなぎます。", + "quickTitle": "クイックリンク", + "quickSchedule": "イベント日程", + "quickExhibit": "出展案内", + "quickDirections": "アクセス", + "quickSitemap": "サイトマップ", + "supportTitle": "カスタマーサポート", + "supportPrivacy": "プライバシーポリシー", + "supportTerms": "利用規約", + "supportContact": "お問い合わせ", + "contactTitle": "連絡先", + "contactAddress": "京畿道高陽市一山西区KINTEXロ217-60", + "contactTel": "代表番号 1899-1001", + "contactEmail": "exhibition@kintex.com", + "bottom": "© KINTEX. All Rights Reserved. (10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si" + } + }, + "home": { + "heroAria": "KINTEX について", + "eyebrow": "Global Exhibition Platform", + "heroTitleLine1": "世界へつながる展示会、", + "heroTitleEm": "KINTEX", + "heroLead": "韓国最大規模の展示コンベンションセンター KINTEX で繰り広げられる、ビジネスと文化の新たな革新を体験してください。", + "ctaRegister": "来場事前登録", + "ctaFacility": "施設案内を見る", + "eventsAria": "開催中・開催予定のイベント", + "eventsTitle": "開催中 · 開催予定", + "eventsSub": "今 KINTEX で開催中の注目情報をご確認ください。", + "eventsMore": "全日程を見る", + "loading": "イベント情報を読み込み中…", + "empty": "現在、開催中または開催予定のイベントはありません。", + "tagExhibition": "EXHIBITION", + "booth": "ブース {{count}}", + "aiOptimized": "AI 最適化", + "ongoing": "開催中", + "ended": "終了", + "dday": "D-{{n}}", + "ddayZero": "D-DAY", + "register": "事前登録", + "hallTbd": "ホール割当予定", + "transportAria": "アクセス", + "transportTitle": "アクセス", + "transportSub": "さらに便利になったアクセスで KINTEX へ。", + "gtxTitle": "GTX-A KINTEX 駅 開通", + "gtxDesc": "KINTEX 駅から徒歩3分", + "transit": "公共交通", + "transitDesc": "広域バスおよび地下鉄", + "parking": "駐車案内", + "parkingDesc": "4,000台同時駐車", + "shuttle": "シャトルバス", + "shuttleDesc": "主要拠点を循環", + "mapAria": "KINTEX 位置地図", + "mapAddress": "高陽市一山西区KINTEXロ217-60", + "mapPlace": "KINTEX 第1・2展示場", + "mapViewAria": "地図を見る" + }, + "detail": { + "tabs": { + "intro": "紹介", + "program": "プログラム", + "speakers": "登壇者", + "exhibitors": "出展企業", + "transport": "交通", + "faq": "FAQ" + }, + "eyebrow": "KINTEX Exhibition", + "loadingTitle": "イベント情報を読み込み中…", + "fallbackTitle": "イベント詳細", + "dateTbd": "日程確認中", + "placeTbd": "ホール割当予定", + "lead": "KINTEX で開催される展示会情報を確認し、事前登録しましょう。", + "factDate": "日時", + "factPlace": "場所", + "ctaRegister": "事前登録する", + "ctaFloorplan": "会場フロアマップを見る", + "navAria": "イベント詳細ナビゲーション", + "shareAria": "共有", + "share": "共有する", + "copyLink": "リンクをコピー", + "overviewAria": "展示会概要", + "overviewTitle": "展示会概要", + "overviewLead": "スマートファクトリー・コリア 2026 は次世代製造技術の集大成です。AI、ロボティクス、IoT、そしてデジタルツイン技術を融合した最新ソリューションを一堂に。世界500社以上のリーディング企業が参加します。", + "statBooth": "出展ブース", + "statVisitors": "予想来場者数", + "statVisitorsValue": "30,000+", + "themesTitle": "主要展示テーマ", + "theme1Title": "AI & 自律ロボット", + "theme1Desc": "自ら学習し判断するスマートロボットソリューション", + "theme2Title": "デジタルツイン & IoT", + "theme2Desc": "仮想世界と現実の製造現場のリアルタイム同期", + "theme3Title": "カーボンニュートラル・スマート製造", + "theme3Desc": "エネルギー効率を最大化する環境配慮型プロセス技術", + "programAria": "プログラム日程", + "programTitle": "プログラム日程", + "day1": "9月5日 (Day 1)", + "day2": "9月6日 (Day 2)", + "agenda1Title": "製造業の未来:AI が導く生産性革命", + "agenda1Desc": "AI のグローバル権威が提案する2030製造戦略と実際の導入事例分析", + "agenda1Room": "メインカンファレンスホール", + "agenda2Title": "産業用5Gとエッジコンピューティングの融合", + "agenda2Desc": "遅延のないデータ伝送によるリアルタイム工程制御技術の実演", + "agenda2Room": "セミナールーム A", + "agenda3Title": "Global Buyers Matching Day", + "agenda3Desc": "海外有望企業と国内サプライヤーによる1対1のビジネスミーティング", + "agenda3Room": "ビジネスラウンジ", + "speakersTitle": "主要登壇者", + "speaker1Role": "AI 研究所長 | TechCorp", + "speaker2Role": "CTO | Automatech", + "speaker3Role": "ロボット工学 碩座教授 | Korea Univ.", + "speaker4Role": "Global VP | Digitron", + "profileAria": "{{name}} プロフィール", + "faqAria": "よくある質問", + "faqTitle": "よくある質問", + "faq1Q": "事前登録はどうすればよいですか?", + "faq1A": "上部の「事前登録する」ボタンからタイプを選択し情報を入力すると、モバイルバッジが発行されます。", + "faq2Q": "入場料はかかりますか?", + "faq2A": "一般来場者は事前登録で無料です。当日登録は有料の場合があります。", + "faq3Q": "駐車場はありますか?", + "faq3A": "KINTEX 第1・2展示場で最大4,000台の同時駐車が可能です。", + "fabCta": "事前登録へ" + }, + "register": { + "steps": { "type": "タイプ", "info": "情報", "interest": "関心分野", "done": "完了" }, + "stepsAria": "事前登録ステップ", + "type": { + "general": "一般来場者", + "generalDesc": "展示および公開セッションを参観できる一般来場者", + "buyer": "バイヤー", + "buyerDesc": "B2Bマッチングおよびビジネス商談のための企業担当者", + "vip": "VIP", + "vipDesc": "招待者専用ラウンジおよび全セッション入場の来賓" + }, + "step0Title": "参加タイプの選択", + "step0Sub": "KINTEX 展示会にご参加いただく資格を選択してください。", + "typeGroupAria": "参加タイプ", + "step1Title": "基本情報の入力", + "step1Sub": "連絡および入場証発行のための情報を入力してください。", + "name": "氏名", + "namePh": "お名前を入力", + "phone": "連絡先 (携帯)", + "email": "メール", + "emailPh": "example@domain.com", + "org": "所属 (会社/機関)", + "orgPh": "会社名または学校名", + "step2Title": "関心分野の選択", + "step2Sub": "関心分野を選択いただくと、最適な情報をご提供します。", + "interestsAria": "関心分野", + "interest": { + "ai": "AI ソリューション", + "factory": "スマートファクトリー", + "robotics": "ロボティクス", + "bigdata": "ビッグデータ", + "cloud": "クラウド" + }, + "consentPrivacy": "個人情報の収集・利用に同意", + "required": "(必須)", + "consentMarketing": "マーケティング配信に同意 (任意)", + "consentMore": "詳細を見る", + "errPrivacy": "個人情報の収集・利用への同意(必須)にチェックしてください。", + "errName": "お名前を入力してください。", + "errEvent": "イベント情報を確認できません。", + "doneTitle": "事前登録を受け付けました", + "doneSub": "{{name}} 様の事前登録が完了しました。入口キオスクでモバイルバッジをご利用ください。", + "visitorDefault": "来場者", + "badgeAria": "モバイルバッジプレビュー", + "badgeName": "山田太郎", + "badgeCodeLabel": "Badge Code", + "doneNote": "入口キオスクで QR を読み取ると実物バッジを印刷できます。", + "regNo": "登録番号:{{id}}", + "submitting": "登録中…", + "submit": "登録する", + "eventBrowse": "イベントを見る", + "floorplan": "会場フロアマップを見る" + }, + "floorplan": { + "sideAria": "ブース検索および一覧", + "filtersTitle": "業種 · ゾーン · キーワード", + "searchPh": "出展企業またはブース番号を検索", + "searchAria": "ブース検索", + "typeFilter": "ブースタイプフィルター", + "hallSelect": "展示場を選択", + "allHalls": "全ホール", + "listAria": "ブース一覧 (非ビジュアル代替)", + "loading": "ブース情報を読み込み中…", + "searchResults": "検索結果 ({{count}})", + "noMatch": "一致するブースがありません。", + "reservedBooth": "予約済みブース", + "availableBooth": "分譲可能", + "type": { + "assembled": "パッケージブース", + "independent": "スペースのみ", + "corner": "コーナーブース", + "island": "アイランド", + "default": "ブース" + }, + "stageAria": "インタラクティブホールマップ", + "boothLayoutAria": "ブース配置", + "boothAria": "ブース {{no}} {{name}}", + "loadFail": "フロアマップを読み込めませんでした。", + "noBooth": "表示するブースがありません。", + "popInfoAria": "{{name}} 情報", + "popDetail": "ブース詳細", + "directions": "経路案内", + "zoomIn": "拡大", + "zoomOut": "縮小", + "reset": "リセット", + "myLocation": "現在地", + "legendAria": "凡例", + "legendAvailable": "販売 (Available)", + "legendReserved": "予約 (Reserved)", + "legendPublic": "共用部 (Public)" + }, + "inquiry": { + "heroAria": "出展のお問い合わせ", + "heroTitle": "出展のお問い合わせ", + "heroLead": "KINTEX とともに、貴社のビジネスをグローバル市場へ拡大しましょう。専門コンサルタントが最適な展示スペースをご提案します。", + "cta": "出展のお問い合わせ", + "guideAria": "出展案内", + "guideTitle": "出展案内", + "booth1Tier": "Entry Tier", + "booth1Title": "パッケージブース (Shell Scheme)", + "booth1Desc": "基本の壁体と床が提供される最も経済的な選択。小規模企業におすすめです。", + "booth1Price": "₩ 2,500,000 / unit", + "booth2Tier": "Design Freedom", + "booth2Title": "スペースのみ (Raw Space)", + "booth2Desc": "スペースのみが提供され、ブランドに合わせた自由な設計が可能です。", + "booth2Price": "₩ 2,000,000 / unit", + "booth3Tier": "Full Solution", + "booth3Title": "プレミアム (Premium)", + "booth3Desc": "AI 最適化された動線設計とカスタムブランディングパッケージを含むオールインワンソリューションです。", + "booth3Price": "別途お問い合わせ", + "boothExampleAria": "{{title}} 例", + "formAria": "お問い合わせ", + "formTitle": "お問い合わせ", + "formSub": "出展申込や展示に関するご質問をお残しください。担当者が営業日24時間以内に回答します。", + "contactPhone": "電話でのお問い合わせ", + "contactPhoneVal": "+82-31-810-8114", + "contactEmail": "メールでのお問い合わせ", + "contactEmailVal": "exhibit@kintex.com", + "contactLoc": "所在地", + "contactLocVal": "京畿道高陽市一山西区KINTEXロ217-60", + "loginHintQ": "すでにアカウントをお持ちですか?", + "loginHintA": "ログインして素早くお問い合わせ", + "company": "会社名", + "companyPh": "会社名を入力", + "name": "担当者", + "namePh": "お名前を入力", + "contact": "連絡先 (メールまたは電話番号)", + "contactPh": "連絡可能な情報を入力", + "scale": "予想規模", + "scaleSelect": "選択してください", + "scale1": "1ブース (9sqm)", + "scale2": "2-4ブース (18-36sqm)", + "scale3": "5-9ブース (45-81sqm)", + "scale4": "10以上", + "hall": "希望ホール", + "hallSelect": "ホールを選択", + "message": "お問い合わせ内容", + "messagePh": "出展目的や特別なご要望があればお残しください。", + "agree": "個人情報の収集および利用に同意します。", + "required": "(必須)", + "errAgree": "個人情報の収集および利用に同意してください。", + "errName": "担当者名を入力してください。", + "errContact": "連絡可能なメールまたは電話番号を入力してください。", + "errMessage": "お問い合わせ内容を入力してください。", + "success": "お問い合わせを受け付けました。受付番号 {{no}}", + "submitting": "送信中…", + "submit": "問い合わせを送信" + }, + "microsite": { + "introAria": "{{brand}} について", + "eyebrow": "KINTEX Exhibitor", + "ctaMeeting": "ミーティング予約", + "ctaBrochure": "会社案内", + "defaultBrand": "ハンビットロボティクス株式会社", + "defaultTagline": "未来を動かすインテリジェント・ロボティクスソリューション", + "defaultBoothLoc": "ブース A-102 · ホール7", + "boothUndetermined": "未定", + "boothPrefix": "ブース", + "boothLocLabel": "KINTEX 展示場ブース位置", + "viewMap": "会場フロアマップを見る", + "productsAria": "主力製品ラインナップ", + "productsTitle": "主力製品ラインナップ", + "productsSub": "現場でご覧いただける代表製品です。", + "product": "製品", + "product1Tag": "Industrial COBOT", + "product1Title": "HR-A1 協働ロボット", + "product1Desc": "高い精度と安全性を備えた産業用協働ロボット。人との協働を最優先に設計しました。", + "product2Tag": "Smart Logistics", + "product2Title": "HR-M3 自律走行 AMR", + "product2Desc": "スマートファクトリーの物流効率を最大化する自律移動ロボット。リアルタイムの障害物回避と経路最適化。", + "product3Tag": "AI Vision", + "product3Title": "HR-Q Vision スキャナー", + "product3Desc": "AI ディープラーニングによる完璧な品質検査。微細な欠陥も見逃さない超精密3Dスキャニング。", + "productImgAria": "{{title}} 画像", + "previewAria": "ブースプレビュー", + "previewTitle": "ブースプレビュー", + "previewSub": "AI が生成した予想ブース画像です。実際の施工結果と異なる場合があります。", + "galleryMain": "メイン展示ゾーン", + "galleryVip": "VIP ラウンジ", + "galleryAria": "{{label}} AI 生成の予想画像", + "watermark": "AI 生成の予想画像 — 実際の施工結果と異なる場合があります", + "meetingAria": "ミーティング予約", + "ctaBanner": "貴社のビジネスに合わせたカスタムロボットソリューションをご提案します。", + "ctaBannerBtn": "今すぐミーティング予約" + }, + "ticket": { + "viewPurchase": "チケット購入", + "viewManage": "確認 · キャンセル", + "viewSwitchAria": "チケット画面切替", + "type": { + "general": "一般券", + "generalDesc": "一般来場者および個人参加者", + "generalPrice": "₩0 (事前登録割引価格)", + "buyer": "バイヤー券", + "buyerDesc": "関連企業従事者およびバイヤー専用", + "buyerPrice": "無料 · 資格審査必要", + "group": "団体券 (10枚以上)", + "groupDesc": "企業団体来場専用", + "groupPrice": "₩9,000 / 1人", + "vip": "VIP 券", + "vipDesc": "展示会オールパス + VIP ラウンジ利用券", + "vipPrice": "₩50,000" + }, + "steps": { "s1": "イベント·券種", "s2": "予約者", "s3": "決済", "s4": "完了" }, + "stepsAria": "予約ステップ", + "bannerTitle": "スマートファクトリー・コリア 2026", + "bannerDate": "2026.09.05 – 09.08", + "bannerPlace": "第2展示場 ホール7", + "posterAria": "イベントポスター", + "loginBanner": "ログインすると予約内容が自動保存されます。", + "loginLink": "ログイン", + "sample": "サンプルデータ", + "h2Type": "チケット券種の選択", + "aiOptimized": "AI OPTIMIZED", + "off10": "10% OFF", + "soldout": "完売", + "qtyAria": "{{name}} 数量", + "decrease": "減らす", + "increase": "増やす", + "h2Booker": "予約者情報", + "bookerName": "氏名", + "bookerNamePh": "お名前", + "bookerTel": "連絡先", + "bookerEmail": "メール (チケット送付)", + "consentPrivacy": "個人情報の収集・利用に同意", + "required": "(必須)", + "consentMarketing": "マーケティング配信に同意 (任意)", + "h2Pay": "決済方法", + "payCard": "クレジットカード", + "payEasy": "簡単決済", + "payBank": "口座振込", + "payGroupAria": "決済方法", + "pgNote": "決済は PG 社のセキュアページで行われ、カード情報は本サイトに保存されません。すべての取引は256ビット SSL 暗号化で保護されます。(PG 未連携 — デモフロー)", + "doneTitle": "予約を受け付けました", + "doneNo": "KTX-2026-0000-DEMO", + "doneSub": "メールでチケットをお送りします。(本サービス連携時) 合計 {{count}} 枚 · 決済 {{total}}", + "sampleReady": "サンプルデータ · 決済準備中", + "walletBtn": "モバイルチケットウォレットを開く", + "manageBtn": "予約確認 / キャンセル", + "summaryAria": "予約内容の概要", + "summaryTitle": "予約内容の概要", + "summaryCount": "選択枚数", + "count": "{{n}} 枚", + "subtotal": "小計", + "discount": "割引", + "finalTotal": "最終決済金額", + "nextStep": "次のステップへ", + "freeDone": "無料予約を完了", + "pay": "決済する", + "payProceed": "決済へ進む", + "customerCenter": "カスタマーセンター 1588-0000 (平日 09–18時)", + "manageTitle": "予約確認 · キャンセル", + "manageSub": "非会員の予約内容を照会・キャンセルできます。", + "lookNo": "予約番号", + "lookNoPh": "KTX-YYYY-XXXXXX", + "lookEmail": "メール", + "lookEmailPh": "example@email.com", + "lookBtn": "照会", + "loginToView": "ログインして予約を確認", + "notLooked": "予約番号とメールを入力して照会してください。(サンプル:任意の値を入力して照会)", + "booked": "予約完了", + "bookNoLabel": "予約番号", + "paidAt": "決済日時", + "colType": "券種", + "colQty": "数量", + "colAmount": "金額", + "bookerInfo": "予約者情報", + "totalPaid": "総決済金額", + "refundTitle": "キャンセル·返金ポリシー", + "refundD7": "来場7日前 (D-7)", + "refundD7Val": "100% 返金", + "refundD3": "来場3日前 (D-3)", + "refundD3Val": "50% 返金", + "refundDay": "来場当日", + "refundDayVal": "0% (返金不可)", + "refundEst": "現在の予想返金額", + "cancelBooking": "予約キャンセル", + "refundNote": "返金は PG 社を通じて元の決済手段へ処理されます。", + "aiHelper": "AI 精算アシスタント", + "aiHelperDesc": "クレジットカード分割払いの場合、キャンセル後カード会社を通じた限度復元まで約3–5営業日かかることがあります。", + "cancelModalAria": "予約キャンセルの確認", + "cancelModalTitle": "予約をキャンセルしますか?", + "cancelModalDesc": "D-15 基準で100%返金(₩40,000)が適用されます。返金は元の決済手段へ PG 社を通じて処理され、本サービス連携時に実際の返金が行われます。", + "cancelConfirm": "キャンセルする" + }, + "login": { + "title": "ログイン", + "sub": "KINTEX 展示会管理システムへようこそ。", + "email": "メール", + "emailPh": "name@company.com", + "password": "パスワード", + "remember": "ID を記憶", + "forgot": "パスワードを探す", + "errResponse": "ログイン応答を処理できませんでした。もう一度お試しください。", + "errLocked": "ログイン試行が一時的に制限されています。しばらくしてからお試しください。", + "errNotImpl": "ログインサービスは準備中です。しばらくしてからお試しください。", + "errGeneric": "ログイン中にエラーが発生しました。", + "submitting": "ログイン中…", + "submit": "ログイン", + "signupPrompt": "アカウントをお持ちでないですか?", + "signup": "新規登録", + "or": "または", + "internalLogin": "KINTEX 内部アカウントでログイン", + "inviteLink": "招待コードで参加 →", + "copyright": "© 2026 KINTEX. All rights reserved.", + "wsTitle": "イベントワークスペース", + "wsSub": "参加中のイベントを選択してください。", + "wsEmptyTitle": "参加中のイベントがありません", + "wsEmptyDesc": "招待リンクを確認するか、招待コードで参加してください。", + "wsInvite": "招待コードで参加" + }, + "signup": { + "title": "新規登録", + "desc": "KINTEX AI 展示会管理アカウントを作成します。会社名・招待コードは任意です。", + "email": "メール", + "emailPh": "name@company.com", + "name": "氏名", + "namePh": "山田太郎", + "password": "パスワード", + "passwordPh": "8文字以上", + "password2": "パスワード確認", + "password2Ph": "パスワード再入力", + "company": "会社名 (任意)", + "companyPh": "KINTEX 株式会社", + "invite": "招待コード (任意)", + "invitePh": "INV-XXXX", + "mismatch": "パスワードが一致しません。", + "errNotImpl": "新規登録サービスは準備中です。しばらくしてからお試しください。", + "errConflict": "すでに登録済みのメールです。", + "errGeneric": "新規登録中にエラーが発生しました。", + "noticePending": "アカウントが作成されました。管理者の承認後にログインできます。", + "noticeActive": "アカウントが作成されました。今すぐログインしてください。", + "doneTitle": "新規登録完了", + "goLogin": "ログインへ", + "cancel": "キャンセル", + "submitting": "登録中…", + "submit": "登録する" + }, + "forgot": { + "requestTitle": "パスワードを探す", + "requestDesc": "登録済みのメールに認証コードをお送りします。登録済みアカウントの場合、メールが送信されます。", + "email": "メール", + "emailPh": "name@company.com", + "sending": "送信中…", + "requestBtn": "認証コードを受け取る", + "resetTitle": "パスワード再設定", + "resetDesc": "<0>{{email}} に届いた認証コードと新しいパスワードを入力してください。", + "code": "認証コード", + "codePh": "メールで受け取ったコード", + "newpw": "新しいパスワード", + "newpwPh": "8文字以上", + "prev": "戻る", + "changing": "変更中…", + "changeBtn": "パスワード変更", + "doneTitle": "パスワード変更完了", + "doneDesc": "新しいパスワードでログインしてください。", + "goLogin": "ログインへ", + "errInvalid": "認証コードが正しくないか、期限切れです。", + "errNotImpl": "パスワード再設定サービスは準備中です。しばらくしてからお試しください。", + "errRequest": "リクエスト中にエラーが発生しました。", + "errReset": "再設定中にエラーが発生しました。", + "cancel": "キャンセル" + } +} diff --git a/src/frontend/src/i18n/locales/ko.json b/src/frontend/src/i18n/locales/ko.json new file mode 100644 index 0000000..fe7fdb6 --- /dev/null +++ b/src/frontend/src/i18n/locales/ko.json @@ -0,0 +1,521 @@ +{ + "common": { + "brand": "KINTEX", + "appSuffix": "AI 전시관리", + "actions": { + "prev": "이전", + "next": "다음", + "cancel": "취소", + "close": "닫기", + "home": "홈으로 이동", + "viewMore": "자세히 보기", + "confirm": "확인" + }, + "states": { + "loading": "불러오는 중입니다…", + "empty": "표시할 내용이 없습니다." + } + }, + "lang": { + "select": "언어 선택" + }, + "shell": { + "nav": { + "events": "행사", + "exhibit": "참가안내", + "visit": "관람안내", + "transport": "교통" + }, + "searchPlaceholder": "행사 검색", + "cta": { "register": "관람 사전등록" }, + "menuOpen": "메뉴 열기", + "menuClose": "메뉴 닫기", + "homeAria": "{{brand}} 홈", + "primaryMenu": "주요 메뉴", + "mobileMenu": "모바일 메뉴", + "footer": { + "brandDesc": "대한민국 최대 규모의 전시 컨벤션 센터. 비즈니스와 문화의 혁신을 잇습니다.", + "quickTitle": "바로가기", + "quickSchedule": "행사 일정", + "quickExhibit": "참가 안내", + "quickDirections": "오시는 길", + "quickSitemap": "사이트맵", + "supportTitle": "고객지원", + "supportPrivacy": "개인정보처리방침", + "supportTerms": "이용약관", + "supportContact": "문의하기", + "contactTitle": "연락처", + "contactAddress": "경기도 고양시 일산서구 킨텍스로 217-60", + "contactTel": "대표번호 1899-1001", + "contactEmail": "exhibition@kintex.com", + "bottom": "© KINTEX. All Rights Reserved. (10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si" + } + }, + "home": { + "heroAria": "킨텍스 소개", + "eyebrow": "Global Exhibition Platform", + "heroTitleLine1": "세계로 통하는 전시,", + "heroTitleEm": "킨텍스", + "heroLead": "대한민국 최대 규모의 전시 컨벤션 센터 KINTEX에서 펼쳐지는 비즈니스와 문화의 새로운 혁신을 경험하세요.", + "ctaRegister": "관람 사전등록", + "ctaFacility": "시설 안내 보기", + "eventsAria": "진행중 예정 행사", + "eventsTitle": "진행중 · 예정 행사", + "eventsSub": "지금 킨텍스에서 열리는 주요 소식을 확인하세요.", + "eventsMore": "전체 일정 보기", + "loading": "행사 정보를 불러오는 중입니다…", + "empty": "현재 진행 중이거나 예정된 행사가 없습니다.", + "tagExhibition": "EXHIBITION", + "booth": "부스 {{count}}", + "aiOptimized": "AI 최적화", + "ongoing": "진행중", + "ended": "종료", + "dday": "D-{{n}}", + "ddayZero": "D-DAY", + "register": "사전등록", + "hallTbd": "홀 배정 예정", + "transportAria": "오시는 길", + "transportTitle": "오시는 길", + "transportSub": "더욱 빨라진 접근성으로 킨텍스를 만나보세요.", + "gtxTitle": "GTX-A 킨텍스역 개통", + "gtxDesc": "킨텍스역에서 도보 3분 거리", + "transit": "대중교통", + "transitDesc": "광역버스 및 지하철", + "parking": "주차안내", + "parkingDesc": "4,000대 동시 주차", + "shuttle": "셔틀버스", + "shuttleDesc": "주요 거점 순환", + "mapAria": "킨텍스 위치 지도", + "mapAddress": "고양시 일산서구 킨텍스로 217-60", + "mapPlace": "KINTEX 제1, 2전시장", + "mapViewAria": "지도 보기" + }, + "detail": { + "tabs": { + "intro": "소개", + "program": "프로그램", + "speakers": "연사", + "exhibitors": "참가업체", + "transport": "교통", + "faq": "FAQ" + }, + "eyebrow": "KINTEX Exhibition", + "loadingTitle": "행사 정보를 불러오는 중…", + "fallbackTitle": "행사 상세", + "dateTbd": "일정 확인 중", + "placeTbd": "홀 배정 예정", + "lead": "킨텍스에서 열리는 전시회 정보를 확인하고 사전등록하세요.", + "factDate": "일시", + "factPlace": "장소", + "ctaRegister": "사전등록하기", + "ctaFloorplan": "전시장 도면 보기", + "navAria": "행사 상세 내비게이션", + "shareAria": "공유", + "share": "공유하기", + "copyLink": "링크 복사", + "overviewAria": "전시회 개요", + "overviewTitle": "전시회 개요", + "overviewLead": "스마트팩토리 코리아 2026은 차세대 제조 기술의 집약체입니다. AI, 로보틱스, IoT, 그리고 디지털 트윈 기술이 결합된 최신 솔루션을 한자리에서 확인하십시오. 전 세계 500개 이상의 선도 기업이 참여합니다.", + "statBooth": "참가 부스", + "statVisitors": "예상 참관객", + "statVisitorsValue": "30,000+", + "themesTitle": "주요 전시 테마", + "theme1Title": "AI & 자율 로봇", + "theme1Desc": "스스로 학습하고 판단하는 스마트 로봇 솔루션", + "theme2Title": "디지털 트윈 & IoT", + "theme2Desc": "가상 세계와 현실 제조 현장의 실시간 동기화", + "theme3Title": "탄소중립 스마트 제조", + "theme3Desc": "에너지 효율 극대화를 위한 친환경 공정 기술", + "programAria": "프로그램 일정", + "programTitle": "프로그램 일정", + "day1": "9월 5일 (Day 1)", + "day2": "9월 6일 (Day 2)", + "agenda1Title": "제조업의 미래: AI가 이끄는 생산성 혁명", + "agenda1Desc": "AI 글로벌 석학들이 제안하는 2030 제조 전략과 실제 구현 사례 분석", + "agenda1Room": "메인 컨퍼런스 홀", + "agenda2Title": "산업용 5G와 엣지 컴퓨팅의 결합", + "agenda2Desc": "지연 없는 데이터 전송을 통한 실시간 공정 제어 기술 시연", + "agenda2Room": "세미나실 A", + "agenda3Title": "Global Buyers Matching Day", + "agenda3Desc": "해외 유망 기업과 국내 공급사 간의 1:1 비즈니스 미팅", + "agenda3Room": "비즈니스 라운지", + "speakersTitle": "주요 연사", + "speaker1Role": "AI 연구소장 | TechCorp", + "speaker2Role": "CTO | Automatech", + "speaker3Role": "로봇공학 석좌교수 | Korea Univ.", + "speaker4Role": "Global VP | Digitron", + "profileAria": "{{name}} 프로필", + "faqAria": "자주 묻는 질문", + "faqTitle": "자주 묻는 질문", + "faq1Q": "사전등록은 어떻게 하나요?", + "faq1A": "상단 \"사전등록하기\" 버튼에서 유형 선택 후 정보를 입력하면 모바일 배지가 발급됩니다.", + "faq2Q": "입장료가 있나요?", + "faq2A": "일반 관람객은 사전등록 시 무료입니다. 현장 등록은 유료로 운영될 수 있습니다.", + "faq3Q": "주차 공간이 있나요?", + "faq3A": "킨텍스 제1·2전시장에 최대 4,000대 동시 주차가 가능합니다.", + "fabCta": "사전등록 바로가기" + }, + "register": { + "steps": { "type": "유형", "info": "정보", "interest": "관심분야", "done": "완료" }, + "stepsAria": "사전등록 단계", + "type": { + "general": "일반 관람객", + "generalDesc": "전시 및 공개 세션 참관이 가능한 일반 방문객", + "buyer": "바이어", + "buyerDesc": "B2B 매칭 및 비즈니스 상담을 위한 기업 담당자", + "vip": "VIP", + "vipDesc": "초청 인사 전용 라운지 및 전 세션 입장 귀빈" + }, + "step0Title": "참가 유형 선택", + "step0Sub": "KINTEX 전시회에 참여하실 자격을 선택해 주세요.", + "typeGroupAria": "참가 유형", + "step1Title": "기본 정보 입력", + "step1Sub": "연락 및 출입증 발급을 위한 정보를 입력해 주세요.", + "name": "이름", + "namePh": "성함을 입력하세요", + "phone": "연락처 (Mobile)", + "email": "이메일", + "emailPh": "example@domain.com", + "org": "소속 (회사/기관)", + "orgPh": "회사명 또는 학교명", + "step2Title": "관심 분야 선택", + "step2Sub": "관심 분야를 선택하시면 맞춤형 정보를 제공해 드립니다.", + "interestsAria": "관심 분야", + "interest": { + "ai": "AI 솔루션", + "factory": "스마트 팩토리", + "robotics": "로보틱스", + "bigdata": "빅데이터", + "cloud": "클라우드" + }, + "consentPrivacy": "개인정보 수집·이용 동의", + "required": "(필수)", + "consentMarketing": "마케팅 수신 동의 (선택)", + "consentMore": "상세보기", + "errPrivacy": "개인정보 수집·이용 동의(필수)에 체크해 주세요.", + "errName": "이름을 입력해 주세요.", + "errEvent": "행사 정보를 확인할 수 없습니다.", + "doneTitle": "사전등록이 접수되었습니다", + "doneSub": "{{name}}님의 사전등록이 완료되었습니다. 모바일 배지를 입구 키오스크에서 사용하세요.", + "visitorDefault": "관람객", + "badgeAria": "모바일 배지 미리보기", + "badgeName": "홍길동", + "badgeCodeLabel": "Badge Code", + "doneNote": "입구 키오스크에서 QR을 인식해 실물 배지를 출력할 수 있습니다.", + "regNo": "등록번호: {{id}}", + "submitting": "등록 중…", + "submit": "등록하기", + "eventBrowse": "행사 둘러보기", + "floorplan": "전시장 도면 보기" + }, + "floorplan": { + "sideAria": "부스 검색 및 목록", + "filtersTitle": "업종 · 존 · 키워드", + "searchPh": "참가업체 또는 부스번호 검색", + "searchAria": "부스 검색", + "typeFilter": "부스 유형 필터", + "hallSelect": "전시장 선택", + "allHalls": "전체 홀", + "listAria": "부스 목록 (비시각 대안)", + "loading": "부스 정보를 불러오는 중…", + "searchResults": "검색 결과 ({{count}})", + "noMatch": "일치하는 부스가 없습니다.", + "reservedBooth": "예약 부스", + "availableBooth": "분양 가능", + "type": { + "assembled": "조립부스", + "independent": "독립부스", + "corner": "코너부스", + "island": "아일랜드", + "default": "부스" + }, + "stageAria": "인터랙티브 홀 지도", + "boothLayoutAria": "부스 배치", + "boothAria": "부스 {{no}} {{name}}", + "loadFail": "도면을 불러오지 못했습니다.", + "noBooth": "표시할 부스가 없습니다.", + "popInfoAria": "{{name}} 정보", + "popDetail": "부스 상세", + "directions": "길찾기", + "zoomIn": "확대", + "zoomOut": "축소", + "reset": "초기화", + "myLocation": "내 위치", + "legendAria": "범례", + "legendAvailable": "판매 (Available)", + "legendReserved": "예약 (Reserved)", + "legendPublic": "공용부 (Public)" + }, + "inquiry": { + "heroAria": "참가 문의", + "heroTitle": "참가 문의", + "heroLead": "KINTEX와 함께 귀사의 비즈니스를 글로벌 시장으로 확장하세요. 전문 컨설턴트가 최적의 전시 공간을 제안해 드립니다.", + "cta": "참가 문의", + "guideAria": "참가 안내", + "guideTitle": "참가 안내", + "booth1Tier": "Entry Tier", + "booth1Title": "조립부스 (Shell Scheme)", + "booth1Desc": "기본 벽체와 바닥이 제공되는 가장 경제적인 선택. 소규모 기업에 추천합니다.", + "booth1Price": "₩ 2,500,000 / unit", + "booth2Tier": "Design Freedom", + "booth2Title": "독립부스 (Raw Space)", + "booth2Desc": "공간만 제공되어 브랜드에 맞는 자유로운 디자인 설계가 가능합니다.", + "booth2Price": "₩ 2,000,000 / unit", + "booth3Tier": "Full Solution", + "booth3Title": "프리미엄 (Premium)", + "booth3Desc": "AI 최적화 동선 설계 및 맞춤형 브랜딩 패키지가 포함된 올인원 솔루션입니다.", + "booth3Price": "별도 문의", + "boothExampleAria": "{{title}} 예시", + "formAria": "문의하기", + "formTitle": "문의하기", + "formSub": "참가 신청 및 전시 관련 궁금한 점을 남겨주세요. 담당자가 영업일 기준 24시간 이내에 답변드립니다.", + "contactPhone": "전화 문의", + "contactPhoneVal": "+82-31-810-8114", + "contactEmail": "이메일 문의", + "contactEmailVal": "exhibit@kintex.com", + "contactLoc": "위치 안내", + "contactLocVal": "경기도 고양시 일산서구 킨텍스로 217-60", + "loginHintQ": "이미 계정이 있으신가요?", + "loginHintA": "로그인하여 빠른 문의하기", + "company": "회사명", + "companyPh": "회사명을 입력하세요", + "name": "담당자", + "namePh": "성함을 입력하세요", + "contact": "연락처 (이메일 또는 전화번호)", + "contactPh": "연락 가능한 정보를 입력하세요", + "scale": "예상 규모", + "scaleSelect": "선택하세요", + "scale1": "1개 부스 (9sqm)", + "scale2": "2-4개 부스 (18-36sqm)", + "scale3": "5-9개 부스 (45-81sqm)", + "scale4": "10개 이상", + "hall": "희망 홀", + "hallSelect": "홀 선택", + "message": "문의 내용", + "messagePh": "참가 목적이나 특별 요청사항이 있다면 남겨주세요.", + "agree": "개인정보 수집 및 이용에 동의합니다.", + "required": "(필수)", + "errAgree": "개인정보 수집 및 이용에 동의해 주세요.", + "errName": "담당자 이름을 입력해 주세요.", + "errContact": "연락 가능한 이메일 또는 전화번호를 입력해 주세요.", + "errMessage": "문의 내용을 입력해 주세요.", + "success": "문의가 접수되었습니다. 접수번호 {{no}}", + "submitting": "전송 중…", + "submit": "문의 보내기" + }, + "microsite": { + "introAria": "{{brand}} 소개", + "eyebrow": "KINTEX Exhibitor", + "ctaMeeting": "미팅 예약", + "ctaBrochure": "기업 소개서", + "defaultBrand": "(주)한빛로보틱스", + "defaultTagline": "미래를 움직이는 지능형 로보틱스 솔루션", + "defaultBoothLoc": "부스 A-102 · 홀7", + "boothUndetermined": "미정", + "boothPrefix": "부스", + "boothLocLabel": "KINTEX 전시장 부스 위치", + "viewMap": "전시장 도면 보기", + "productsAria": "핵심 제품 라인업", + "productsTitle": "핵심 제품 라인업", + "productsSub": "현장에서 만나보실 대표 제품입니다.", + "product": "제품", + "product1Tag": "Industrial COBOT", + "product1Title": "HR-A1 협동 로봇", + "product1Desc": "고도의 정밀도와 안전성을 갖춘 산업용 협동 로봇. 인간과의 협업을 최우선으로 설계했습니다.", + "product2Tag": "Smart Logistics", + "product2Title": "HR-M3 자율주행 AMR", + "product2Desc": "스마트 팩토리 물류 효율을 극대화하는 자율 이동 로봇. 실시간 장애물 회피와 경로 최적화.", + "product3Tag": "AI Vision", + "product3Title": "HR-Q Vision 스캐너", + "product3Desc": "AI 딥러닝 기반 완벽한 품질 검수. 미세 결함도 놓치지 않는 초정밀 3D 스캐닝.", + "productImgAria": "{{title}} 이미지", + "previewAria": "부스 미리보기", + "previewTitle": "부스 미리보기", + "previewSub": "AI가 생성한 예상 부스 이미지입니다. 실제 시공 결과와 다를 수 있습니다.", + "galleryMain": "메인 전시 존", + "galleryVip": "VIP 라운지", + "galleryAria": "{{label}} AI 생성 예상 이미지", + "watermark": "AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음", + "meetingAria": "미팅 예약", + "ctaBanner": "귀사의 비즈니스에 맞는 맞춤형 로봇 솔루션을 제안해 드립니다.", + "ctaBannerBtn": "지금 미팅 예약하기" + }, + "ticket": { + "viewPurchase": "입장권 예매", + "viewManage": "예매 확인 · 취소", + "viewSwitchAria": "티켓 화면 전환", + "type": { + "general": "일반권", + "generalDesc": "일반 관람객 및 개인 참가자", + "generalPrice": "₩0 (사전등록 할인가)", + "buyer": "바이어권", + "buyerDesc": "유관 기업 종사자 및 바이어 전용", + "buyerPrice": "무료 · 자격심사 필요", + "group": "단체권 (10매 이상)", + "groupDesc": "기업 단체 관람 전용", + "groupPrice": "₩9,000 / 1인", + "vip": "VIP권", + "vipDesc": "전시회 올패스 + VIP 라운지 이용권", + "vipPrice": "₩50,000" + }, + "steps": { "s1": "행사·권종", "s2": "예매자", "s3": "결제", "s4": "완료" }, + "stepsAria": "예매 단계", + "bannerTitle": "스마트팩토리 코리아 2026", + "bannerDate": "2026.09.05 – 09.08", + "bannerPlace": "제2전시장 홀7", + "posterAria": "행사 포스터", + "loginBanner": "로그인하면 예매 내역이 자동 저장됩니다.", + "loginLink": "로그인하기", + "sample": "샘플 데이터", + "h2Type": "티켓 권종 선택", + "aiOptimized": "AI OPTIMIZED", + "off10": "10% OFF", + "soldout": "매진", + "qtyAria": "{{name}} 수량", + "decrease": "감소", + "increase": "증가", + "h2Booker": "예매자 정보", + "bookerName": "이름", + "bookerNamePh": "성함", + "bookerTel": "연락처", + "bookerEmail": "이메일 (티켓 발송)", + "consentPrivacy": "개인정보 수집·이용 동의", + "required": "(필수)", + "consentMarketing": "마케팅 수신 동의 (선택)", + "h2Pay": "결제 수단", + "payCard": "신용카드", + "payEasy": "간편결제", + "payBank": "계좌이체", + "payGroupAria": "결제 수단", + "pgNote": "결제는 PG사 보안 페이지에서 진행되며, 카드정보는 본 사이트에 저장되지 않습니다. 모든 거래는 256비트 SSL 암호화로 보호됩니다. (PG 미연동 — 데모 플로우)", + "doneTitle": "예매가 접수되었습니다", + "doneNo": "KTX-2026-0000-DEMO", + "doneSub": "이메일로 티켓을 보내드립니다. (실서비스 연동 시) 총 {{count}}매 · 결제 {{total}}", + "sampleReady": "샘플 데이터 · 결제 준비 중", + "walletBtn": "모바일 티켓 지갑 열기", + "manageBtn": "예매 확인 / 취소", + "summaryAria": "예매 내역 요약", + "summaryTitle": "예매 내역 요약", + "summaryCount": "선택 매수", + "count": "{{n}}매", + "subtotal": "소계", + "discount": "할인", + "finalTotal": "최종 결제 금액", + "nextStep": "다음 단계로 이동", + "freeDone": "무료 예매 완료", + "pay": "결제하기", + "payProceed": "결제 진행", + "customerCenter": "고객센터 1588-0000 (평일 09–18시)", + "manageTitle": "예매 확인 · 취소", + "manageSub": "비회원 예매 내역을 조회하고 취소하실 수 있습니다.", + "lookNo": "예매번호", + "lookNoPh": "KTX-YYYY-XXXXXX", + "lookEmail": "이메일", + "lookEmailPh": "example@email.com", + "lookBtn": "조회하기", + "loginToView": "로그인하여 내 예매 보기", + "notLooked": "예매번호와 이메일을 입력해 조회하세요. (샘플: 아무 값이나 입력 후 조회)", + "booked": "예매완료", + "bookNoLabel": "예매번호", + "paidAt": "결제일시", + "colType": "권종", + "colQty": "수량", + "colAmount": "금액", + "bookerInfo": "예매자 정보", + "totalPaid": "총 결제 금액", + "refundTitle": "취소·환불 정책", + "refundD7": "방문 7일 전 (D-7)", + "refundD7Val": "100% 환불", + "refundD3": "방문 3일 전 (D-3)", + "refundD3Val": "50% 환불", + "refundDay": "방문 당일", + "refundDayVal": "0% (환불불가)", + "refundEst": "현재 예상 환불액", + "cancelBooking": "예매 취소", + "refundNote": "환불은 PG사를 통해 원결제수단으로 처리됩니다.", + "aiHelper": "AI 정산 도우미", + "aiHelperDesc": "신용카드 할부 결제 건은 취소 시 카드사를 통해 한도 복구까지 약 3–5영업일이 소요될 수 있습니다.", + "cancelModalAria": "예매 취소 확인", + "cancelModalTitle": "예매를 취소하시겠습니까?", + "cancelModalDesc": "D-15 기준 100% 환불(₩40,000)이 적용됩니다. 환불은 원결제수단으로 PG사를 통해 처리되며, 실서비스 연동 시 실제 환불이 진행됩니다.", + "cancelConfirm": "취소하기" + }, + "login": { + "title": "로그인", + "sub": "KINTEX 전시 관리 시스템에 오신 것을 환영합니다.", + "email": "이메일", + "emailPh": "name@company.com", + "password": "비밀번호", + "remember": "아이디 기억", + "forgot": "비밀번호 찾기", + "errResponse": "로그인 응답을 처리하지 못했습니다. 다시 시도해 주세요.", + "errLocked": "로그인 시도가 일시적으로 제한되었습니다. 잠시 후 다시 시도해 주세요.", + "errNotImpl": "로그인 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.", + "errGeneric": "로그인 중 오류가 발생했습니다.", + "submitting": "로그인 중…", + "submit": "로그인", + "signupPrompt": "아직 계정이 없으신가요?", + "signup": "회원가입", + "or": "또는", + "internalLogin": "킨텍스 내부 계정으로 로그인", + "inviteLink": "초대 코드로 참여하기 →", + "copyright": "© 2026 KINTEX. All rights reserved.", + "wsTitle": "행사 워크스페이스", + "wsSub": "참여 중인 행사를 선택하세요.", + "wsEmptyTitle": "참여 중인 행사가 없습니다", + "wsEmptyDesc": "초대 링크를 확인하거나 초대 코드로 참여하세요.", + "wsInvite": "초대 코드로 참여하기" + }, + "signup": { + "title": "회원가입", + "desc": "KINTEX AI 전시관리 계정을 만듭니다. 회사명·초대코드는 선택 사항입니다.", + "email": "이메일", + "emailPh": "name@company.com", + "name": "이름", + "namePh": "홍길동", + "password": "비밀번호", + "passwordPh": "8자 이상", + "password2": "비밀번호 확인", + "password2Ph": "비밀번호 재입력", + "company": "회사명 (선택)", + "companyPh": "(주)킨텍스", + "invite": "초대 코드 (선택)", + "invitePh": "INV-XXXX", + "mismatch": "비밀번호가 일치하지 않습니다.", + "errNotImpl": "회원가입 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.", + "errConflict": "이미 가입된 이메일입니다.", + "errGeneric": "회원가입 중 오류가 발생했습니다.", + "noticePending": "계정이 생성되었습니다. 관리자 승인 후 로그인할 수 있습니다.", + "noticeActive": "계정이 생성되었습니다. 이제 로그인해 주세요.", + "doneTitle": "회원가입 완료", + "goLogin": "로그인하러 가기", + "cancel": "취소", + "submitting": "가입 중…", + "submit": "가입하기" + }, + "forgot": { + "requestTitle": "비밀번호 찾기", + "requestDesc": "가입한 이메일로 인증코드를 보내드립니다. 등록된 계정인 경우 메일이 발송됩니다.", + "email": "이메일", + "emailPh": "name@company.com", + "sending": "전송 중…", + "requestBtn": "인증코드 받기", + "resetTitle": "비밀번호 재설정", + "resetDesc": "<0>{{email}} 로 받은 인증코드와 새 비밀번호를 입력하세요.", + "code": "인증코드", + "codePh": "메일로 받은 코드", + "newpw": "새 비밀번호", + "newpwPh": "8자 이상", + "prev": "이전", + "changing": "변경 중…", + "changeBtn": "비밀번호 변경", + "doneTitle": "비밀번호 변경 완료", + "doneDesc": "새 비밀번호로 로그인해 주세요.", + "goLogin": "로그인하러 가기", + "errInvalid": "인증코드가 올바르지 않거나 만료되었습니다.", + "errNotImpl": "비밀번호 재설정 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.", + "errRequest": "요청 중 오류가 발생했습니다.", + "errReset": "재설정 중 오류가 발생했습니다.", + "cancel": "취소" + } +} diff --git a/src/frontend/src/i18n/locales/zh.json b/src/frontend/src/i18n/locales/zh.json new file mode 100644 index 0000000..c43f6d7 --- /dev/null +++ b/src/frontend/src/i18n/locales/zh.json @@ -0,0 +1,521 @@ +{ + "common": { + "brand": "KINTEX", + "appSuffix": "AI 展会管理", + "actions": { + "prev": "上一步", + "next": "下一步", + "cancel": "取消", + "close": "关闭", + "home": "返回首页", + "viewMore": "查看详情", + "confirm": "确认" + }, + "states": { + "loading": "加载中…", + "empty": "暂无内容。" + } + }, + "lang": { + "select": "选择语言" + }, + "shell": { + "nav": { + "events": "展会", + "exhibit": "参展指南", + "visit": "参观指南", + "transport": "交通" + }, + "searchPlaceholder": "搜索展会", + "cta": { "register": "观众预登记" }, + "menuOpen": "打开菜单", + "menuClose": "关闭菜单", + "homeAria": "{{brand}} 首页", + "primaryMenu": "主菜单", + "mobileMenu": "移动菜单", + "footer": { + "brandDesc": "韩国最大规模的展览会议中心,连接商业与文化的创新。", + "quickTitle": "快速链接", + "quickSchedule": "展会日程", + "quickExhibit": "参展指南", + "quickDirections": "交通路线", + "quickSitemap": "网站地图", + "supportTitle": "客户支持", + "supportPrivacy": "隐私政策", + "supportTerms": "使用条款", + "supportContact": "联系我们", + "contactTitle": "联系方式", + "contactAddress": "京畿道高阳市一山西区KINTEX路217-60", + "contactTel": "总机 1899-1001", + "contactEmail": "exhibition@kintex.com", + "bottom": "© KINTEX. 版权所有。(10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si" + } + }, + "home": { + "heroAria": "关于 KINTEX", + "eyebrow": "全球展览平台", + "heroTitleLine1": "连接世界的展会,", + "heroTitleEm": "KINTEX", + "heroLead": "在韩国最大规模的展览会议中心 KINTEX,体验商业与文化的全新创新。", + "ctaRegister": "观众预登记", + "ctaFacility": "查看设施指南", + "eventsAria": "正在进行及即将举办的展会", + "eventsTitle": "正在进行 · 即将举办", + "eventsSub": "了解 KINTEX 当前的重要资讯。", + "eventsMore": "查看全部日程", + "loading": "正在加载展会信息…", + "empty": "当前没有正在进行或即将举办的展会。", + "tagExhibition": "EXHIBITION", + "booth": "{{count}} 个展位", + "aiOptimized": "AI 优化", + "ongoing": "进行中", + "ended": "已结束", + "dday": "倒计时 {{n}} 天", + "ddayZero": "今日开幕", + "register": "预登记", + "hallTbd": "展厅待分配", + "transportAria": "交通路线", + "transportTitle": "交通路线", + "transportSub": "以更便捷的交通抵达 KINTEX。", + "gtxTitle": "GTX-A KINTEX 站正式开通", + "gtxDesc": "距 KINTEX 站步行3分钟", + "transit": "公共交通", + "transitDesc": "城际巴士及地铁", + "parking": "停车指南", + "parkingDesc": "可同时停放4,000辆", + "shuttle": "接驳巴士", + "shuttleDesc": "主要枢纽循环运行", + "mapAria": "KINTEX 位置地图", + "mapAddress": "高阳市一山西区KINTEX路217-60", + "mapPlace": "KINTEX 第1、2展馆", + "mapViewAria": "查看地图" + }, + "detail": { + "tabs": { + "intro": "介绍", + "program": "议程", + "speakers": "演讲嘉宾", + "exhibitors": "参展商", + "transport": "交通", + "faq": "常见问题" + }, + "eyebrow": "KINTEX Exhibition", + "loadingTitle": "正在加载展会信息…", + "fallbackTitle": "展会详情", + "dateTbd": "日期确认中", + "placeTbd": "展厅待分配", + "lead": "查看 KINTEX 举办的展会信息并进行预登记。", + "factDate": "日期", + "factPlace": "地点", + "ctaRegister": "立即预登记", + "ctaFloorplan": "查看展馆平面图", + "navAria": "展会详情导航", + "shareAria": "分享", + "share": "分享", + "copyLink": "复制链接", + "overviewAria": "展会概览", + "overviewTitle": "展会概览", + "overviewLead": "智能工厂韩国 2026 是新一代制造技术的集大成。在同一场地体验结合 AI、机器人、物联网和数字孪生技术的最新解决方案,全球500余家领先企业参展。", + "statBooth": "参展展位", + "statVisitors": "预计参观人数", + "statVisitorsValue": "30,000+", + "themesTitle": "主要展示主题", + "theme1Title": "AI 与自主机器人", + "theme1Desc": "能够自主学习与判断的智能机器人解决方案", + "theme2Title": "数字孪生与物联网", + "theme2Desc": "虚拟世界与现实制造现场的实时同步", + "theme3Title": "碳中和智能制造", + "theme3Desc": "最大化能源效率的环保工艺技术", + "programAria": "议程安排", + "programTitle": "议程安排", + "day1": "9月5日 (Day 1)", + "day2": "9月6日 (Day 2)", + "agenda1Title": "制造业的未来:AI 引领的生产力革命", + "agenda1Desc": "全球 AI 学者提出的2030制造战略与实际实施案例分析", + "agenda1Room": "主会议厅", + "agenda2Title": "工业5G与边缘计算的结合", + "agenda2Desc": "通过无延迟数据传输实现实时工艺控制技术演示", + "agenda2Room": "研讨室 A", + "agenda3Title": "全球采购商配对日", + "agenda3Desc": "海外优质企业与国内供应商之间的一对一商务洽谈", + "agenda3Room": "商务休息室", + "speakersTitle": "主要演讲嘉宾", + "speaker1Role": "AI 研究所所长 | TechCorp", + "speaker2Role": "CTO | Automatech", + "speaker3Role": "机器人工程学讲席教授 | Korea Univ.", + "speaker4Role": "Global VP | Digitron", + "profileAria": "{{name}} 简介", + "faqAria": "常见问题", + "faqTitle": "常见问题", + "faq1Q": "如何进行预登记?", + "faq1A": "点击顶部\"立即预登记\"按钮,选择类型并填写信息后,即可获得移动徽章。", + "faq2Q": "是否收取门票?", + "faq2A": "普通观众预登记后免费。现场登记可能收费。", + "faq3Q": "是否提供停车位?", + "faq3A": "KINTEX 第1、2展馆最多可同时停放4,000辆车。", + "fabCta": "前往预登记" + }, + "register": { + "steps": { "type": "类型", "info": "信息", "interest": "兴趣领域", "done": "完成" }, + "stepsAria": "预登记步骤", + "type": { + "general": "普通观众", + "generalDesc": "可参观展览及公开会议的普通访客", + "buyer": "采购商", + "buyerDesc": "用于B2B配对及商务洽谈的企业负责人", + "vip": "VIP", + "vipDesc": "享有专属贵宾休息室及全场次入场的受邀嘉宾" + }, + "step0Title": "选择参加类型", + "step0Sub": "请选择您参加 KINTEX 展会的资格。", + "typeGroupAria": "参加类型", + "step1Title": "填写基本信息", + "step1Sub": "请填写用于联系及发放通行证的信息。", + "name": "姓名", + "namePh": "请输入姓名", + "phone": "联系电话 (手机)", + "email": "电子邮箱", + "emailPh": "example@domain.com", + "org": "所属 (公司/机构)", + "orgPh": "公司或学校名称", + "step2Title": "选择兴趣领域", + "step2Sub": "选择您的兴趣领域,我们将为您提供定制化信息。", + "interestsAria": "兴趣领域", + "interest": { + "ai": "AI 解决方案", + "factory": "智能工厂", + "robotics": "机器人", + "bigdata": "大数据", + "cloud": "云计算" + }, + "consentPrivacy": "同意收集及使用个人信息", + "required": "(必填)", + "consentMarketing": "同意接收营销信息 (选填)", + "consentMore": "查看详情", + "errPrivacy": "请勾选同意收集及使用个人信息 (必填)。", + "errName": "请输入姓名。", + "errEvent": "无法确认展会信息。", + "doneTitle": "预登记已受理", + "doneSub": "{{name}},您的预登记已完成。请在入口自助终端使用移动徽章。", + "visitorDefault": "观众", + "badgeAria": "移动徽章预览", + "badgeName": "张三", + "badgeCodeLabel": "Badge Code", + "doneNote": "在入口自助终端扫描二维码即可打印实体徽章。", + "regNo": "登记编号:{{id}}", + "submitting": "登记中…", + "submit": "立即登记", + "eventBrowse": "浏览展会", + "floorplan": "查看展馆平面图" + }, + "floorplan": { + "sideAria": "展位搜索及列表", + "filtersTitle": "行业 · 区域 · 关键词", + "searchPh": "搜索参展商或展位号", + "searchAria": "搜索展位", + "typeFilter": "展位类型筛选", + "hallSelect": "选择展馆", + "allHalls": "全部展厅", + "listAria": "展位列表 (非可视替代)", + "loading": "正在加载展位信息…", + "searchResults": "搜索结果 ({{count}})", + "noMatch": "没有匹配的展位。", + "reservedBooth": "已预订展位", + "availableBooth": "可预订", + "type": { + "assembled": "标准展位", + "independent": "光地展位", + "corner": "角落展位", + "island": "岛型展位", + "default": "展位" + }, + "stageAria": "交互式展厅地图", + "boothLayoutAria": "展位布局", + "boothAria": "展位 {{no}} {{name}}", + "loadFail": "无法加载平面图。", + "noBooth": "没有可显示的展位。", + "popInfoAria": "{{name}} 信息", + "popDetail": "展位详情", + "directions": "路线导航", + "zoomIn": "放大", + "zoomOut": "缩小", + "reset": "重置", + "myLocation": "我的位置", + "legendAria": "图例", + "legendAvailable": "可售 (Available)", + "legendReserved": "已订 (Reserved)", + "legendPublic": "公共区 (Public)" + }, + "inquiry": { + "heroAria": "参展咨询", + "heroTitle": "参展咨询", + "heroLead": "与 KINTEX 一起将贵公司的业务拓展至全球市场。专业顾问将为您提供最佳展位方案。", + "cta": "参展咨询", + "guideAria": "参展指南", + "guideTitle": "参展指南", + "booth1Tier": "Entry Tier", + "booth1Title": "标准展位 (Shell Scheme)", + "booth1Desc": "提供基本墙体和地板的最经济选择。推荐给中小型企业。", + "booth1Price": "₩ 2,500,000 / 单位", + "booth2Tier": "Design Freedom", + "booth2Title": "光地展位 (Raw Space)", + "booth2Desc": "仅提供空间,可根据品牌进行自由设计。", + "booth2Price": "₩ 2,000,000 / 单位", + "booth3Tier": "Full Solution", + "booth3Title": "高级 (Premium)", + "booth3Desc": "包含 AI 优化动线设计及定制品牌套餐的一站式解决方案。", + "booth3Price": "另行咨询", + "boothExampleAria": "{{title}} 示例", + "formAria": "咨询", + "formTitle": "咨询", + "formSub": "请留下有关参展申请及展会的疑问。负责人将在工作日24小时内回复。", + "contactPhone": "电话咨询", + "contactPhoneVal": "+82-31-810-8114", + "contactEmail": "邮件咨询", + "contactEmailVal": "exhibit@kintex.com", + "contactLoc": "位置指南", + "contactLocVal": "京畿道高阳市一山西区KINTEX路217-60", + "loginHintQ": "已有账户?", + "loginHintA": "登录以快速咨询", + "company": "公司名称", + "companyPh": "请输入公司名称", + "name": "负责人", + "namePh": "请输入姓名", + "contact": "联系方式 (邮箱或电话)", + "contactPh": "请输入可联系的信息", + "scale": "预计规模", + "scaleSelect": "请选择", + "scale1": "1个展位 (9sqm)", + "scale2": "2-4个展位 (18-36sqm)", + "scale3": "5-9个展位 (45-81sqm)", + "scale4": "10个以上", + "hall": "意向展厅", + "hallSelect": "选择展厅", + "message": "咨询内容", + "messagePh": "如有参展目的或特殊要求,请留言。", + "agree": "我同意收集及使用个人信息。", + "required": "(必填)", + "errAgree": "请同意收集及使用个人信息。", + "errName": "请输入负责人姓名。", + "errContact": "请输入可联系的邮箱或电话号码。", + "errMessage": "请输入咨询内容。", + "success": "咨询已受理。受理编号 {{no}}", + "submitting": "发送中…", + "submit": "发送咨询" + }, + "microsite": { + "introAria": "关于 {{brand}}", + "eyebrow": "KINTEX Exhibitor", + "ctaMeeting": "预约会议", + "ctaBrochure": "企业介绍手册", + "defaultBrand": "翰光机器人有限公司", + "defaultTagline": "驱动未来的智能机器人解决方案", + "defaultBoothLoc": "展位 A-102 · 7号厅", + "boothUndetermined": "待定", + "boothPrefix": "展位", + "boothLocLabel": "KINTEX 展馆展位位置", + "viewMap": "查看展馆平面图", + "productsAria": "核心产品线", + "productsTitle": "核心产品线", + "productsSub": "您可在现场见到的代表性产品。", + "product": "产品", + "product1Tag": "Industrial COBOT", + "product1Title": "HR-A1 协作机器人", + "product1Desc": "兼具高精度与安全性的工业协作机器人。以人机协作为最优先设计。", + "product2Tag": "Smart Logistics", + "product2Title": "HR-M3 自动驾驶 AMR", + "product2Desc": "最大化智能工厂物流效率的自主移动机器人。实时避障与路径优化。", + "product3Tag": "AI Vision", + "product3Title": "HR-Q Vision 扫描仪", + "product3Desc": "基于 AI 深度学习的完美质量检测。不放过任何微小缺陷的超精密3D扫描。", + "productImgAria": "{{title}} 图片", + "previewAria": "展位预览", + "previewTitle": "展位预览", + "previewSub": "由 AI 生成的预期展位图像。实际施工结果可能有所不同。", + "galleryMain": "主展示区", + "galleryVip": "VIP 休息室", + "galleryAria": "{{label}} AI 生成的预期图像", + "watermark": "AI 生成的预期图像 — 实际施工结果可能不同", + "meetingAria": "预约会议", + "ctaBanner": "我们将为您的业务提供量身定制的机器人解决方案。", + "ctaBannerBtn": "立即预约会议" + }, + "ticket": { + "viewPurchase": "购票", + "viewManage": "查询 · 取消", + "viewSwitchAria": "切换票务视图", + "type": { + "general": "普通票", + "generalDesc": "普通观众及个人参加者", + "generalPrice": "₩0 (预登记优惠价)", + "buyer": "采购商票", + "buyerDesc": "仅限相关企业从业者及采购商", + "buyerPrice": "免费 · 需资格审核", + "group": "团体票 (10张以上)", + "groupDesc": "仅限企业团体参观", + "groupPrice": "₩9,000 / 人", + "vip": "VIP 票", + "vipDesc": "展会通票 + VIP 休息室使用权", + "vipPrice": "₩50,000" + }, + "steps": { "s1": "展会·票种", "s2": "购票人", "s3": "支付", "s4": "完成" }, + "stepsAria": "预订步骤", + "bannerTitle": "智能工厂韩国 2026", + "bannerDate": "2026.09.05 – 09.08", + "bannerPlace": "第2展馆 7号厅", + "posterAria": "展会海报", + "loginBanner": "登录后将自动保存预订记录。", + "loginLink": "登录", + "sample": "示例数据", + "h2Type": "选择票种", + "aiOptimized": "AI OPTIMIZED", + "off10": "10% OFF", + "soldout": "售罄", + "qtyAria": "{{name}} 数量", + "decrease": "减少", + "increase": "增加", + "h2Booker": "购票人信息", + "bookerName": "姓名", + "bookerNamePh": "姓名", + "bookerTel": "联系电话", + "bookerEmail": "邮箱 (票据发送)", + "consentPrivacy": "同意收集及使用个人信息", + "required": "(必填)", + "consentMarketing": "同意接收营销信息 (选填)", + "h2Pay": "支付方式", + "payCard": "信用卡", + "payEasy": "快捷支付", + "payBank": "银行转账", + "payGroupAria": "支付方式", + "pgNote": "支付在 PG 公司的安全页面进行,卡片信息不会存储在本网站。所有交易均受256位SSL加密保护。(PG 未接入 — 演示流程)", + "doneTitle": "预订已受理", + "doneNo": "KTX-2026-0000-DEMO", + "doneSub": "我们将通过邮件发送票据。(正式服务接入时) 共 {{count}} 张 · 支付 {{total}}", + "sampleReady": "示例数据 · 支付准备中", + "walletBtn": "打开移动票夹", + "manageBtn": "查询 / 取消预订", + "summaryAria": "预订明细摘要", + "summaryTitle": "预订明细摘要", + "summaryCount": "所选张数", + "count": "{{n}} 张", + "subtotal": "小计", + "discount": "优惠", + "finalTotal": "最终支付金额", + "nextStep": "进入下一步", + "freeDone": "完成免费预订", + "pay": "支付", + "payProceed": "继续支付", + "customerCenter": "客服中心 1588-0000 (工作日 09–18时)", + "manageTitle": "查询 · 取消预订", + "manageSub": "您可以查询并取消非会员预订记录。", + "lookNo": "预订编号", + "lookNoPh": "KTX-YYYY-XXXXXX", + "lookEmail": "邮箱", + "lookEmailPh": "example@email.com", + "lookBtn": "查询", + "loginToView": "登录查看我的预订", + "notLooked": "请输入预订编号和邮箱进行查询。(示例:输入任意值后查询)", + "booked": "预订完成", + "bookNoLabel": "预订编号", + "paidAt": "支付时间", + "colType": "票种", + "colQty": "数量", + "colAmount": "金额", + "bookerInfo": "购票人信息", + "totalPaid": "总支付金额", + "refundTitle": "取消·退款政策", + "refundD7": "参观前7天 (D-7)", + "refundD7Val": "100% 退款", + "refundD3": "参观前3天 (D-3)", + "refundD3Val": "50% 退款", + "refundDay": "参观当天", + "refundDayVal": "0% (不可退款)", + "refundEst": "当前预计退款额", + "cancelBooking": "取消预订", + "refundNote": "退款将通过 PG 公司按原支付方式处理。", + "aiHelper": "AI 结算助手", + "aiHelperDesc": "信用卡分期付款的订单,取消后通过卡公司恢复额度约需3–5个工作日。", + "cancelModalAria": "确认取消预订", + "cancelModalTitle": "确定取消此预订吗?", + "cancelModalDesc": "按 D-15 标准适用100%退款(₩40,000)。退款将通过 PG 公司按原支付方式处理,正式服务接入时进行实际退款。", + "cancelConfirm": "取消" + }, + "login": { + "title": "登录", + "sub": "欢迎使用 KINTEX 展会管理系统。", + "email": "电子邮箱", + "emailPh": "name@company.com", + "password": "密码", + "remember": "记住账号", + "forgot": "找回密码", + "errResponse": "无法处理登录响应。请重试。", + "errLocked": "登录尝试暂时受限。请稍后重试。", + "errNotImpl": "登录服务正在准备中。请稍后重试。", + "errGeneric": "登录时发生错误。", + "submitting": "登录中…", + "submit": "登录", + "signupPrompt": "还没有账户?", + "signup": "注册", + "or": "或", + "internalLogin": "使用 KINTEX 内部账户登录", + "inviteLink": "使用邀请码加入 →", + "copyright": "© 2026 KINTEX. 版权所有。", + "wsTitle": "展会工作区", + "wsSub": "请选择您正在参与的展会。", + "wsEmptyTitle": "尚未加入任何展会", + "wsEmptyDesc": "请确认邀请链接或使用邀请码加入。", + "wsInvite": "使用邀请码加入" + }, + "signup": { + "title": "注册", + "desc": "创建 KINTEX AI 展会管理账户。公司名称及邀请码为选填项。", + "email": "电子邮箱", + "emailPh": "name@company.com", + "name": "姓名", + "namePh": "张三", + "password": "密码", + "passwordPh": "8位以上", + "password2": "确认密码", + "password2Ph": "再次输入密码", + "company": "公司名称 (选填)", + "companyPh": "KINTEX 有限公司", + "invite": "邀请码 (选填)", + "invitePh": "INV-XXXX", + "mismatch": "两次密码不一致。", + "errNotImpl": "注册服务正在准备中。请稍后重试。", + "errConflict": "该邮箱已注册。", + "errGeneric": "注册时发生错误。", + "noticePending": "账户已创建。管理员审核后即可登录。", + "noticeActive": "账户已创建。现在请登录。", + "doneTitle": "注册完成", + "goLogin": "前往登录", + "cancel": "取消", + "submitting": "注册中…", + "submit": "注册" + }, + "forgot": { + "requestTitle": "找回密码", + "requestDesc": "我们将向您注册的邮箱发送验证码。若账户存在,将发送邮件。", + "email": "电子邮箱", + "emailPh": "name@company.com", + "sending": "发送中…", + "requestBtn": "获取验证码", + "resetTitle": "重置密码", + "resetDesc": "请输入发送至 <0>{{email}} 的验证码及新密码。", + "code": "验证码", + "codePh": "邮件收到的验证码", + "newpw": "新密码", + "newpwPh": "8位以上", + "prev": "上一步", + "changing": "更改中…", + "changeBtn": "更改密码", + "doneTitle": "密码更改完成", + "doneDesc": "请使用新密码登录。", + "goLogin": "前往登录", + "errInvalid": "验证码不正确或已过期。", + "errNotImpl": "密码重置服务正在准备中。请稍后重试。", + "errRequest": "请求时发生错误。", + "errReset": "重置时发生错误。", + "cancel": "取消" + } +} diff --git a/src/frontend/src/main.tsx b/src/frontend/src/main.tsx index 8e32fcd..288c253 100644 --- a/src/frontend/src/main.tsx +++ b/src/frontend/src/main.tsx @@ -3,6 +3,8 @@ import { createRoot } from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BrowserRouter } from 'react-router-dom'; import { App } from './App'; +import './store/themeStore'; // 진입 즉시 data-theme 스탬프(셸 밖 화면 포함) +import './i18n'; // i18next 초기화(다국어 ko/en/zh/ja) import './styles/global.css'; const queryClient = new QueryClient({ diff --git a/src/frontend/src/screens/login/ForgotPasswordDialog.tsx b/src/frontend/src/screens/login/ForgotPasswordDialog.tsx index 873c669..c4fd791 100644 --- a/src/frontend/src/screens/login/ForgotPasswordDialog.tsx +++ b/src/frontend/src/screens/login/ForgotPasswordDialog.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useTranslation, Trans } from 'react-i18next'; import { authApi } from '../../api/endpoints'; import { ApiRequestError } from '../../api/client'; import { Button } from '../../components/ui/Button'; @@ -22,6 +23,7 @@ export function ForgotPasswordDialog({ onDone: (email: string) => void; defaultEmail?: string; }) { + const { t } = useTranslation(); const [step, setStep] = useState('request'); const [email, setEmail] = useState(defaultEmail); const [code, setCode] = useState(''); @@ -33,7 +35,7 @@ export function ForgotPasswordDialog({ function degradedMessage(err: ApiRequestError): string | null { if (err.code === 'NOT_IMPLEMENTED' || err.httpStatus === 404) { - return '비밀번호 재설정 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.'; + return t('forgot.errNotImpl'); } return null; } @@ -49,7 +51,7 @@ export function ForgotPasswordDialog({ if (err instanceof ApiRequestError) { setError(degradedMessage(err) ?? err.message); } else { - setError('요청 중 오류가 발생했습니다.'); + setError(t('forgot.errRequest')); } } finally { setSubmitting(false); @@ -70,12 +72,12 @@ export function ForgotPasswordDialog({ } catch (err) { if (err instanceof ApiRequestError) { if (err.code === 'OTP_INVALID' || err.code === 'VALIDATION') { - setError('인증코드가 올바르지 않거나 만료되었습니다.'); + setError(t('forgot.errInvalid')); } else { setError(degradedMessage(err) ?? err.message); } } else { - setError('재설정 중 오류가 발생했습니다.'); + setError(t('forgot.errReset')); } } finally { setSubmitting(false); @@ -91,29 +93,27 @@ export function ForgotPasswordDialog({ -

비밀번호 변경 완료

-

새 비밀번호로 로그인해 주세요.

+

{t('forgot.doneTitle')}

+

{t('forgot.doneDesc')}

- +
) : step === 'request' ? (

- 비밀번호 찾기 + {t('forgot.requestTitle')}

-

- 가입한 이메일로 인증코드를 보내드립니다. 등록된 계정인 경우 메일이 발송됩니다. -

+

{t('forgot.requestDesc')}

- + setEmail(e.target.value)} - placeholder="name@company.com" + placeholder={t('forgot.emailPh')} required />
@@ -124,42 +124,42 @@ export function ForgotPasswordDialog({ )}
) : (

- 비밀번호 재설정 + {t('forgot.resetTitle')}

- {email} 로 받은 인증코드와 새 비밀번호를 입력하세요. + ]} />

- + setCode(e.target.value)} - placeholder="메일로 받은 코드" + placeholder={t('forgot.codePh')} required />
- + setNewPassword(e.target.value)} - placeholder="8자 이상" + placeholder={t('forgot.newpwPh')} aria-describedby="fp-pw-hint" required /> @@ -179,10 +179,10 @@ export function ForgotPasswordDialog({ )}
diff --git a/src/frontend/src/screens/login/LoginPage.tsx b/src/frontend/src/screens/login/LoginPage.tsx index 91c3bb3..1fb6b9a 100644 --- a/src/frontend/src/screens/login/LoginPage.tsx +++ b/src/frontend/src/screens/login/LoginPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { authApi } from '../../api/endpoints'; import { ApiRequestError } from '../../api/client'; @@ -6,6 +7,7 @@ import { useAuthStore } from '../../store/authStore'; import { Button } from '../../components/ui/Button'; import { DdayChip, RoleBadge } from '../../components/ui/Badge'; import { EmptyState } from '../../components/ui/States'; +import { LanguageSwitcher } from '../../components/ui/LanguageSwitcher'; import { InviteDialog } from './InviteDialog'; import { SignupDialog } from './SignupDialog'; import { ForgotPasswordDialog } from './ForgotPasswordDialog'; @@ -21,6 +23,7 @@ type OtpMode = 'verify' | 'enroll'; const REMEMBER_KEY = 'kintex_remember_email'; export function LoginPage() { + const { t } = useTranslation(); const navigate = useNavigate(); const applyLogin = useAuthStore((s) => s.applyLogin); const selectEvent = useAuthStore((s) => s.selectEvent); @@ -73,20 +76,20 @@ export function LoginPage() { }); setPhase('otp'); } else { - setError('로그인 응답을 처리하지 못했습니다. 다시 시도해 주세요.'); + setError(t('login.errResponse')); } } catch (err) { if (err instanceof ApiRequestError) { // 잠금·미구현·자격오류 등 코드별 안내(계정 존재 여부 미노출). if (err.code === 'ACCOUNT_LOCKED') { - setError('로그인 시도가 일시적으로 제한되었습니다. 잠시 후 다시 시도해 주세요.'); + setError(t('login.errLocked')); } else if (err.code === 'NOT_IMPLEMENTED') { - setError('로그인 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.'); + setError(t('login.errNotImpl')); } else { setError(err.message); } } else { - setError('로그인 중 오류가 발생했습니다.'); + setError(t('login.errGeneric')); } } finally { setSubmitting(false); @@ -115,7 +118,7 @@ export function LoginPage() { setPhase('login'); }} /> -

© 2026 KINTEX. All rights reserved.

+

{t('login.copyright')}

); @@ -126,15 +129,15 @@ export function LoginPage() {
-

행사 워크스페이스

-

참여 중인 행사를 선택하세요.

+

{t('login.wsTitle')}

+

{t('login.wsSub')}

{workspaces.length === 0 ? ( setInviteOpen(true)}>초대 코드로 참여하기} + title={t('login.wsEmptyTitle')} + description={t('login.wsEmptyDesc')} + action={} /> ) : ( <> @@ -160,7 +163,7 @@ export function LoginPage() {
@@ -179,6 +182,9 @@ export function LoginPage() { {/* 우측 45% 로그인 카드 */}
+
+ +
KINTEX - AI 전시관리 + {t('common.appSuffix')}
-

로그인

-

KINTEX 전시 관리 시스템에 오신 것을 환영합니다.

+

{t('login.title')}

+

{t('login.sub')}

- + setEmail(e.target.value)} required />
- + setRemember(e.target.checked)} /> - 아이디 기억 + {t('login.remember')}
@@ -243,27 +249,27 @@ export function LoginPage() { )}

- 아직 계정이 없으신가요?{' '} + {t('login.signupPrompt')}{' '}

- 또는 + {t('login.or')}
-

© 2026 KINTEX. All rights reserved.

+

{t('login.copyright')}

{inviteOpen && setInviteOpen(false)} />} diff --git a/src/frontend/src/screens/login/SignupDialog.tsx b/src/frontend/src/screens/login/SignupDialog.tsx index 3275ff0..85b7241 100644 --- a/src/frontend/src/screens/login/SignupDialog.tsx +++ b/src/frontend/src/screens/login/SignupDialog.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { authApi } from '../../api/endpoints'; import { ApiRequestError } from '../../api/client'; import { Button } from '../../components/ui/Button'; @@ -20,6 +21,7 @@ export function SignupDialog({ onDone: (email: string) => void; defaultEmail?: string; }) { + const { t } = useTranslation(); const [email, setEmail] = useState(defaultEmail); const [displayName, setDisplayName] = useState(''); const [password, setPassword] = useState(''); @@ -37,7 +39,7 @@ export function SignupDialog({ e.preventDefault(); setError(null); if (password !== confirm) { - setError('비밀번호가 일치하지 않습니다.'); + setError(t('signup.mismatch')); return; } setSubmitting(true); @@ -51,22 +53,19 @@ export function SignupDialog({ }); const pending = res?.status && res.status !== 'ACTIVE'; setNotice( - res?.message ?? - (pending - ? '계정이 생성되었습니다. 관리자 승인 후 로그인할 수 있습니다.' - : '계정이 생성되었습니다. 이제 로그인해 주세요.'), + res?.message ?? (pending ? t('signup.noticePending') : t('signup.noticeActive')), ); } catch (err) { if (err instanceof ApiRequestError) { if (err.code === 'NOT_IMPLEMENTED' || err.httpStatus === 404) { - setError('회원가입 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.'); + setError(t('signup.errNotImpl')); } else if (err.code === 'CONFLICT') { - setError('이미 가입된 이메일입니다.'); + setError(t('signup.errConflict')); } else { setError(err.message); } } else { - setError('회원가입 중 오류가 발생했습니다.'); + setError(t('signup.errGeneric')); } } finally { setSubmitting(false); @@ -82,53 +81,51 @@ export function SignupDialog({ -

회원가입 완료

+

{t('signup.doneTitle')}

{notice}

- +
) : (

- 회원가입 + {t('signup.title')}

-

- KINTEX AI 전시관리 계정을 만듭니다. 회사명·초대코드는 선택 사항입니다. -

+

{t('signup.desc')}

- + setEmail(e.target.value)} - placeholder="name@company.com" + placeholder={t('signup.emailPh')} required />
- + setDisplayName(e.target.value)} - placeholder="홍길동" + placeholder={t('signup.namePh')} required />
- + setPassword(e.target.value)} - placeholder="8자 이상" + placeholder={t('signup.passwordPh')} aria-describedby="su-pw-hint" required /> @@ -142,40 +139,38 @@ export function SignupDialog({
- + setConfirm(e.target.value)} - placeholder="비밀번호 재입력" + placeholder={t('signup.password2Ph')} aria-invalid={mismatch} required /> {mismatch && ( - - 비밀번호가 일치하지 않습니다. - + {t('signup.mismatch')} )}
- + setCompanyName(e.target.value)} - placeholder="(주)킨텍스" + placeholder={t('signup.companyPh')} />
- + setInviteCode(e.target.value)} - placeholder="INV-XXXX" + placeholder={t('signup.invitePh')} />
@@ -187,10 +182,10 @@ export function SignupDialog({
diff --git a/src/frontend/src/screens/public/PublicEventDetailPage.tsx b/src/frontend/src/screens/public/PublicEventDetailPage.tsx index d1bb8a7..75c5f36 100644 --- a/src/frontend/src/screens/public/PublicEventDetailPage.tsx +++ b/src/frontend/src/screens/public/PublicEventDetailPage.tsx @@ -4,6 +4,7 @@ * 데이터: GET /api/public/events/{eventId} (공개 카탈로그). 아젠다·연사·FAQ는 안내용 예시(전용 API 후속). */ import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Link, useParams } from 'react-router-dom'; import { PublicShell } from './PublicShell'; import { publicApi, type PublicEvent } from './publicApi'; @@ -22,52 +23,35 @@ import { } from './publicIcons'; const TABS = [ - { key: 'intro', label: '소개' }, - { key: 'program', label: '프로그램' }, - { key: 'speakers', label: '연사' }, - { key: 'exhibitors', label: '참가업체' }, - { key: 'transport', label: '교통' }, - { key: 'faq', label: 'FAQ' }, + { key: 'intro', i18n: 'detail.tabs.intro' }, + { key: 'program', i18n: 'detail.tabs.program' }, + { key: 'speakers', i18n: 'detail.tabs.speakers' }, + { key: 'exhibitors', i18n: 'detail.tabs.exhibitors' }, + { key: 'transport', i18n: 'detail.tabs.transport' }, + { key: 'faq', i18n: 'detail.tabs.faq' }, ]; const THEMES = [ - { ic: , title: 'AI & 자율 로봇', desc: '스스로 학습하고 판단하는 스마트 로봇 솔루션' }, - { ic: , title: '디지털 트윈 & IoT', desc: '가상 세계와 현실 제조 현장의 실시간 동기화' }, - { ic: , title: '탄소중립 스마트 제조', desc: '에너지 효율 극대화를 위한 친환경 공정 기술' }, + { ic: , titleKey: 'detail.theme1Title', descKey: 'detail.theme1Desc' }, + { ic: , titleKey: 'detail.theme2Title', descKey: 'detail.theme2Desc' }, + { ic: , titleKey: 'detail.theme3Title', descKey: 'detail.theme3Desc' }, ]; const AGENDA = [ - { - time: '10:00 – 11:30', - room: '메인 컨퍼런스 홀', - 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 비즈니스 미팅', - }, + { time: '10:00 – 11:30', roomKey: 'detail.agenda1Room', tag: 'KEYNOTE', titleKey: 'detail.agenda1Title', descKey: 'detail.agenda1Desc' }, + { time: '13:00 – 14:30', roomKey: 'detail.agenda2Room', tag: 'TECH SESSION', titleKey: 'detail.agenda2Title', descKey: 'detail.agenda2Desc' }, + { time: '15:00 – 17:00', roomKey: 'detail.agenda3Room', tag: 'NETWORKING', titleKey: 'detail.agenda3Title', descKey: 'detail.agenda3Desc' }, ]; const SPEAKERS = [ - { name: '김철수', role: 'AI 연구소장 | TechCorp' }, - { name: '이영희', role: 'CTO | Automatech' }, - { name: '박지성', role: '로봇공학 석좌교수 | Korea Univ.' }, - { name: 'Sarah Jenkins', role: 'Global VP | Digitron' }, + { name: '김철수', roleKey: 'detail.speaker1Role' }, + { name: '이영희', roleKey: 'detail.speaker2Role' }, + { name: '박지성', roleKey: 'detail.speaker3Role' }, + { name: 'Sarah Jenkins', roleKey: 'detail.speaker4Role' }, ]; export function PublicEventDetailPage() { + const { t } = useTranslation(); const { eventId } = useParams<{ eventId: string }>(); const [activeTab, setActiveTab] = useState('intro'); const [day, setDay] = useState(1); @@ -94,17 +78,17 @@ export function PublicEventDetailPage() { }; }, [eventId]); - const title = event?.name ?? (loading ? '행사 정보를 불러오는 중…' : '행사 상세'); - const dateRange = event ? formatRange(event.startDate, event.endDate) : '일정 확인 중'; - const place = event?.hallLabel ?? '홀 배정 예정'; + const title = event?.name ?? (loading ? t('detail.loadingTitle') : t('detail.fallbackTitle')); + const dateRange = event ? formatRange(event.startDate, event.endDate) : t('detail.dateTbd'); + const place = event?.hallLabel ?? t('detail.placeTbd'); const registerTo = eventId ? `/public/events/${encodeURIComponent(eventId)}/register` : '#program'; return ( - + {/* 행사 히어로 */}
- KINTEX Exhibition + {t('detail.eyebrow')}

{title}

-

- {error ? error : '킨텍스에서 열리는 전시회 정보를 확인하고 사전등록하세요.'} -

+

{error ? error : t('detail.lead')}

- 일시 + {t('detail.factDate')} {dateRange} - 장소 + {t('detail.factPlace')} {place}
- 사전등록하기 + {t('detail.ctaRegister')} {eventId && ( - 전시장 도면 보기 + {t('detail.ctaFloorplan')} )}
@@ -154,52 +136,48 @@ export function PublicEventDetailPage() {
{/* 서브탭(앵커) */} -