kintex/mobile/app/(visitor)/onsite.tsx
zio 0933dbea41 feat(mobile): B2C visitor tab, congestion pill, live notice feed, ticket wallet, brand logo (v0.2.2 prep)
- (visitor) route group, visitor lib, tickets wallet
- CongestionPill + LiveNoticeFeed components
- login/tickets screens, i18n 4 locales, wordmark assets, splash

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:41:57 +09:00

673 lines
22 KiB
TypeScript

/*
* 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<Seg>('congestion');
return (
<View style={styles.flex}>
{/* 세그먼트 */}
<View style={styles.segment}>
{(['congestion', 'parking'] as Seg[]).map((s) => {
const on = seg === s;
return (
<Pressable
key={s}
accessibilityRole="tab"
accessibilityState={{ selected: on }}
style={[styles.segBtn, on && styles.segBtnOn]}
onPress={() => setSeg(s)}
>
<Text style={[styles.segText, on && styles.segTextOn]}>
{t(s === 'congestion' ? 'onsite.segCongestion' : 'onsite.segParking')}
</Text>
</Pressable>
);
})}
</View>
{seg === 'congestion' ? (
<CongestionView eventId={eventId} />
) : (
<ParkingView eventId={eventId} hasToken={!!token} />
)}
</View>
);
}
// ── 혼잡도 ──
function CongestionView({ eventId }: { eventId: string }) {
const { t } = useTranslation();
const [data, setData] = useState<CongestionOverviewDto | null>(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 (
<View style={styles.loading}>
<ActivityIndicator color={colors.primary600} size="large" />
</View>
);
}
if (!data) {
return (
<ScrollView contentContainerStyle={styles.scroll}>
<Banner tone={degraded ? 'degraded' : 'info'}>
{degraded ? t('congestion.degraded') : t('congestion.empty')}
</Banner>
</ScrollView>
);
}
return (
<ScrollView contentContainerStyle={styles.scroll}>
{/* 종합 */}
<View style={styles.overallCard}>
<View style={styles.overallTop}>
<Text style={styles.overallLabel}>{t('congestion.overall')}</Text>
<CongestionPill level={data.overallLevel} label={data.overallLabel} />
</View>
<View style={styles.overallMetric}>
<Ionicons name="people-outline" size={18} color={colors.neutral500} />
<Text style={styles.overallCount}>
{t('congestion.onSite')} {t('congestion.people', { n: won(data.onSiteCount) })}
</Text>
</View>
</View>
<AreaGroup title={t('congestion.gates')} icon="enter-outline" areas={data.entryGates} />
<AreaGroup title={t('congestion.popular')} icon="star-outline" areas={data.popularSessions} />
<AreaGroup title={t('congestion.parking')} icon="car-outline" areas={data.parking} />
</ScrollView>
);
}
function AreaGroup({
title,
icon,
areas,
}: {
title: string;
icon: keyof typeof Ionicons.glyphMap;
areas: CongestionAreaDto[];
}) {
if (!areas || areas.length === 0) return null;
return (
<View style={styles.group}>
<View style={styles.groupHeader}>
<Ionicons name={icon} size={16} color={colors.primary600} />
<Text style={styles.groupTitle}>{title}</Text>
</View>
{areas.map((a) => (
<View key={a.id} style={styles.areaRow}>
<Text style={styles.areaLabel} numberOfLines={1}>
{a.label}
</Text>
<CongestionPill level={a.level} label={a.levelLabel} percent={a.occupancyPercent} size="sm" />
</View>
))}
</View>
);
}
// ── 주차 ──
function ParkingView({ eventId, hasToken }: { eventId: string; hasToken: boolean }) {
const { t } = useTranslation();
const [lots, setLots] = useState<ParkingLotStatusDto[] | null>(null);
const [degraded, setDegraded] = useState(false);
const [passes, setPasses] = useState<ParkingPassDto[] | null>(null);
const [purchaseFor, setPurchaseFor] = useState<ParkingLotStatusDto | null>(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 (
<ScrollView contentContainerStyle={styles.scroll}>
{/* 주차 현황 */}
<View style={styles.groupHeader}>
<Ionicons name="car-outline" size={16} color={colors.primary600} />
<Text style={styles.groupTitle}>{t('parking.title')}</Text>
</View>
{lots == null ? (
<View style={styles.loading}>
<ActivityIndicator color={colors.primary600} />
</View>
) : lots.length === 0 ? (
<Banner tone={degraded ? 'degraded' : 'info'}>
{degraded ? t('parking.degraded') : t('parking.empty')}
</Banner>
) : (
lots.map((lot) => (
<LotCard key={lot.lotId} lot={lot} onBuy={() => setPurchaseFor(lot)} canBuy={hasToken} />
))
)}
{/* 내 주차권 */}
<View style={[styles.groupHeader, { marginTop: spacing.sm }]}>
<Ionicons name="pricetag-outline" size={16} color={colors.primary600} />
<Text style={styles.groupTitle}>{t('parking.myPasses')}</Text>
</View>
{!hasToken ? (
<Pressable style={styles.loginNote} onPress={() => router.push('/login')}>
<Text style={styles.loginNoteText}>{t('parking.loginRequired')}</Text>
<Ionicons name="chevron-forward" size={16} color={colors.primary600} />
</Pressable>
) : passes == null ? (
<View style={styles.loading}>
<ActivityIndicator color={colors.primary600} />
</View>
) : passes.length === 0 ? (
<Text style={styles.emptyNote}>{t('parking.noPasses')}</Text>
) : (
passes.map((p) => <PassCard key={p.passNo} pass={p} />)
)}
<PurchaseModal
lot={purchaseFor}
onClose={() => setPurchaseFor(null)}
onPurchased={() => {
setPurchaseFor(null);
loadPasses();
loadLots();
}}
eventId={eventId}
/>
</ScrollView>
);
}
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 (
<View style={styles.lotCard}>
<View style={styles.lotTop}>
<Text style={styles.lotName} numberOfLines={1}>
{lot.name}
</Text>
<CongestionPill level={lot.congestionLevel} label={lot.congestionLabel} percent={pct} size="sm" />
</View>
{/* 점유율 바 */}
<View style={styles.bar}>
<View style={[styles.barFill, { width: `${pct}%` }]} />
</View>
<View style={styles.lotMetaRow}>
<Text style={styles.lotMeta}>
{t('parking.available', { n: won(lot.available) })} · {t('parking.capacity', { n: won(lot.totalCapacity) })}
</Text>
</View>
<View style={styles.lotMetaRow}>
<Text style={styles.lotMeta}>
{t('parking.rate', { n: won(lot.hourlyRate) })}
{lot.dailyMax != null ? ` · ${t('parking.daily', { n: won(lot.dailyMax) })}` : ''}
</Text>
</View>
<Pressable
accessibilityRole="button"
style={[styles.buyBtn, !canBuy && styles.buyBtnGhost]}
onPress={onBuy}
disabled={!canBuy}
>
<Ionicons
name="ticket-outline"
size={16}
color={canBuy ? colors.white : colors.neutral500}
/>
<Text style={[styles.buyText, !canBuy && { color: colors.neutral500 }]}>
{t('parking.buyPass')} · {t('parking.passPrice', { n: won(lot.passPrice) })}
</Text>
</Pressable>
{!canBuy ? <Text style={styles.buyHint}>{t('parking.loginRequired')}</Text> : null}
</View>
);
}
const PASS_STATUS_TONE: Record<ParkingPassDto['status'], { color: string; bg: string }> = {
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 (
<View style={[styles.passCard, dim && styles.passDim]}>
<View style={styles.passTop}>
<Text style={styles.passLot} numberOfLines={1}>
{pass.lotName}
</Text>
<View style={[styles.passBadge, { backgroundColor: tone.bg }]}>
<Text style={[styles.passBadgeText, { color: tone.color }]}>
{t(`parking.passStatus.${pass.status}` as const)}
</Text>
</View>
</View>
<Text style={styles.passMeta}>
{pass.useDate} · {pass.passNo}
</Text>
<View style={styles.passBottom}>
<Text style={styles.passMeta}>
{pass.vehiclePlateMasked ?? '-'}
</Text>
<Text style={styles.passAmount}>{t('parking.passPrice', { n: won(pass.amount) })}</Text>
</View>
</View>
);
}
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<string | null>(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 (
<Modal visible={!!lot} transparent animationType="slide" onRequestClose={onClose}>
<View style={styles.modalBackdrop}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{t('parking.purchaseTitle')}</Text>
<Pressable onPress={onClose} accessibilityLabel={t('common.close')} hitSlop={8}>
<Ionicons name="close" size={22} color={colors.neutral500} />
</Pressable>
</View>
{lot ? (
<>
{/* 선택 주차장 */}
<Text style={styles.modalLabel}>{t('parking.selectLot')}</Text>
<View style={styles.modalLotBox}>
<Text style={styles.modalLotName}>{lot.name}</Text>
<Text style={styles.modalLotPrice}>{t('parking.passPrice', { n: won(lot.passPrice) })}</Text>
</View>
{/* 이용일 스텝퍼 */}
<Text style={styles.modalLabel}>{t('parking.useDate')}</Text>
<View style={styles.dateRow}>
<Pressable
style={styles.dateBtn}
disabled={dayOffset <= 1}
onPress={() => setDayOffset((v) => Math.max(1, v - 1))}
accessibilityLabel={t('parking.prevDay')}
>
<Ionicons
name="chevron-back"
size={20}
color={dayOffset <= 1 ? colors.neutral200 : colors.neutral700}
/>
</Pressable>
<Text style={styles.dateText}>{ymd(useDate)}</Text>
<Pressable
style={styles.dateBtn}
disabled={dayOffset >= 14}
onPress={() => setDayOffset((v) => Math.min(14, v + 1))}
accessibilityLabel={t('parking.nextDay')}
>
<Ionicons
name="chevron-forward"
size={20}
color={dayOffset >= 14 ? colors.neutral200 : colors.neutral700}
/>
</Pressable>
</View>
{/* 차량번호(선택) */}
<Text style={styles.modalLabel}>{t('parking.plate')}</Text>
<TextInput
style={styles.input}
value={plate}
onChangeText={setPlate}
placeholder={t('parking.platePlaceholder')}
placeholderTextColor={colors.neutral500}
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.modalNote}>{t('parking.plateNote')}</Text>
<Text style={styles.modalNote}>{t('parking.payNote')}</Text>
{err ? <Banner tone="error">{err}</Banner> : null}
<Pressable
style={[styles.confirmBtn, busy && styles.confirmBusy]}
onPress={submit}
disabled={busy}
accessibilityRole="button"
>
{busy ? (
<ActivityIndicator color={colors.white} />
) : (
<Text style={styles.confirmText}>{t('parking.confirm')}</Text>
)}
</Pressable>
</>
) : null}
</View>
</View>
</Modal>
);
}
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' },
});