/* * 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' }, });