diff --git a/mobile/app.json b/mobile/app.json
index 21dd44e..73bb946 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -4,7 +4,7 @@
"slug": "kintex",
"owner": "zioinfo",
"scheme": "kintex",
- "version": "0.2.0",
+ "version": "0.2.2",
"orientation": "portrait",
"userInterfaceStyle": "light",
"jsEngine": "hermes",
@@ -28,7 +28,7 @@
},
"android": {
"package": "kr.co.zioinfo.kintex",
- "versionCode": 2,
+ "versionCode": 4,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0066B3"
diff --git a/mobile/app/(visitor)/_layout.tsx b/mobile/app/(visitor)/_layout.tsx
new file mode 100644
index 0000000..0da9a57
--- /dev/null
+++ b/mobile/app/(visitor)/_layout.tsx
@@ -0,0 +1,69 @@
+/*
+ * 관람객(B2C) 하단 탭 셸 — 운영(B2B) (tabs) 셸과 분리된 관람객 전용 트랙.
+ * 탭: 홈 · 행사 · 티켓 · 현장(혼잡/주차) · 마이. design.md §4 SCR-M5/M14/M15 관람객 톤.
+ * 진입: roleTrack visitor 랜딩(/(visitor)) 또는 게스트. 인증 가드는 (tabs)와 동일(로그인 필요).
+ */
+import { Ionicons } from '@expo/vector-icons';
+import { Redirect, Tabs } from 'expo-router';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useAuth } from '../../context/AuthContext';
+import { colors } from '../../theme';
+
+export default function VisitorTabsLayout() {
+ const { t } = useTranslation();
+ const { ready, token } = useAuth();
+ if (ready && !token) return ;
+
+ return (
+
+ ,
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+ ,
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+ ,
+ }}
+ />
+
+ );
+}
diff --git a/mobile/app/(visitor)/events.tsx b/mobile/app/(visitor)/events.tsx
new file mode 100644
index 0000000..6950eaf
--- /dev/null
+++ b/mobile/app/(visitor)/events.tsx
@@ -0,0 +1,94 @@
+/*
+ * [모바일] 관람객(B2C) 행사 — 참여 행사 목록 + 선택 행사 라이브 공지 피드(전체).
+ * 참여 행사는 로그인 워크스페이스에서 수신(계약 발명 없음). 없으면 데모 라이브 행사로 공개 공지 노출.
+ * 라이브 공지 = 공개 API(cms_backlog_contract.md).
+ */
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { Banner } from '../../components/Banner';
+import { Card } from '../../components/Card';
+import { DdayChip } from '../../components/DdayChip';
+import { LiveNoticeFeed } from '../../components/LiveNoticeFeed';
+import { useAuth } from '../../context/AuthContext';
+import { DEFAULT_PUBLIC_EVENT_ID } from '../../lib/config';
+import { colors, radius, spacing, type } from '../../theme';
+
+export default function VisitorEventsScreen() {
+ const { t } = useTranslation();
+ const { workspaces, activeWorkspace } = useAuth();
+ const [selected, setSelected] = useState(
+ activeWorkspace?.eventId ?? workspaces[0]?.eventId ?? null,
+ );
+
+ const selectedWs = workspaces.find((w) => w.eventId === selected) ?? null;
+ const feedEventId = selected ?? DEFAULT_PUBLIC_EVENT_ID;
+
+ return (
+
+ {t('vevents.title')}
+
+ {workspaces.length === 0 ? (
+ {t('vevents.empty')}
+ ) : (
+ <>
+ {/* 참여 행사 선택 칩 */}
+
+ {workspaces.map((w) => {
+ const on = w.eventId === selected;
+ return (
+ setSelected(w.eventId)}
+ style={[styles.chip, on && styles.chipOn]}
+ >
+
+ {w.eventName}
+
+
+ );
+ })}
+
+
+ {/* 선택 행사 요약 카드 */}
+ {selectedWs ? (
+
+
+
+ {selectedWs.eventName}
+
+
+
+
+ {selectedWs.hallLabel} · {selectedWs.startDate} ~ {selectedWs.endDate}
+
+
+ ) : null}
+ >
+ )}
+
+ {/* 라이브 공지(전체) */}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ scroll: { padding: spacing.md, gap: spacing.md, paddingBottom: 40 },
+ title: { fontSize: type.h2.fontSize, fontWeight: '700', color: colors.neutral900 },
+ chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
+ chip: {
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ borderRadius: radius.pill,
+ borderWidth: 1,
+ borderColor: colors.neutral200,
+ maxWidth: 240,
+ },
+ chipOn: { backgroundColor: colors.primary100, borderColor: colors.primary600 },
+ chipText: { color: colors.neutral700, fontSize: type.caption.fontSize },
+ chipTextOn: { color: colors.primary700, fontWeight: '700' },
+ eventTop: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 },
+ eventName: { flex: 1, fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
+ eventMeta: { marginTop: 4, fontSize: type.caption.fontSize, color: colors.neutral500 },
+});
diff --git a/mobile/app/(visitor)/index.tsx b/mobile/app/(visitor)/index.tsx
new file mode 100644
index 0000000..653eeff
--- /dev/null
+++ b/mobile/app/(visitor)/index.tsx
@@ -0,0 +1,139 @@
+/*
+ * SCR-M5 계열 [모바일] 관람객(B2C) 홈 — 친근한 소비자 톤.
+ * 인사 + 활성 행사 요약 + 라이브 공지 피드(상단 요약 3건) + 빠른 이동 타일(티켓·행사·현장) + AI 관람 도우미 안내.
+ * design.md §4 SCR-M5. 라이브 공지 = 공개 API(cms_backlog_contract.md).
+ */
+import { Ionicons } from '@expo/vector-icons';
+import { router } from 'expo-router';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { Banner } from '../../components/Banner';
+import { Card } from '../../components/Card';
+import { DdayChip } from '../../components/DdayChip';
+import { LiveNoticeFeed } from '../../components/LiveNoticeFeed';
+import { useAuth } from '../../context/AuthContext';
+import { DEFAULT_PUBLIC_EVENT_ID } from '../../lib/config';
+import { colors, radius, spacing, type } from '../../theme';
+
+export default function VisitorHomeScreen() {
+ const { t } = useTranslation();
+ const { user, activeWorkspace } = useAuth();
+ const eventId = activeWorkspace?.eventId ?? DEFAULT_PUBLIC_EVENT_ID;
+
+ const tiles: {
+ key: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ label: string;
+ desc: string;
+ onPress: () => void;
+ }[] = [
+ {
+ key: 'tickets',
+ icon: 'ticket-outline',
+ label: t('vhome.tileTickets'),
+ desc: t('vhome.tileTicketsDesc'),
+ onPress: () => router.push('/(visitor)/tickets'),
+ },
+ {
+ key: 'events',
+ icon: 'calendar-outline',
+ label: t('vhome.tileEvents'),
+ desc: t('vhome.tileEventsDesc'),
+ onPress: () => router.push('/(visitor)/events'),
+ },
+ {
+ key: 'onsite',
+ icon: 'navigate-outline',
+ label: t('vhome.tileOnsite'),
+ desc: t('vhome.tileOnsiteDesc'),
+ onPress: () => router.push('/(visitor)/onsite'),
+ },
+ ];
+
+ return (
+
+
+ {t('vhome.hello', { name: user?.displayName ?? t('common.user') })}
+
+ {t('vhome.subtitle')}
+
+ {/* 활성 행사 요약(참여 행사가 있을 때) */}
+ {activeWorkspace ? (
+
+
+
+ {activeWorkspace.eventName}
+
+
+
+
+ {activeWorkspace.hallLabel} · {activeWorkspace.startDate} ~ {activeWorkspace.endDate}
+
+
+ ) : (
+ {t('vhome.noEvent')}
+ )}
+
+ {/* 라이브 공지 요약(상단 3건) */}
+
+
+ {/* 빠른 이동 타일 */}
+
+ {tiles.map((tile) => (
+
+
+
+
+ {tile.label}
+ {tile.desc}
+
+ ))}
+
+
+ {/* AI 관람 도우미 안내 */}
+
+
+
+
+ {t('vhome.aiTitle')}
+ {t('vhome.aiDesc')}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ scroll: { padding: spacing.md, gap: spacing.md, paddingBottom: 40 },
+ hello: { fontSize: type.h2.fontSize, fontWeight: '700', color: colors.neutral900 },
+ sub: { marginTop: -8, fontSize: type.caption.fontSize, color: colors.neutral500 },
+ eventTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
+ eventName: { flex: 1, fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
+ eventMeta: { marginTop: 4, fontSize: type.caption.fontSize, color: colors.neutral500 },
+ tiles: { flexDirection: 'row', gap: spacing.sm },
+ tile: {
+ flex: 1,
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.neutral200,
+ borderRadius: radius.md,
+ padding: spacing.sm,
+ gap: 6,
+ minHeight: 104,
+ },
+ tileIcon: {
+ width: 40,
+ height: 40,
+ borderRadius: radius.sm,
+ backgroundColor: colors.primary050,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ tileLabel: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.neutral900 },
+ tileDesc: { fontSize: 11, color: colors.neutral500, lineHeight: 15 },
+ aiRow: { flexDirection: 'row', gap: 10, alignItems: 'flex-start' },
+ aiTitle: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.aiAccent },
+ aiDesc: { marginTop: 2, fontSize: type.caption.fontSize, color: colors.neutral700, lineHeight: 18 },
+});
diff --git a/mobile/app/(visitor)/my.tsx b/mobile/app/(visitor)/my.tsx
new file mode 100644
index 0000000..86c410b
--- /dev/null
+++ b/mobile/app/(visitor)/my.tsx
@@ -0,0 +1,177 @@
+/*
+ * [모바일] 관람객(B2C) 마이 — 내정보·내 티켓·내 주차권·언어·서버 상태·로그아웃.
+ * 운영(B2B) 더보기(SCR-M3 more)의 관람객 경량판. 공통 키(more.*) 재사용 + vmy.* 최소 신설.
+ */
+import { Ionicons } from '@expo/vector-icons';
+import { router } from 'expo-router';
+import React, { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { Avatar } from '../../components/Avatar';
+import { Card } from '../../components/Card';
+import { LanguageSelector } from '../../components/LanguageSelector';
+import { useAuth } from '../../context/AuthContext';
+import { api, isDegraded } from '../../lib/api';
+import { API_BASE } from '../../lib/config';
+import type { HealthDto } from '../../lib/types';
+import { colors, radius, spacing, touch, type } from '../../theme';
+
+export default function VisitorMyScreen() {
+ const { t } = useTranslation();
+ const { user, signOut } = useAuth();
+ const [health, setHealth] = useState(t('common.loading'));
+
+ const checkHealth = useCallback(async () => {
+ try {
+ const res = await api.get('/health', { anonymous: true });
+ setHealth(res?.status === 'UP' ? t('more.statusUp') : (res?.status ?? t('common.unknown')));
+ } catch (e) {
+ setHealth(isDegraded(e) ? t('more.statusDown') : t('common.error'));
+ }
+ }, [t]);
+
+ useEffect(() => {
+ checkHealth();
+ }, [checkHealth]);
+
+ async function onSignOut() {
+ await signOut();
+ router.replace('/login');
+ }
+
+ return (
+
+ {/* 내 정보 진입 */}
+ router.push('/profile')}
+ accessibilityRole="button"
+ accessibilityLabel={t('more.openProfile')}
+ >
+
+
+
+
+ {user?.displayName ?? t('common.user')}
+ {t('more.accountGeneral')}
+
+
+
+
+
+
+ {/* 바로가기 — 내 티켓 · 내 주차권 */}
+ router.push('/(visitor)/tickets')}
+ />
+ router.push('/(visitor)/onsite')}
+ />
+
+ {/* 언어 */}
+
+
+ {/* 서버 상태 */}
+
+
+
+
+
+
+
+
+
+
+
+ {t('more.signOut')}
+
+
+ {t('more.version')}
+
+ );
+}
+
+function NavRow({
+ icon,
+ title,
+ desc,
+ onPress,
+}: {
+ icon: keyof typeof Ionicons.glyphMap;
+ title: string;
+ desc: string;
+ onPress: () => void;
+}) {
+ return (
+
+
+
+
+
+ {title}
+ {desc}
+
+
+
+ );
+}
+
+function Row({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ scroll: { padding: spacing.md, gap: spacing.md, paddingBottom: 40 },
+ meRow: { flexDirection: 'row', alignItems: 'center' },
+ name: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
+ meta: { marginTop: 4, color: colors.neutral500, fontSize: type.caption.fontSize },
+ navRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.neutral200,
+ borderRadius: radius.md,
+ padding: spacing.md,
+ },
+ navIcon: {
+ width: 40,
+ height: 40,
+ borderRadius: radius.sm,
+ backgroundColor: colors.primary050,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ navTitle: { fontSize: type.h3.fontSize, fontWeight: '600', color: colors.neutral900 },
+ navDesc: { fontSize: type.caption.fontSize, color: colors.neutral500 },
+ row: { flexDirection: 'row', justifyContent: 'space-between', gap: 12, paddingVertical: 4 },
+ rowLabel: { color: colors.neutral500, fontSize: type.body.fontSize },
+ rowValue: { flex: 1, textAlign: 'right', color: colors.neutral900, fontSize: type.body.fontSize },
+ divider: { height: 1, backgroundColor: colors.neutral200, marginVertical: 6 },
+ healthRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
+ signOut: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ minHeight: touch.min,
+ borderRadius: radius.sm,
+ borderWidth: 1,
+ borderColor: colors.error,
+ },
+ signOutText: { color: colors.error, fontSize: type.h3.fontSize, fontWeight: '600' },
+ version: { textAlign: 'center', color: colors.neutral500, fontSize: type.caption.fontSize },
+});
diff --git a/mobile/app/(visitor)/onsite.tsx b/mobile/app/(visitor)/onsite.tsx
new file mode 100644
index 0000000..111a21f
--- /dev/null
+++ b/mobile/app/(visitor)/onsite.tsx
@@ -0,0 +1,672 @@
+/*
+ * F-C2/F-C3 [모바일] 관람객(B2C) 현장 — 실시간 혼잡 + 주차 현황 + 사전 주차권(mock).
+ * 세그먼트: 혼잡도 | 주차. 혼잡/주차 현황은 공개 조회, 주차권은 인증(본인).
+ * 계약: _workspace/parking_congestion_contract.md. PII(차량번호)는 서버 마스킹 — 원문 미저장.
+ */
+import { Ionicons } from '@expo/vector-icons';
+import { router } from 'expo-router';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ ActivityIndicator,
+ Modal,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
+import { Banner } from '../../components/Banner';
+import { CongestionPill } from '../../components/CongestionPill';
+import { useAuth } from '../../context/AuthContext';
+import { isDegraded } from '../../lib/api';
+import { DEFAULT_PUBLIC_EVENT_ID } from '../../lib/config';
+import type {
+ CongestionAreaDto,
+ CongestionOverviewDto,
+ ParkingLotStatusDto,
+ ParkingPassDto,
+} from '../../lib/types';
+import {
+ getCongestion,
+ getMyParkingPasses,
+ getParkingLots,
+ purchaseParkingPass,
+} from '../../lib/visitor';
+import { colors, radius, spacing, touch, type } from '../../theme';
+
+type Seg = 'congestion' | 'parking';
+
+function won(n: number): string {
+ return `${n.toLocaleString('ko-KR')}`;
+}
+function ymd(d: Date): string {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+}
+
+export default function VisitorOnsiteScreen() {
+ const { t } = useTranslation();
+ const { token, activeWorkspace } = useAuth();
+ const eventId = activeWorkspace?.eventId ?? DEFAULT_PUBLIC_EVENT_ID;
+
+ const [seg, setSeg] = useState('congestion');
+
+ return (
+
+ {/* 세그먼트 */}
+
+ {(['congestion', 'parking'] as Seg[]).map((s) => {
+ const on = seg === s;
+ return (
+ setSeg(s)}
+ >
+
+ {t(s === 'congestion' ? 'onsite.segCongestion' : 'onsite.segParking')}
+
+
+ );
+ })}
+
+
+ {seg === 'congestion' ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+// ── 혼잡도 ──
+function CongestionView({ eventId }: { eventId: string }) {
+ const { t } = useTranslation();
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [degraded, setDegraded] = useState(false);
+
+ const load = useCallback(async () => {
+ try {
+ const res = await getCongestion(eventId);
+ setData(res);
+ setDegraded(false);
+ } catch (e) {
+ setDegraded(isDegraded(e));
+ setData(null);
+ } finally {
+ setLoading(false);
+ }
+ }, [eventId]);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+ if (!data) {
+ return (
+
+
+ {degraded ? t('congestion.degraded') : t('congestion.empty')}
+
+
+ );
+ }
+
+ return (
+
+ {/* 종합 */}
+
+
+ {t('congestion.overall')}
+
+
+
+
+
+ {t('congestion.onSite')} {t('congestion.people', { n: won(data.onSiteCount) })}
+
+
+
+
+
+
+
+
+ );
+}
+
+function AreaGroup({
+ title,
+ icon,
+ areas,
+}: {
+ title: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ areas: CongestionAreaDto[];
+}) {
+ if (!areas || areas.length === 0) return null;
+ return (
+
+
+
+ {title}
+
+ {areas.map((a) => (
+
+
+ {a.label}
+
+
+
+ ))}
+
+ );
+}
+
+// ── 주차 ──
+function ParkingView({ eventId, hasToken }: { eventId: string; hasToken: boolean }) {
+ const { t } = useTranslation();
+ const [lots, setLots] = useState(null);
+ const [degraded, setDegraded] = useState(false);
+ const [passes, setPasses] = useState(null);
+ const [purchaseFor, setPurchaseFor] = useState(null);
+
+ const loadLots = useCallback(async () => {
+ try {
+ const res = await getParkingLots(eventId);
+ setLots(res ?? []);
+ setDegraded(false);
+ } catch (e) {
+ setDegraded(isDegraded(e));
+ setLots([]);
+ }
+ }, [eventId]);
+
+ const loadPasses = useCallback(async () => {
+ if (!hasToken) {
+ setPasses(null);
+ return;
+ }
+ try {
+ const res = await getMyParkingPasses();
+ setPasses(res?.passes ?? []);
+ } catch {
+ setPasses([]); // degraded/미배포 시 빈 목록(정직)
+ }
+ }, [hasToken]);
+
+ useEffect(() => {
+ loadLots();
+ loadPasses();
+ }, [loadLots, loadPasses]);
+
+ return (
+
+ {/* 주차 현황 */}
+
+
+ {t('parking.title')}
+
+
+ {lots == null ? (
+
+
+
+ ) : lots.length === 0 ? (
+
+ {degraded ? t('parking.degraded') : t('parking.empty')}
+
+ ) : (
+ lots.map((lot) => (
+ setPurchaseFor(lot)} canBuy={hasToken} />
+ ))
+ )}
+
+ {/* 내 주차권 */}
+
+
+ {t('parking.myPasses')}
+
+ {!hasToken ? (
+ router.push('/login')}>
+ {t('parking.loginRequired')}
+
+
+ ) : passes == null ? (
+
+
+
+ ) : passes.length === 0 ? (
+ {t('parking.noPasses')}
+ ) : (
+ passes.map((p) => )
+ )}
+
+ setPurchaseFor(null)}
+ onPurchased={() => {
+ setPurchaseFor(null);
+ loadPasses();
+ loadLots();
+ }}
+ eventId={eventId}
+ />
+
+ );
+}
+
+function LotCard({
+ lot,
+ onBuy,
+ canBuy,
+}: {
+ lot: ParkingLotStatusDto;
+ onBuy: () => void;
+ canBuy: boolean;
+}) {
+ const { t } = useTranslation();
+ const pct = Math.max(0, Math.min(100, lot.occupancyPercent));
+ return (
+
+
+
+ {lot.name}
+
+
+
+ {/* 점유율 바 */}
+
+
+
+
+
+ {t('parking.available', { n: won(lot.available) })} · {t('parking.capacity', { n: won(lot.totalCapacity) })}
+
+
+
+
+ {t('parking.rate', { n: won(lot.hourlyRate) })}
+ {lot.dailyMax != null ? ` · ${t('parking.daily', { n: won(lot.dailyMax) })}` : ''}
+
+
+
+
+
+ {t('parking.buyPass')} · {t('parking.passPrice', { n: won(lot.passPrice) })}
+
+
+ {!canBuy ? {t('parking.loginRequired')} : null}
+
+ );
+}
+
+const PASS_STATUS_TONE: Record = {
+ PAID: { color: colors.success, bg: '#E7F6EF' },
+ USED: { color: colors.neutral700, bg: colors.neutral050 },
+ CANCELLED: { color: colors.error, bg: '#FEF3F2' },
+};
+
+function PassCard({ pass }: { pass: ParkingPassDto }) {
+ const { t } = useTranslation();
+ const tone = PASS_STATUS_TONE[pass.status] ?? PASS_STATUS_TONE.USED;
+ const dim = pass.status !== 'PAID';
+ return (
+
+
+
+ {pass.lotName}
+
+
+
+ {t(`parking.passStatus.${pass.status}` as const)}
+
+
+
+
+ {pass.useDate} · {pass.passNo}
+
+
+
+ {pass.vehiclePlateMasked ?? '-'}
+
+ {t('parking.passPrice', { n: won(pass.amount) })}
+
+
+ );
+}
+
+function PurchaseModal({
+ lot,
+ eventId,
+ onClose,
+ onPurchased,
+}: {
+ lot: ParkingLotStatusDto | null;
+ eventId: string;
+ onClose: () => void;
+ onPurchased: () => void;
+}) {
+ const { t } = useTranslation();
+ const [dayOffset, setDayOffset] = useState(1); // 내일 기본(오늘 이후)
+ const [plate, setPlate] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState(null);
+
+ const useDate = useMemo(() => {
+ const d = new Date();
+ d.setDate(d.getDate() + dayOffset);
+ return d;
+ }, [dayOffset]);
+
+ // 모달이 열릴 때마다 초기화
+ useEffect(() => {
+ if (lot) {
+ setDayOffset(1);
+ setPlate('');
+ setErr(null);
+ setBusy(false);
+ }
+ }, [lot]);
+
+ async function submit() {
+ if (!lot) return;
+ setBusy(true);
+ setErr(null);
+ try {
+ await purchaseParkingPass({
+ lotId: lot.lotId,
+ useDate: ymd(useDate),
+ eventId,
+ vehiclePlate: plate.trim() ? plate.trim() : undefined,
+ payMethod: 'card',
+ });
+ onPurchased();
+ } catch (e) {
+ setErr((e as Error).message || t('parking.purchaseErr'));
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+
+ {t('parking.purchaseTitle')}
+
+
+
+
+
+ {lot ? (
+ <>
+ {/* 선택 주차장 */}
+ {t('parking.selectLot')}
+
+ {lot.name}
+ {t('parking.passPrice', { n: won(lot.passPrice) })}
+
+
+ {/* 이용일 스텝퍼 */}
+ {t('parking.useDate')}
+
+ setDayOffset((v) => Math.max(1, v - 1))}
+ accessibilityLabel={t('parking.prevDay')}
+ >
+
+
+ {ymd(useDate)}
+ = 14}
+ onPress={() => setDayOffset((v) => Math.min(14, v + 1))}
+ accessibilityLabel={t('parking.nextDay')}
+ >
+ = 14 ? colors.neutral200 : colors.neutral700}
+ />
+
+
+
+ {/* 차량번호(선택) */}
+ {t('parking.plate')}
+
+ {t('parking.plateNote')}
+ {t('parking.payNote')}
+
+ {err ? {err} : null}
+
+
+ {busy ? (
+
+ ) : (
+ {t('parking.confirm')}
+ )}
+
+ >
+ ) : null}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ flex: { flex: 1, backgroundColor: colors.neutral050 },
+ scroll: { padding: spacing.md, gap: spacing.sm, paddingBottom: 48 },
+ loading: { paddingVertical: spacing.xl, alignItems: 'center' },
+ segment: {
+ flexDirection: 'row',
+ margin: spacing.md,
+ marginBottom: 0,
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.neutral200,
+ borderRadius: radius.md,
+ padding: 4,
+ gap: 4,
+ },
+ segBtn: {
+ flex: 1,
+ minHeight: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radius.sm,
+ },
+ segBtnOn: { backgroundColor: colors.primary050 },
+ segText: { fontSize: type.body.fontSize, fontWeight: '600', color: colors.neutral500 },
+ segTextOn: { color: colors.primary700 },
+ // 혼잡
+ overallCard: {
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.neutral200,
+ borderRadius: radius.md,
+ padding: spacing.md,
+ gap: 10,
+ },
+ overallTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
+ overallLabel: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
+ overallMetric: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ overallCount: { fontSize: type.body.fontSize, color: colors.neutral700, fontWeight: '600' },
+ group: {
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.neutral200,
+ borderRadius: radius.md,
+ padding: spacing.md,
+ gap: 8,
+ },
+ groupHeader: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ groupTitle: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.neutral900 },
+ areaRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 10,
+ borderTopWidth: 1,
+ borderTopColor: colors.neutral200,
+ paddingTop: 8,
+ },
+ areaLabel: { flex: 1, fontSize: type.body.fontSize, color: colors.neutral700 },
+ // 주차 lot
+ lotCard: {
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.neutral200,
+ borderRadius: radius.md,
+ padding: spacing.md,
+ gap: 8,
+ },
+ lotTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
+ lotName: { flex: 1, fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
+ bar: {
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: colors.neutral200,
+ overflow: 'hidden',
+ },
+ barFill: { height: 8, borderRadius: 4, backgroundColor: colors.primary600 },
+ lotMetaRow: { flexDirection: 'row' },
+ lotMeta: { fontSize: type.caption.fontSize, color: colors.neutral500 },
+ buyBtn: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 6,
+ minHeight: touch.min,
+ borderRadius: radius.sm,
+ backgroundColor: colors.primary600,
+ marginTop: 2,
+ },
+ buyBtnGhost: { backgroundColor: colors.neutral050, borderWidth: 1, borderColor: colors.neutral200 },
+ buyText: { color: colors.white, fontSize: type.body.fontSize, fontWeight: '700' },
+ buyHint: { fontSize: 11, color: colors.neutral500, textAlign: 'center' },
+ // 주차권
+ loginNote: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ backgroundColor: colors.primary050,
+ borderRadius: radius.md,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ },
+ loginNoteText: { fontSize: type.body.fontSize, color: colors.primary700, fontWeight: '600' },
+ emptyNote: { fontSize: type.caption.fontSize, color: colors.neutral500, paddingVertical: spacing.sm },
+ passCard: {
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.neutral200,
+ borderRadius: radius.md,
+ padding: spacing.md,
+ gap: 6,
+ },
+ passDim: { opacity: 0.6 },
+ passTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
+ passLot: { flex: 1, fontSize: type.body.fontSize, fontWeight: '700', color: colors.neutral900 },
+ passBadge: { borderRadius: radius.pill, paddingHorizontal: 10, paddingVertical: 3 },
+ passBadgeText: { fontSize: 11, fontWeight: '700' },
+ passMeta: { fontSize: type.caption.fontSize, color: colors.neutral500 },
+ passBottom: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
+ passAmount: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.primary700 },
+ // 모달
+ modalBackdrop: { flex: 1, backgroundColor: 'rgba(16,24,40,0.45)', justifyContent: 'flex-end' },
+ modalSheet: {
+ backgroundColor: colors.white,
+ borderTopLeftRadius: 16,
+ borderTopRightRadius: 16,
+ padding: spacing.md,
+ paddingBottom: spacing.xl,
+ gap: 8,
+ },
+ modalHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
+ modalTitle: { fontSize: type.h2.fontSize, fontWeight: '700', color: colors.neutral900 },
+ modalLabel: { fontSize: type.caption.fontSize, color: colors.neutral500, fontWeight: '600', marginTop: 6 },
+ modalLotBox: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ backgroundColor: colors.neutral050,
+ borderRadius: radius.sm,
+ padding: 12,
+ },
+ modalLotName: { flex: 1, fontSize: type.body.fontSize, fontWeight: '600', color: colors.neutral900 },
+ modalLotPrice: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.primary700 },
+ dateRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ backgroundColor: colors.neutral050,
+ borderRadius: radius.sm,
+ borderWidth: 1,
+ borderColor: colors.neutral200,
+ },
+ dateBtn: { width: touch.min, height: touch.min, alignItems: 'center', justifyContent: 'center' },
+ dateText: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
+ input: {
+ minHeight: touch.min,
+ borderWidth: 1,
+ borderColor: colors.neutral200,
+ borderRadius: radius.sm,
+ paddingHorizontal: 12,
+ fontSize: type.body.fontSize,
+ color: colors.neutral900,
+ backgroundColor: colors.white,
+ },
+ modalNote: { fontSize: 11, color: colors.neutral500, lineHeight: 16 },
+ confirmBtn: {
+ minHeight: 52,
+ borderRadius: radius.md,
+ backgroundColor: colors.primary600,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: 8,
+ },
+ confirmBusy: { opacity: 0.7 },
+ confirmText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '700' },
+});
diff --git a/mobile/app/(visitor)/tickets.tsx b/mobile/app/(visitor)/tickets.tsx
new file mode 100644
index 0000000..1601698
--- /dev/null
+++ b/mobile/app/(visitor)/tickets.tsx
@@ -0,0 +1,10 @@
+/*
+ * SCR-M15 [모바일] 관람객(B2C) 티켓 탭 — 공용 TicketWallet 본문 재사용.
+ * 헤더는 탭 레이아웃이 소유. 기존 /tickets 라우트와 동일 본문(편입).
+ */
+import React from 'react';
+import { TicketWallet } from '../../components/tickets/TicketWallet';
+
+export default function VisitorTicketsScreen() {
+ return ;
+}
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index 3f0c77b..a9d1414 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -30,6 +30,7 @@ function RootStack() {
+
diff --git a/mobile/app/login.tsx b/mobile/app/login.tsx
index 1e6cbe5..1f98802 100644
--- a/mobile/app/login.tsx
+++ b/mobile/app/login.tsx
@@ -7,6 +7,7 @@ import { Link, router } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
+ Image,
KeyboardAvoidingView,
Platform,
Pressable,
@@ -104,9 +105,15 @@ export default function LoginScreen() {
contentContainerStyle={[styles.scroll, { paddingTop: insets.top + spacing.lg }]}
keyboardShouldPersistTaps="handled"
>
- {/* 브랜드 패널 */}
+ {/* 브랜드 패널 — 정식 CI 워드마크(CI_01, 딥블루 패널용 화이트 변형) */}
- KINTEX
+
{t('login.brandTitle')}
{t('login.brandCaption')}
@@ -193,7 +200,7 @@ const styles = StyleSheet.create({
padding: spacing.lg,
gap: 8,
},
- brandMark: { color: colors.white, fontSize: 22, fontWeight: '800', letterSpacing: 1 },
+ brandMark: { width: 178, height: 32, marginBottom: 2 },
brandTitle: { color: colors.white, fontSize: type.h2.fontSize, fontWeight: '700', lineHeight: 30 },
brandCaption: { color: 'rgba(255,255,255,0.75)', fontSize: type.caption.fontSize },
card: {
diff --git a/mobile/app/tickets/index.tsx b/mobile/app/tickets/index.tsx
index 852e3b4..9797803 100644
--- a/mobile/app/tickets/index.tsx
+++ b/mobile/app/tickets/index.tsx
@@ -1,191 +1,18 @@
/*
- * SCR-M15 [모바일] 내 티켓 지갑 — 관람객(B2C) 트랙.
- * 필터 탭(진행중·예정·지난) + 티켓 카드 리스트 + QR 풀스크린 뷰어.
- * 배지 전환 안내 배너 + 오프라인 표시 배지. 사용됨/취소 카드 흐리게.
- * ※ M10(티켓)·M9(환불) 백엔드 미구현 → 샘플 데이터("샘플" 배지). API 호출 없음.
+ * SCR-M15 [모바일] 내 티켓 지갑 — 레거시 스택 라우트(/tickets).
+ * 본문은 공용 TicketWallet 컴포넌트(관람객 탭 (visitor)/tickets와 동일). 딥링크·예매 완료 복귀 호환용.
*/
-import { Ionicons } from '@expo/vector-icons';
-import { router, Stack } from 'expo-router';
-import React, { useMemo, useState } from 'react';
+import { Stack } from 'expo-router';
+import React from 'react';
import { useTranslation } from 'react-i18next';
-import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
-import { Banner } from '../../components/Banner';
-import { useSecureScreen } from '../../context/SecureScreenContext';
-import { QrViewerModal } from '../../components/tickets/QrViewerModal';
-import { TicketCard } from '../../components/tickets/TicketCard';
-import {
- FILTER_TABS,
- SAMPLE_TICKETS,
- type SampleTicket,
- type TicketFilter,
-} from '../../components/tickets/sampleTickets';
-import { colors, radius, spacing, type } from '../../theme';
+import { TicketWallet } from '../../components/tickets/TicketWallet';
export default function TicketWalletScreen() {
const { t } = useTranslation();
- // 티켓 QR 화면 — 캡처 차단(QR 재사용 방지) + 백그라운드 마스킹(B4/B7).
- useSecureScreen('tickets');
-
- const [filter, setFilter] = useState('active');
- const [qrOpen, setQrOpen] = useState(false);
- const [qrIndex, setQrIndex] = useState(0);
-
- const filtered = useMemo(
- () => SAMPLE_TICKETS.filter((t) => t.filter === filter),
- [filter],
- );
-
- // QR 뷰어 대상 = 현재 필터 내 사용가능 티켓만(스와이프 전환 범위)
- const usableInView = useMemo(
- () => filtered.filter((t) => t.status === 'usable'),
- [filtered],
- );
-
- function openQr(t: SampleTicket) {
- const i = usableInView.findIndex((x) => x.id === t.id);
- setQrIndex(i < 0 ? 0 : i);
- setQrOpen(true);
- }
-
- function openDetail(ticket: SampleTicket) {
- // SCR-P8(예매 확인·취소) 미구현 → 안내만(샘플)
- Alert.alert(
- t('tickets.detailTitle'),
- `${ticket.eventName}\n${ticket.bookingNoMasked}`,
- );
- }
-
return (
-
+ <>
-
-
- {/* 오프라인 표시 배지 */}
-
-
-
- {t('tickets.offline')}
-
-
-
- {/* 필터 탭(세그먼트) */}
-
- {FILTER_TABS.map((tab) => {
- const on = filter === tab.key;
- return (
- setFilter(tab.key)}
- >
- {tab.label}
-
- );
- })}
-
-
- {/* 배지 전환 안내 배너 */}
- {t('tickets.badgeNotice')}
-
- {/* 티켓 리스트 / 빈 상태 */}
- {filtered.length === 0 ? (
-
- ) : (
- filtered.map((tk) => (
-
- ))
- )}
-
- {t('tickets.footNote')}
-
-
- setQrOpen(false)}
- />
-
+
+ >
);
}
-
-function EmptyState() {
- const { t } = useTranslation();
- return (
-
-
- {t('tickets.emptyTitle')}
- {t('tickets.emptyBody')}
- router.push('/tickets/select')}
- >
- {t('tickets.emptyBtn')}
-
-
- );
-}
-
-const styles = StyleSheet.create({
- flex: { flex: 1, backgroundColor: colors.neutral050 },
- scroll: { padding: spacing.md, gap: spacing.md, paddingBottom: 40 },
- offlineWrap: { alignItems: 'center' },
- offlineBadge: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 6,
- backgroundColor: colors.white,
- borderWidth: 1,
- borderColor: colors.neutral200,
- borderRadius: radius.pill,
- paddingHorizontal: 12,
- paddingVertical: 5,
- },
- offlineDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.primary600 },
- offlineText: { fontSize: 11, color: colors.neutral500, fontWeight: '500' },
- segment: {
- flexDirection: 'row',
- backgroundColor: colors.white,
- borderWidth: 1,
- borderColor: colors.neutral200,
- borderRadius: radius.md,
- padding: 4,
- gap: 4,
- },
- segBtn: {
- flex: 1,
- minHeight: 40,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radius.sm,
- },
- segBtnOn: { backgroundColor: colors.primary050 },
- segText: { fontSize: type.body.fontSize, fontWeight: '600', color: colors.neutral500 },
- segTextOn: { color: colors.primary700 },
- footNote: { textAlign: 'center', fontSize: 11, color: colors.neutral500, marginTop: 4 },
- empty: {
- alignItems: 'center',
- gap: 8,
- paddingVertical: spacing.xl,
- backgroundColor: colors.white,
- borderWidth: 1,
- borderColor: colors.neutral200,
- borderRadius: radius.md,
- },
- emptyTitle: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
- emptyBody: { fontSize: type.caption.fontSize, color: colors.neutral500, textAlign: 'center' },
- emptyBtn: {
- marginTop: 8,
- minHeight: 48,
- paddingHorizontal: 24,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: colors.primary600,
- borderRadius: radius.sm,
- },
- emptyBtnText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '700' },
-});
diff --git a/mobile/assets/logo-wordmark-white.png b/mobile/assets/logo-wordmark-white.png
new file mode 100644
index 0000000..1c28c17
Binary files /dev/null and b/mobile/assets/logo-wordmark-white.png differ
diff --git a/mobile/assets/logo-wordmark.png b/mobile/assets/logo-wordmark.png
new file mode 100644
index 0000000..3ab6702
Binary files /dev/null and b/mobile/assets/logo-wordmark.png differ
diff --git a/mobile/assets/splash.png b/mobile/assets/splash.png
index 94eb737..c2f8e42 100644
Binary files a/mobile/assets/splash.png and b/mobile/assets/splash.png differ
diff --git a/mobile/components/CongestionPill.tsx b/mobile/components/CongestionPill.tsx
new file mode 100644
index 0000000..f8efdf0
--- /dev/null
+++ b/mobile/components/CongestionPill.tsx
@@ -0,0 +1,51 @@
+/*
+ * 혼잡도 필 — 여유(FREE)/보통(NORMAL)/혼잡(BUSY) 3단계.
+ * 색 + 텍스트 병기(WCAG: 색만으로 정보 전달 금지). 점유율(%) 선택 노출.
+ */
+import React from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+import type { CongestionLevel } from '../lib/types';
+import { colors, radius, type } from '../theme';
+
+const LEVEL_STYLE: Record = {
+ FREE: { color: colors.success, bg: '#E7F6EF' },
+ NORMAL: { color: colors.warning, bg: '#FFF7ED' },
+ BUSY: { color: colors.error, bg: '#FEF3F2' },
+};
+
+export function CongestionPill({
+ level,
+ label,
+ percent,
+ size = 'md',
+}: {
+ level: CongestionLevel;
+ label: string;
+ percent?: number | null;
+ size?: 'sm' | 'md';
+}) {
+ const s = LEVEL_STYLE[level] ?? LEVEL_STYLE.NORMAL;
+ const text = percent != null ? `${label} ${percent}%` : label;
+ return (
+
+
+ {text}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ pill: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 5,
+ borderRadius: radius.pill,
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ alignSelf: 'flex-start',
+ },
+ pillSm: { paddingHorizontal: 8, paddingVertical: 2 },
+ dot: { width: 7, height: 7, borderRadius: 4 },
+ text: { fontSize: type.caption.fontSize, fontWeight: '700' },
+ textSm: { fontSize: 11 },
+});
diff --git a/mobile/components/LiveNoticeFeed.tsx b/mobile/components/LiveNoticeFeed.tsx
new file mode 100644
index 0000000..36483d3
--- /dev/null
+++ b/mobile/components/LiveNoticeFeed.tsx
@@ -0,0 +1,173 @@
+/*
+ * 라이브 공지 피드 — 공개 GET /api/public/events/{id}/live-notices.
+ * 고정(pinned) 상단 우선 → 최신순(서버 정렬 신뢰). 긴급(URGENT) 강조. 30초 폴링.
+ * 상태: 로딩·빈·degraded(마지막 데이터 유지 + 안내). 홈/행사 상세 공용.
+ * 근거: src/backend/_workspace/cms_backlog_contract.md (공개 라이브 공지).
+ */
+import { Ionicons } from '@expo/vector-icons';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
+import { isDegraded } from '../lib/api';
+import { getLiveNotices } from '../lib/visitor';
+import type { LiveNoticeCategory, LiveNoticeDto } from '../lib/types';
+import { colors, radius, spacing, type } from '../theme';
+
+const POLL_MS = 30_000;
+
+/** 카테고리 → 배지 색(계약 권장 매핑). */
+const CATEGORY_STYLE: Record = {
+ URGENT: { color: colors.error, bg: '#FEF3F2' },
+ PROGRAM: { color: colors.warning, bg: '#FFF7ED' },
+ INFO: { color: colors.primary700, bg: colors.primary050 },
+ GENERAL: { color: colors.neutral700, bg: colors.neutral050 },
+};
+
+interface Props {
+ eventId: string;
+ /** 노출 최대 건수(홈=3 요약, 행사=전체). */
+ max?: number;
+}
+
+export function LiveNoticeFeed({ eventId, max }: Props) {
+ const { t } = useTranslation();
+ const [items, setItems] = useState(null);
+ const [degraded, setDegraded] = useState(false);
+ const mounted = useRef(true);
+
+ const catLabel = useCallback(
+ (c: LiveNoticeCategory): string => t(`notices.cat.${c}` as const),
+ [t],
+ );
+
+ const load = useCallback(async () => {
+ try {
+ const res = await getLiveNotices(eventId, 100);
+ if (!mounted.current) return;
+ // 서버가 pinned 우선→최신 정렬. 방어적으로 pinned 재정렬(안정 정렬).
+ const sorted = [...(res ?? [])].sort((a, b) => Number(b.pinned) - Number(a.pinned));
+ setItems(sorted);
+ setDegraded(false);
+ } catch (e) {
+ if (!mounted.current) return;
+ setDegraded(isDegraded(e));
+ setItems((prev) => prev ?? []); // 최초 실패는 빈 배열로(무한 로딩 방지)
+ }
+ }, [eventId]);
+
+ useEffect(() => {
+ mounted.current = true;
+ load();
+ const timer = setInterval(load, POLL_MS);
+ return () => {
+ mounted.current = false;
+ clearInterval(timer);
+ };
+ }, [load]);
+
+ const visible = max != null ? (items ?? []).slice(0, max) : items ?? [];
+
+ return (
+
+
+
+
+ {t('notices.title')}
+
+
+
+ {t('notices.live')}
+
+
+
+ {items == null ? (
+
+
+
+ ) : visible.length === 0 ? (
+
+ {degraded ? t('notices.degraded') : t('notices.empty')}
+
+ ) : (
+
+ {degraded ? {t('notices.degraded')} : null}
+ {visible.map((n) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function NoticeRow({
+ notice,
+ catLabel,
+}: {
+ notice: LiveNoticeDto;
+ catLabel: (c: LiveNoticeCategory) => string;
+}) {
+ const { t } = useTranslation();
+ const urgent = notice.category === 'URGENT';
+ const cat = CATEGORY_STYLE[notice.category] ?? CATEGORY_STYLE.GENERAL;
+ return (
+
+
+
+ {catLabel(notice.category)}
+
+ {notice.pinned ? (
+
+
+ {t('notices.pinned')}
+
+ ) : null}
+
+
+ {notice.title}
+
+ {notice.body ? (
+
+ {notice.body}
+
+ ) : null}
+ {notice.authorName ? {notice.authorName} : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ wrap: {
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.neutral200,
+ borderRadius: radius.md,
+ padding: spacing.md,
+ gap: spacing.sm,
+ },
+ header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
+ headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ title: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
+ liveDot: { flexDirection: 'row', alignItems: 'center', gap: 5 },
+ liveDotInner: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.error },
+ liveText: { fontSize: 11, fontWeight: '700', color: colors.error },
+ loading: { paddingVertical: spacing.md, alignItems: 'center' },
+ empty: { fontSize: type.caption.fontSize, color: colors.neutral500, paddingVertical: spacing.sm },
+ degradedNote: { fontSize: 11, color: colors.neutral500 },
+ list: { gap: spacing.sm },
+ row: {
+ borderTopWidth: 1,
+ borderTopColor: colors.neutral200,
+ paddingTop: spacing.sm,
+ gap: 4,
+ },
+ rowUrgent: { borderLeftWidth: 3, borderLeftColor: colors.error, paddingLeft: 8 },
+ rowTop: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ catBadge: { borderRadius: radius.sm, paddingHorizontal: 6, paddingVertical: 2 },
+ catText: { fontSize: 10, fontWeight: '700' },
+ pinBadge: { flexDirection: 'row', alignItems: 'center', gap: 3 },
+ pinText: { fontSize: 10, fontWeight: '600', color: colors.primary700 },
+ rowTitle: { fontSize: type.body.fontSize, fontWeight: '600', color: colors.neutral900 },
+ rowBody: { fontSize: type.caption.fontSize, color: colors.neutral700, lineHeight: 18 },
+ rowMeta: { fontSize: 11, color: colors.neutral500 },
+});
diff --git a/mobile/components/tickets/TicketWallet.tsx b/mobile/components/tickets/TicketWallet.tsx
new file mode 100644
index 0000000..bae7ffc
--- /dev/null
+++ b/mobile/components/tickets/TicketWallet.tsx
@@ -0,0 +1,177 @@
+/*
+ * SCR-M15 내 티켓 지갑 (본문 컴포넌트) — 관람객(B2C) 트랙.
+ * 필터 탭(진행중·예정·지난) + 티켓 카드 + QR 풀스크린 뷰어 + 배지 전환 안내 + 오프라인 배지.
+ * 헤더(Stack/Tab)는 호출 측이 소유 — 이 컴포넌트는 본문만 렌더(탭·스택 양쪽 재사용).
+ * ※ M10(티켓)·M9(환불) 백엔드 미구현 → 샘플 데이터. API 호출 없음.
+ */
+import { Ionicons } from '@expo/vector-icons';
+import { router } from 'expo-router';
+import React, { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { useSecureScreen } from '../../context/SecureScreenContext';
+import { colors, radius, spacing, type } from '../../theme';
+import { Banner } from '../Banner';
+import { QrViewerModal } from './QrViewerModal';
+import { TicketCard } from './TicketCard';
+import {
+ FILTER_TABS,
+ SAMPLE_TICKETS,
+ type SampleTicket,
+ type TicketFilter,
+} from './sampleTickets';
+
+export function TicketWallet() {
+ const { t } = useTranslation();
+ // 티켓 QR 화면 — 캡처 차단(QR 재사용 방지) + 백그라운드 마스킹(B4/B7).
+ useSecureScreen('tickets');
+
+ const [filter, setFilter] = useState('active');
+ const [qrOpen, setQrOpen] = useState(false);
+ const [qrIndex, setQrIndex] = useState(0);
+
+ const filtered = useMemo(() => SAMPLE_TICKETS.filter((tk) => tk.filter === filter), [filter]);
+ const usableInView = useMemo(() => filtered.filter((tk) => tk.status === 'usable'), [filtered]);
+
+ function openQr(tk: SampleTicket) {
+ const i = usableInView.findIndex((x) => x.id === tk.id);
+ setQrIndex(i < 0 ? 0 : i);
+ setQrOpen(true);
+ }
+
+ function openDetail(ticket: SampleTicket) {
+ Alert.alert(t('tickets.detailTitle'), `${ticket.eventName}\n${ticket.bookingNoMasked}`);
+ }
+
+ return (
+
+
+ {/* 오프라인 표시 배지 */}
+
+
+
+ {t('tickets.offline')}
+
+
+
+ {/* 필터 탭(세그먼트) */}
+
+ {FILTER_TABS.map((tab) => {
+ const on = filter === tab.key;
+ return (
+ setFilter(tab.key)}
+ >
+ {tab.label}
+
+ );
+ })}
+
+
+ {/* 배지 전환 안내 배너 */}
+ {t('tickets.badgeNotice')}
+
+ {/* 티켓 리스트 / 빈 상태 */}
+ {filtered.length === 0 ? (
+
+ ) : (
+ filtered.map((tk) => (
+
+ ))
+ )}
+
+ {t('tickets.footNote')}
+
+
+ setQrOpen(false)}
+ />
+
+ );
+}
+
+function EmptyState() {
+ const { t } = useTranslation();
+ return (
+
+
+ {t('tickets.emptyTitle')}
+ {t('tickets.emptyBody')}
+ router.push('/tickets/select')}
+ >
+ {t('tickets.emptyBtn')}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ flex: { flex: 1, backgroundColor: colors.neutral050 },
+ scroll: { padding: spacing.md, gap: spacing.md, paddingBottom: 40 },
+ offlineWrap: { alignItems: 'center' },
+ offlineBadge: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.neutral200,
+ borderRadius: radius.pill,
+ paddingHorizontal: 12,
+ paddingVertical: 5,
+ },
+ offlineDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.primary600 },
+ offlineText: { fontSize: 11, color: colors.neutral500, fontWeight: '500' },
+ segment: {
+ flexDirection: 'row',
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.neutral200,
+ borderRadius: radius.md,
+ padding: 4,
+ gap: 4,
+ },
+ segBtn: {
+ flex: 1,
+ minHeight: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radius.sm,
+ },
+ segBtnOn: { backgroundColor: colors.primary050 },
+ segText: { fontSize: type.body.fontSize, fontWeight: '600', color: colors.neutral500 },
+ segTextOn: { color: colors.primary700 },
+ footNote: { textAlign: 'center', fontSize: 11, color: colors.neutral500, marginTop: 4 },
+ empty: {
+ alignItems: 'center',
+ gap: 8,
+ paddingVertical: spacing.xl,
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.neutral200,
+ borderRadius: radius.md,
+ },
+ emptyTitle: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
+ emptyBody: { fontSize: type.caption.fontSize, color: colors.neutral500, textAlign: 'center' },
+ emptyBtn: {
+ marginTop: 8,
+ minHeight: 48,
+ paddingHorizontal: 24,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: colors.primary600,
+ borderRadius: radius.sm,
+ },
+ emptyBtnText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '700' },
+});
diff --git a/mobile/lib/config.ts b/mobile/lib/config.ts
index d6f0f20..f71a182 100644
--- a/mobile/lib/config.ts
+++ b/mobile/lib/config.ts
@@ -13,3 +13,10 @@ export const API_BASE: string =
/** AI 생성 이미지 기본 고지문 (백엔드 watermarkText 부재 시 폴백 — 계약 §0-3). */
export const AI_IMAGE_NOTICE =
'AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다';
+
+/**
+ * 공개(B2C) 조회 폴백 행사 ID — 로그인 관람객이 참여 워크스페이스가 없거나
+ * 게스트로 공개 혼잡/주차/공지를 조회할 때 사용하는 데모 라이브 행사.
+ * 시크릿 아님(공개 식별자). 활성 워크스페이스가 있으면 그것을 우선한다.
+ */
+export const DEFAULT_PUBLIC_EVENT_ID = 'e-2026-live';
diff --git a/mobile/lib/i18n/locales/en.ts b/mobile/lib/i18n/locales/en.ts
index 7b612a1..a586851 100644
--- a/mobile/lib/i18n/locales/en.ts
+++ b/mobile/lib/i18n/locales/en.ts
@@ -201,4 +201,91 @@ export const en: Translations = {
emptyBtn: 'Book entry pass',
detailTitle: 'Booking detail · cancel',
},
+ vnav: {
+ tabHome: 'Home',
+ tabEvents: 'Events',
+ tabTickets: 'Tickets',
+ tabOnsite: 'On-site',
+ tabMy: 'My',
+ },
+ vhome: {
+ hello: 'Welcome, {{name}}',
+ subtitle: "Explore today's exhibition with ease",
+ noEvent: 'You are not part of any event — book a pass and it will appear here.',
+ tileTickets: 'My Tickets',
+ tileTicketsDesc: 'Entry QR · badge',
+ tileEvents: 'Events',
+ tileEventsDesc: 'Notices · schedule',
+ tileOnsite: 'On-site',
+ tileOnsiteDesc: 'Crowd · parking',
+ aiTitle: 'AI Visit Assistant',
+ aiDesc: 'Live crowd, parking and notices. Booth search and wayfinding coming soon.',
+ },
+ vevents: {
+ title: 'Events',
+ empty: 'You are not part of any event — showing public notices.',
+ },
+ notices: {
+ title: 'Live Notices',
+ live: 'LIVE',
+ pinned: 'Pinned',
+ empty: 'No notices posted.',
+ degraded: 'Could not load notices (check connection).',
+ cat: {
+ URGENT: 'Urgent',
+ PROGRAM: 'Program',
+ GENERAL: 'General',
+ INFO: 'Info',
+ },
+ },
+ onsite: {
+ segCongestion: 'Crowd',
+ segParking: 'Parking',
+ },
+ congestion: {
+ overall: 'Overall crowd',
+ onSite: 'On-site',
+ people: '{{n}} people',
+ gates: 'Entry gates',
+ parking: 'Parking',
+ popular: 'Popular areas',
+ empty: 'No crowd data to show.',
+ degraded: 'Could not load crowd data (check connection).',
+ },
+ parking: {
+ title: 'Parking status',
+ empty: 'No parking data.',
+ degraded: 'Could not load parking data (check connection).',
+ available: '{{n}} free',
+ capacity: '{{n}} total',
+ rate: '{{n}} KRW/hr',
+ daily: 'daily max {{n}} KRW',
+ passPrice: '{{n}} KRW',
+ buyPass: 'Buy pass',
+ myPasses: 'My passes',
+ noPasses: 'You have no parking passes.',
+ loginRequired: 'Sign in to buy or view parking passes.',
+ purchaseTitle: 'Buy parking pass',
+ selectLot: 'Parking lot',
+ useDate: 'Use date',
+ plate: 'Vehicle plate (optional)',
+ platePlaceholder: 'e.g. 123가4567',
+ plateNote: 'The plate is masked and not stored.',
+ payNote: 'Payment is simulated (mock) — no real charge.',
+ confirm: 'Buy',
+ purchaseErr: 'Failed to buy the parking pass.',
+ prevDay: 'Previous day',
+ nextDay: 'Next day',
+ passStatus: {
+ PAID: 'Paid',
+ USED: 'Used',
+ CANCELLED: 'Cancelled',
+ },
+ },
+ vmy: {
+ tickets: 'My Tickets',
+ ticketsDesc: 'Entry QR · badge switch',
+ parking: 'My Passes',
+ parkingDesc: 'Parking status · pre-pass',
+ },
};
diff --git a/mobile/lib/i18n/locales/ja.ts b/mobile/lib/i18n/locales/ja.ts
index e80dac2..dde4bc9 100644
--- a/mobile/lib/i18n/locales/ja.ts
+++ b/mobile/lib/i18n/locales/ja.ts
@@ -201,4 +201,91 @@ export const ja: Translations = {
emptyBtn: '入場券を予約',
detailTitle: '予約詳細 · キャンセル',
},
+ vnav: {
+ tabHome: 'ホーム',
+ tabEvents: 'イベント',
+ tabTickets: 'チケット',
+ tabOnsite: '現場',
+ tabMy: 'マイ',
+ },
+ vhome: {
+ hello: '{{name}}さん、ようこそ',
+ subtitle: '今日の展示会を気軽に見て回りましょう',
+ noEvent: '参加中のイベントがありません — 入場券を予約するとここに表示されます。',
+ tileTickets: 'マイチケット',
+ tileTicketsDesc: '入場QR · バッジ',
+ tileEvents: 'イベント',
+ tileEventsDesc: 'お知らせ · 日程',
+ tileOnsite: '現場',
+ tileOnsiteDesc: '混雑 · 駐車',
+ aiTitle: 'AI 観覧アシスタント',
+ aiDesc: '混雑・駐車・お知らせをリアルタイムに案内します。ブース検索・道案内も近日追加。',
+ },
+ vevents: {
+ title: 'イベント',
+ empty: '参加中のイベントがありません — 公開のお知らせを表示します。',
+ },
+ notices: {
+ title: 'ライブお知らせ',
+ live: 'LIVE',
+ pinned: '固定',
+ empty: '登録されたお知らせがありません。',
+ degraded: 'お知らせを取得できませんでした(接続を確認)。',
+ cat: {
+ URGENT: '緊急',
+ PROGRAM: 'プログラム',
+ GENERAL: '一般',
+ INFO: '案内',
+ },
+ },
+ onsite: {
+ segCongestion: '混雑度',
+ segParking: '駐車',
+ },
+ congestion: {
+ overall: '総合混雑度',
+ onSite: '現場人数',
+ people: '{{n}}人',
+ gates: '入場ゲート',
+ parking: '駐車場',
+ popular: '人気エリア',
+ empty: '表示できる混雑情報がありません。',
+ degraded: '混雑情報を取得できませんでした(接続を確認)。',
+ },
+ parking: {
+ title: '駐車状況',
+ empty: '駐車場情報がありません。',
+ degraded: '駐車情報を取得できませんでした(接続を確認)。',
+ available: '空き {{n}}台',
+ capacity: '全 {{n}}台',
+ rate: '1時間 {{n}}ウォン',
+ daily: '1日上限 {{n}}ウォン',
+ passPrice: '{{n}}ウォン',
+ buyPass: '駐車券購入',
+ myPasses: 'マイ駐車券',
+ noPasses: '保有する駐車券がありません。',
+ loginRequired: '駐車券の購入・照会にはログインが必要です。',
+ purchaseTitle: '事前駐車券の購入',
+ selectLot: '駐車場',
+ useDate: '利用日',
+ plate: '車両番号(任意)',
+ platePlaceholder: '例:123가4567',
+ plateNote: '車両番号はマスキングされ保存されません。',
+ payNote: '決済はシミュレーション(mock)です — 実際に請求されません。',
+ confirm: '購入',
+ purchaseErr: '駐車券の購入に失敗しました。',
+ prevDay: '前の日',
+ nextDay: '次の日',
+ passStatus: {
+ PAID: '決済完了',
+ USED: '使用済み',
+ CANCELLED: 'キャンセル',
+ },
+ },
+ vmy: {
+ tickets: 'マイチケット',
+ ticketsDesc: '入場QR · バッジ切替',
+ parking: 'マイ駐車券',
+ parkingDesc: '駐車状況 · 事前駐車券',
+ },
};
diff --git a/mobile/lib/i18n/locales/ko.ts b/mobile/lib/i18n/locales/ko.ts
index 59a45df..635d21d 100644
--- a/mobile/lib/i18n/locales/ko.ts
+++ b/mobile/lib/i18n/locales/ko.ts
@@ -203,6 +203,93 @@ export const ko = {
emptyBtn: '입장권 예매',
detailTitle: '예매 상세·취소',
},
+ vnav: {
+ tabHome: '홈',
+ tabEvents: '행사',
+ tabTickets: '티켓',
+ tabOnsite: '현장',
+ tabMy: '마이',
+ },
+ vhome: {
+ hello: '{{name}}님, 환영합니다',
+ subtitle: '오늘의 전시를 편하게 둘러보세요',
+ noEvent: '참여 중인 행사가 없습니다 — 입장권을 예매하면 여기에 표시됩니다.',
+ tileTickets: '내 티켓',
+ tileTicketsDesc: '입장 QR · 배지',
+ tileEvents: '행사',
+ tileEventsDesc: '공지 · 일정',
+ tileOnsite: '현장',
+ tileOnsiteDesc: '혼잡 · 주차',
+ aiTitle: 'AI 관람 도우미',
+ aiDesc: '혼잡·주차·공지를 실시간으로 안내합니다. 곧 부스 검색·길찾기도 추가됩니다.',
+ },
+ vevents: {
+ title: '행사',
+ empty: '참여 중인 행사가 없습니다 — 공개 공지를 표시합니다.',
+ },
+ notices: {
+ title: '라이브 공지',
+ live: 'LIVE',
+ pinned: '고정',
+ empty: '등록된 공지가 없습니다.',
+ degraded: '공지를 불러오지 못했습니다 (연결 확인).',
+ cat: {
+ URGENT: '긴급',
+ PROGRAM: '프로그램',
+ GENERAL: '일반',
+ INFO: '안내',
+ },
+ },
+ onsite: {
+ segCongestion: '혼잡도',
+ segParking: '주차',
+ },
+ congestion: {
+ overall: '종합 혼잡도',
+ onSite: '현장 인원',
+ people: '{{n}}명',
+ gates: '입장 게이트',
+ parking: '주차장',
+ popular: '인기 공간',
+ empty: '표시할 혼잡 정보가 없습니다.',
+ degraded: '혼잡 정보를 불러오지 못했습니다 (연결 확인).',
+ },
+ parking: {
+ title: '주차 현황',
+ empty: '주차장 정보가 없습니다.',
+ degraded: '주차 정보를 불러오지 못했습니다 (연결 확인).',
+ available: '여유 {{n}}면',
+ capacity: '전체 {{n}}면',
+ rate: '시간당 {{n}}원',
+ daily: '일 최대 {{n}}원',
+ passPrice: '{{n}}원',
+ buyPass: '주차권 구매',
+ myPasses: '내 주차권',
+ noPasses: '보유한 주차권이 없습니다.',
+ loginRequired: '주차권 구매·조회는 로그인이 필요합니다.',
+ purchaseTitle: '사전 주차권 구매',
+ selectLot: '주차장',
+ useDate: '이용일',
+ plate: '차량번호 (선택)',
+ platePlaceholder: '예: 123가4567',
+ plateNote: '차량번호는 마스킹되어 저장되지 않습니다.',
+ payNote: '결제는 시뮬레이션(mock)입니다 — 실제 청구되지 않습니다.',
+ confirm: '구매',
+ purchaseErr: '주차권 구매에 실패했습니다.',
+ prevDay: '이전 날짜',
+ nextDay: '다음 날짜',
+ passStatus: {
+ PAID: '결제완료',
+ USED: '사용됨',
+ CANCELLED: '취소',
+ },
+ },
+ vmy: {
+ tickets: '내 티켓',
+ ticketsDesc: '입장 QR · 배지 전환',
+ parking: '내 주차권',
+ parkingDesc: '주차 현황 · 사전 주차권',
+ },
} as const;
export type Resource = typeof ko;
diff --git a/mobile/lib/i18n/locales/zh.ts b/mobile/lib/i18n/locales/zh.ts
index 3e8b9eb..fd134ae 100644
--- a/mobile/lib/i18n/locales/zh.ts
+++ b/mobile/lib/i18n/locales/zh.ts
@@ -200,4 +200,91 @@ export const zh: Translations = {
emptyBtn: '预订入场券',
detailTitle: '预订详情 · 取消',
},
+ vnav: {
+ tabHome: '首页',
+ tabEvents: '展会',
+ tabTickets: '门票',
+ tabOnsite: '现场',
+ tabMy: '我的',
+ },
+ vhome: {
+ hello: '{{name}},欢迎',
+ subtitle: '轻松逛今天的展会',
+ noEvent: '您尚未参加任何展会 — 预订门票后将显示在此处。',
+ tileTickets: '我的门票',
+ tileTicketsDesc: '入场二维码 · 徽章',
+ tileEvents: '展会',
+ tileEventsDesc: '公告 · 日程',
+ tileOnsite: '现场',
+ tileOnsiteDesc: '拥挤 · 停车',
+ aiTitle: 'AI 观展助手',
+ aiDesc: '实时提供拥挤、停车与公告信息。展位搜索与导航即将上线。',
+ },
+ vevents: {
+ title: '展会',
+ empty: '您尚未参加任何展会 — 显示公开公告。',
+ },
+ notices: {
+ title: '实时公告',
+ live: 'LIVE',
+ pinned: '置顶',
+ empty: '暂无公告。',
+ degraded: '无法加载公告(请检查网络)。',
+ cat: {
+ URGENT: '紧急',
+ PROGRAM: '节目',
+ GENERAL: '一般',
+ INFO: '提示',
+ },
+ },
+ onsite: {
+ segCongestion: '拥挤度',
+ segParking: '停车',
+ },
+ congestion: {
+ overall: '综合拥挤度',
+ onSite: '现场人数',
+ people: '{{n}}人',
+ gates: '入场闸口',
+ parking: '停车场',
+ popular: '热门区域',
+ empty: '暂无拥挤信息。',
+ degraded: '无法加载拥挤信息(请检查网络)。',
+ },
+ parking: {
+ title: '停车现况',
+ empty: '暂无停车场信息。',
+ degraded: '无法加载停车信息(请检查网络)。',
+ available: '空位 {{n}}',
+ capacity: '共 {{n}}',
+ rate: '每小时 {{n}} 韩元',
+ daily: '每日最高 {{n}} 韩元',
+ passPrice: '{{n}} 韩元',
+ buyPass: '购买停车券',
+ myPasses: '我的停车券',
+ noPasses: '暂无停车券。',
+ loginRequired: '购买或查看停车券需要登录。',
+ purchaseTitle: '购买预约停车券',
+ selectLot: '停车场',
+ useDate: '使用日期',
+ plate: '车牌号(可选)',
+ platePlaceholder: '例如:123가4567',
+ plateNote: '车牌号将被掩码且不予保存。',
+ payNote: '支付为模拟(mock)— 不会实际收费。',
+ confirm: '购买',
+ purchaseErr: '停车券购买失败。',
+ prevDay: '前一天',
+ nextDay: '后一天',
+ passStatus: {
+ PAID: '已支付',
+ USED: '已使用',
+ CANCELLED: '已取消',
+ },
+ },
+ vmy: {
+ tickets: '我的门票',
+ ticketsDesc: '入场二维码 · 徽章转换',
+ parking: '我的停车券',
+ parkingDesc: '停车现况 · 预约停车券',
+ },
};
diff --git a/mobile/lib/roleTrack.ts b/mobile/lib/roleTrack.ts
index 0ac610f..89203eb 100644
--- a/mobile/lib/roleTrack.ts
+++ b/mobile/lib/roleTrack.ts
@@ -52,14 +52,14 @@ export function resolveLandingTrack({ user, workspaces }: LandingInput): Track {
* 트랙 → 로그인 직후 랜딩 라우트(모바일).
* · admin/ops/business → 탭 홈(/(tabs)) — 모바일엔 별도 admin 화면 없음, 홈이 역할 진입 허브
* · agency(장치·시공) → 현장 탭(/(tabs)/field) — 체크리스트 허브
- * · visitor(관람객) → 티켓 지갑(/tickets) — B2C 진입
+ * · visitor(관람객) → 관람객(B2C) 탭 셸(/(visitor)) — 홈·행사·티켓·현장·마이
*/
export function landingPathForTrack(track: Track): string {
switch (track) {
case 'agency':
return '/(tabs)/field';
case 'visitor':
- return '/tickets';
+ return '/(visitor)';
case 'admin':
case 'ops':
case 'business':
diff --git a/mobile/lib/types.ts b/mobile/lib/types.ts
index 9e2d1bb..9baeb98 100644
--- a/mobile/lib/types.ts
+++ b/mobile/lib/types.ts
@@ -129,3 +129,94 @@ export interface OtpStatusDto {
export interface AvatarUploadResponse {
photoUrl: string;
}
+
+// ── B2C 관람객: 혼잡·주차 (parking_congestion_contract.md) ──
+/** 혼잡/점유 수준. 여유/보통/혼잡 3단계. */
+export type CongestionLevel = 'FREE' | 'NORMAL' | 'BUSY';
+
+/** 주차장 현황 — GET /api/public/parking/lots (공개). */
+export interface ParkingLotStatusDto {
+ lotId: string;
+ code: string;
+ name: string;
+ exhibitionCenter: number | null; // 1|2|null(공용)
+ totalCapacity: number;
+ occupied: number;
+ available: number;
+ occupancyPercent: number;
+ congestionLevel: CongestionLevel;
+ congestionLabel: string; // 여유|보통|혼잡
+ hourlyRate: number;
+ dailyMax: number | null;
+ passPrice: number; // 사전 주차권(1일권) 가격
+ note?: string | null;
+ updatedAt: string;
+}
+
+/** 사전 주차권 구매 요청 — POST /api/parking/passes (인증). */
+export interface PurchasePassRequest {
+ lotId: string;
+ useDate: string; // YYYY-MM-DD, 오늘 이후
+ eventId?: string;
+ vehiclePlate?: string; // 마스킹 후 폐기·미저장
+ payMethod?: 'card' | 'easy' | 'bank';
+}
+
+/** 주차권 — POST/GET 응답 공통 shape. */
+export interface ParkingPassDto {
+ passNo: string;
+ status: 'PAID' | 'CANCELLED' | 'USED';
+ lotId: string;
+ lotName: string;
+ eventId: string | null;
+ useDate: string;
+ vehiclePlateMasked: string | null; // 원문 미저장(마스킹만)
+ amount: number;
+ payMethod: string | null;
+ payApprovalNo: string | null;
+ issuedAt: string;
+}
+
+/** 내 주차권 — GET /api/parking/passes/me. */
+export interface MyPassesDto {
+ passes: ParkingPassDto[];
+}
+
+/** 혼잡 영역 — 게이트/주차/인기 공간 공통. occupancyPercent는 계측 부재 시 null. */
+export interface CongestionAreaDto {
+ id: string;
+ label: string;
+ level: CongestionLevel;
+ levelLabel: string;
+ occupancyPercent: number | null;
+}
+
+/** 혼잡 요약 — GET /api/public/congestion?eventId= (공개). */
+export interface CongestionOverviewDto {
+ eventId: string;
+ overallLevel: CongestionLevel;
+ overallLabel: string;
+ onSiteCount: number;
+ entryGates: CongestionAreaDto[];
+ parking: CongestionAreaDto[];
+ popularSessions: CongestionAreaDto[];
+ updatedAt: string;
+}
+
+// ── B2C 관람객: 라이브 공지 (cms_backlog_contract.md) ──
+/** 공지 카테고리 — 배지 색상 매핑(URGENT=red·PROGRAM=amber·INFO=blue·GENERAL=slate). */
+export type LiveNoticeCategory = 'URGENT' | 'PROGRAM' | 'GENERAL' | 'INFO';
+
+/** 라이브 공지 — GET /api/public/events/{eventId}/live-notices (공개). */
+export interface LiveNoticeDto {
+ id: string;
+ eventId: string;
+ category: LiveNoticeCategory;
+ title: string;
+ body?: string | null;
+ pinned: boolean;
+ status: string; // published|archived
+ authorName?: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
diff --git a/mobile/lib/visitor.ts b/mobile/lib/visitor.ts
new file mode 100644
index 0000000..4f6dbb3
--- /dev/null
+++ b/mobile/lib/visitor.ts
@@ -0,0 +1,47 @@
+/*
+ * 관람객(B2C) API 배선 — 공개 혼잡/주차/공지 + 인증 주차권.
+ * 계약: _workspace/parking_congestion_contract.md · src/backend/_workspace/cms_backlog_contract.md.
+ * 봉투 언랩·인증 헤더는 lib/api.ts가 처리. 공개 조회는 anonymous(토큰 미첨부).
+ * 주차권 구매/조회는 인증(JWT). PII(차량번호)는 서버가 마스킹해 반환 — 원문 미저장.
+ */
+import { api } from './api';
+import type {
+ CongestionOverviewDto,
+ LiveNoticeDto,
+ MyPassesDto,
+ ParkingLotStatusDto,
+ ParkingPassDto,
+ PurchasePassRequest,
+} from './types';
+
+/** 주차 현황 — 공개(비로그인). eventId는 선택. */
+export function getParkingLots(eventId?: string): Promise {
+ const q = eventId ? `?eventId=${encodeURIComponent(eventId)}` : '';
+ return api.get(`/api/public/parking/lots${q}`, { anonymous: true });
+}
+
+/** 혼잡 요약 — 공개. eventId 필수. */
+export function getCongestion(eventId: string): Promise {
+ return api.get(
+ `/api/public/congestion?eventId=${encodeURIComponent(eventId)}`,
+ { anonymous: true },
+ );
+}
+
+/** 라이브 공지 — 공개. 게시(pinned 우선→최신) 건만. */
+export function getLiveNotices(eventId: string, limit = 100): Promise {
+ return api.get(
+ `/api/public/events/${encodeURIComponent(eventId)}/live-notices?limit=${limit}`,
+ { anonymous: true },
+ );
+}
+
+/** 사전 주차권 구매(mock 결제) — 인증. 구매자 = JWT 주체. */
+export function purchaseParkingPass(req: PurchasePassRequest): Promise {
+ return api.post('/api/parking/passes', req);
+}
+
+/** 내 주차권 조회 — 인증(본인 소유분만). */
+export function getMyParkingPasses(): Promise {
+ return api.get('/api/parking/passes/me');
+}