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