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>
This commit is contained in:
parent
9faeb8892b
commit
0933dbea41
@ -4,7 +4,7 @@
|
|||||||
"slug": "kintex",
|
"slug": "kintex",
|
||||||
"owner": "zioinfo",
|
"owner": "zioinfo",
|
||||||
"scheme": "kintex",
|
"scheme": "kintex",
|
||||||
"version": "0.2.0",
|
"version": "0.2.2",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
@ -28,7 +28,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"package": "kr.co.zioinfo.kintex",
|
"package": "kr.co.zioinfo.kintex",
|
||||||
"versionCode": 2,
|
"versionCode": 4,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
"backgroundColor": "#0066B3"
|
"backgroundColor": "#0066B3"
|
||||||
|
|||||||
69
mobile/app/(visitor)/_layout.tsx
Normal file
69
mobile/app/(visitor)/_layout.tsx
Normal file
@ -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 <Redirect href="/login" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
tabBarActiveTintColor: colors.primary600,
|
||||||
|
tabBarInactiveTintColor: colors.neutral500,
|
||||||
|
headerStyle: { backgroundColor: colors.white },
|
||||||
|
headerTitleStyle: { fontWeight: '600', color: colors.neutral900 },
|
||||||
|
tabBarStyle: { borderTopColor: colors.neutral200 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: t('vnav.tabHome'),
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" color={color} size={size} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="events"
|
||||||
|
options={{
|
||||||
|
title: t('vnav.tabEvents'),
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="calendar-outline" color={color} size={size} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="tickets"
|
||||||
|
options={{
|
||||||
|
title: t('vnav.tabTickets'),
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="ticket-outline" color={color} size={size} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="onsite"
|
||||||
|
options={{
|
||||||
|
title: t('vnav.tabOnsite'),
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="navigate-outline" color={color} size={size} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="my"
|
||||||
|
options={{
|
||||||
|
title: t('vnav.tabMy'),
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="person-outline" color={color} size={size} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
mobile/app/(visitor)/events.tsx
Normal file
94
mobile/app/(visitor)/events.tsx
Normal file
@ -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<string | null>(
|
||||||
|
activeWorkspace?.eventId ?? workspaces[0]?.eventId ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedWs = workspaces.find((w) => w.eventId === selected) ?? null;
|
||||||
|
const feedEventId = selected ?? DEFAULT_PUBLIC_EVENT_ID;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.scroll}>
|
||||||
|
<Text style={styles.title}>{t('vevents.title')}</Text>
|
||||||
|
|
||||||
|
{workspaces.length === 0 ? (
|
||||||
|
<Banner tone="info">{t('vevents.empty')}</Banner>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 참여 행사 선택 칩 */}
|
||||||
|
<View style={styles.chips}>
|
||||||
|
{workspaces.map((w) => {
|
||||||
|
const on = w.eventId === selected;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={w.eventId}
|
||||||
|
onPress={() => setSelected(w.eventId)}
|
||||||
|
style={[styles.chip, on && styles.chipOn]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.chipText, on && styles.chipTextOn]} numberOfLines={1}>
|
||||||
|
{w.eventName}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 선택 행사 요약 카드 */}
|
||||||
|
{selectedWs ? (
|
||||||
|
<Card accent="none">
|
||||||
|
<View style={styles.eventTop}>
|
||||||
|
<Text style={styles.eventName} numberOfLines={2}>
|
||||||
|
{selectedWs.eventName}
|
||||||
|
</Text>
|
||||||
|
<DdayChip dday={selectedWs.dday} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.eventMeta}>
|
||||||
|
{selectedWs.hallLabel} · {selectedWs.startDate} ~ {selectedWs.endDate}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 라이브 공지(전체) */}
|
||||||
|
<LiveNoticeFeed eventId={feedEventId} />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
139
mobile/app/(visitor)/index.tsx
Normal file
139
mobile/app/(visitor)/index.tsx
Normal file
@ -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 (
|
||||||
|
<ScrollView contentContainerStyle={styles.scroll}>
|
||||||
|
<Text style={styles.hello}>
|
||||||
|
{t('vhome.hello', { name: user?.displayName ?? t('common.user') })}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.sub}>{t('vhome.subtitle')}</Text>
|
||||||
|
|
||||||
|
{/* 활성 행사 요약(참여 행사가 있을 때) */}
|
||||||
|
{activeWorkspace ? (
|
||||||
|
<Card accent="none">
|
||||||
|
<View style={styles.eventTop}>
|
||||||
|
<Text style={styles.eventName} numberOfLines={1}>
|
||||||
|
{activeWorkspace.eventName}
|
||||||
|
</Text>
|
||||||
|
<DdayChip dday={activeWorkspace.dday} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.eventMeta}>
|
||||||
|
{activeWorkspace.hallLabel} · {activeWorkspace.startDate} ~ {activeWorkspace.endDate}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Banner tone="info">{t('vhome.noEvent')}</Banner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 라이브 공지 요약(상단 3건) */}
|
||||||
|
<LiveNoticeFeed eventId={eventId} max={3} />
|
||||||
|
|
||||||
|
{/* 빠른 이동 타일 */}
|
||||||
|
<View style={styles.tiles}>
|
||||||
|
{tiles.map((tile) => (
|
||||||
|
<Pressable key={tile.key} style={styles.tile} onPress={tile.onPress}>
|
||||||
|
<View style={styles.tileIcon}>
|
||||||
|
<Ionicons name={tile.icon} size={24} color={colors.primary600} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.tileLabel}>{tile.label}</Text>
|
||||||
|
<Text style={styles.tileDesc}>{tile.desc}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* AI 관람 도우미 안내 */}
|
||||||
|
<Card accent="ai">
|
||||||
|
<View style={styles.aiRow}>
|
||||||
|
<Ionicons name="sparkles-outline" size={20} color={colors.aiAccent} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.aiTitle}>{t('vhome.aiTitle')}</Text>
|
||||||
|
<Text style={styles.aiDesc}>{t('vhome.aiDesc')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
177
mobile/app/(visitor)/my.tsx
Normal file
177
mobile/app/(visitor)/my.tsx
Normal file
@ -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<string>(t('common.loading'));
|
||||||
|
|
||||||
|
const checkHealth = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<HealthDto>('/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 (
|
||||||
|
<ScrollView contentContainerStyle={styles.scroll}>
|
||||||
|
{/* 내 정보 진입 */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push('/profile')}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('more.openProfile')}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<View style={styles.meRow}>
|
||||||
|
<Avatar name={user?.displayName} size={48} />
|
||||||
|
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||||
|
<Text style={styles.name}>{user?.displayName ?? t('common.user')}</Text>
|
||||||
|
<Text style={styles.meta}>{t('more.accountGeneral')}</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={colors.neutral500} />
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* 바로가기 — 내 티켓 · 내 주차권 */}
|
||||||
|
<NavRow
|
||||||
|
icon="ticket-outline"
|
||||||
|
title={t('vmy.tickets')}
|
||||||
|
desc={t('vmy.ticketsDesc')}
|
||||||
|
onPress={() => router.push('/(visitor)/tickets')}
|
||||||
|
/>
|
||||||
|
<NavRow
|
||||||
|
icon="car-outline"
|
||||||
|
title={t('vmy.parking')}
|
||||||
|
desc={t('vmy.parkingDesc')}
|
||||||
|
onPress={() => router.push('/(visitor)/onsite')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 언어 */}
|
||||||
|
<LanguageSelector />
|
||||||
|
|
||||||
|
{/* 서버 상태 */}
|
||||||
|
<Card>
|
||||||
|
<Row label={t('more.apiServer')} value={API_BASE} />
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<Pressable style={styles.healthRow} onPress={checkHealth}>
|
||||||
|
<Row label={t('more.serverStatus')} value={health} />
|
||||||
|
<Ionicons name="refresh" size={18} color={colors.primary600} />
|
||||||
|
</Pressable>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Pressable style={styles.signOut} onPress={onSignOut}>
|
||||||
|
<Ionicons name="log-out-outline" size={20} color={colors.error} />
|
||||||
|
<Text style={styles.signOutText}>{t('more.signOut')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Text style={styles.version}>{t('more.version')}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavRow({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable style={styles.navRow} onPress={onPress}>
|
||||||
|
<View style={styles.navIcon}>
|
||||||
|
<Ionicons name={icon} size={22} color={colors.primary600} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.navTitle}>{title}</Text>
|
||||||
|
<Text style={styles.navDesc}>{desc}</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={colors.neutral500} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.rowLabel}>{label}</Text>
|
||||||
|
<Text style={styles.rowValue} numberOfLines={1}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
672
mobile/app/(visitor)/onsite.tsx
Normal file
672
mobile/app/(visitor)/onsite.tsx
Normal file
@ -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<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' },
|
||||||
|
});
|
||||||
10
mobile/app/(visitor)/tickets.tsx
Normal file
10
mobile/app/(visitor)/tickets.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
* SCR-M15 [모바일] 관람객(B2C) 티켓 탭 — 공용 TicketWallet 본문 재사용.
|
||||||
|
* 헤더는 탭 레이아웃이 소유. 기존 /tickets 라우트와 동일 본문(편입).
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { TicketWallet } from '../../components/tickets/TicketWallet';
|
||||||
|
|
||||||
|
export default function VisitorTicketsScreen() {
|
||||||
|
return <TicketWallet />;
|
||||||
|
}
|
||||||
@ -30,6 +30,7 @@ function RootStack() {
|
|||||||
<Stack.Screen name="register" options={{ title: t('nav.register') }} />
|
<Stack.Screen name="register" options={{ title: t('nav.register') }} />
|
||||||
<Stack.Screen name="forgot-password" options={{ title: t('nav.forgotPassword') }} />
|
<Stack.Screen name="forgot-password" options={{ title: t('nav.forgotPassword') }} />
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="(visitor)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="profile" options={{ title: t('nav.profile') }} />
|
<Stack.Screen name="profile" options={{ title: t('nav.profile') }} />
|
||||||
<Stack.Screen name="checklist" options={{ title: t('nav.checklist') }} />
|
<Stack.Screen name="checklist" options={{ title: t('nav.checklist') }} />
|
||||||
<Stack.Screen name="inspection" options={{ title: t('nav.inspection') }} />
|
<Stack.Screen name="inspection" options={{ title: t('nav.inspection') }} />
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { Link, router } from 'expo-router';
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
|
Image,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
@ -104,9 +105,15 @@ export default function LoginScreen() {
|
|||||||
contentContainerStyle={[styles.scroll, { paddingTop: insets.top + spacing.lg }]}
|
contentContainerStyle={[styles.scroll, { paddingTop: insets.top + spacing.lg }]}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
{/* 브랜드 패널 */}
|
{/* 브랜드 패널 — 정식 CI 워드마크(CI_01, 딥블루 패널용 화이트 변형) */}
|
||||||
<View style={styles.brand}>
|
<View style={styles.brand}>
|
||||||
<Text style={styles.brandMark}>KINTEX</Text>
|
<Image
|
||||||
|
source={require('../assets/logo-wordmark-white.png')}
|
||||||
|
style={styles.brandMark}
|
||||||
|
resizeMode="contain"
|
||||||
|
accessibilityRole="image"
|
||||||
|
accessibilityLabel="KINTEX"
|
||||||
|
/>
|
||||||
<Text style={styles.brandTitle}>{t('login.brandTitle')}</Text>
|
<Text style={styles.brandTitle}>{t('login.brandTitle')}</Text>
|
||||||
<Text style={styles.brandCaption}>{t('login.brandCaption')}</Text>
|
<Text style={styles.brandCaption}>{t('login.brandCaption')}</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -193,7 +200,7 @@ const styles = StyleSheet.create({
|
|||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
gap: 8,
|
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 },
|
brandTitle: { color: colors.white, fontSize: type.h2.fontSize, fontWeight: '700', lineHeight: 30 },
|
||||||
brandCaption: { color: 'rgba(255,255,255,0.75)', fontSize: type.caption.fontSize },
|
brandCaption: { color: 'rgba(255,255,255,0.75)', fontSize: type.caption.fontSize },
|
||||||
card: {
|
card: {
|
||||||
|
|||||||
@ -1,191 +1,18 @@
|
|||||||
/*
|
/*
|
||||||
* SCR-M15 [모바일] 내 티켓 지갑 — 관람객(B2C) 트랙.
|
* SCR-M15 [모바일] 내 티켓 지갑 — 레거시 스택 라우트(/tickets).
|
||||||
* 필터 탭(진행중·예정·지난) + 티켓 카드 리스트 + QR 풀스크린 뷰어.
|
* 본문은 공용 TicketWallet 컴포넌트(관람객 탭 (visitor)/tickets와 동일). 딥링크·예매 완료 복귀 호환용.
|
||||||
* 배지 전환 안내 배너 + 오프라인 표시 배지. 사용됨/취소 카드 흐리게.
|
|
||||||
* ※ M10(티켓)·M9(환불) 백엔드 미구현 → 샘플 데이터("샘플" 배지). API 호출 없음.
|
|
||||||
*/
|
*/
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Stack } from 'expo-router';
|
||||||
import { router, Stack } from 'expo-router';
|
import React from 'react';
|
||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { TicketWallet } from '../../components/tickets/TicketWallet';
|
||||||
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';
|
|
||||||
|
|
||||||
export default function TicketWalletScreen() {
|
export default function TicketWalletScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// 티켓 QR 화면 — 캡처 차단(QR 재사용 방지) + 백그라운드 마스킹(B4/B7).
|
|
||||||
useSecureScreen('tickets');
|
|
||||||
|
|
||||||
const [filter, setFilter] = useState<TicketFilter>('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 (
|
return (
|
||||||
<View style={styles.flex}>
|
<>
|
||||||
<Stack.Screen options={{ title: t('nav.ticketsIndex'), headerTitleAlign: 'center' }} />
|
<Stack.Screen options={{ title: t('nav.ticketsIndex'), headerTitleAlign: 'center' }} />
|
||||||
|
<TicketWallet />
|
||||||
<ScrollView contentContainerStyle={styles.scroll}>
|
</>
|
||||||
{/* 오프라인 표시 배지 */}
|
|
||||||
<View style={styles.offlineWrap}>
|
|
||||||
<View style={styles.offlineBadge}>
|
|
||||||
<View style={styles.offlineDot} />
|
|
||||||
<Text style={styles.offlineText}>{t('tickets.offline')}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 필터 탭(세그먼트) */}
|
|
||||||
<View style={styles.segment}>
|
|
||||||
{FILTER_TABS.map((tab) => {
|
|
||||||
const on = filter === tab.key;
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={tab.key}
|
|
||||||
accessibilityRole="tab"
|
|
||||||
accessibilityState={{ selected: on }}
|
|
||||||
style={[styles.segBtn, on && styles.segBtnOn]}
|
|
||||||
onPress={() => setFilter(tab.key)}
|
|
||||||
>
|
|
||||||
<Text style={[styles.segText, on && styles.segTextOn]}>{tab.label}</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 배지 전환 안내 배너 */}
|
|
||||||
<Banner tone="info">{t('tickets.badgeNotice')}</Banner>
|
|
||||||
|
|
||||||
{/* 티켓 리스트 / 빈 상태 */}
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<EmptyState />
|
|
||||||
) : (
|
|
||||||
filtered.map((tk) => (
|
|
||||||
<TicketCard key={tk.id} ticket={tk} onOpenQr={openQr} onDetail={openDetail} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text style={styles.footNote}>{t('tickets.footNote')}</Text>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<QrViewerModal
|
|
||||||
visible={qrOpen}
|
|
||||||
tickets={usableInView}
|
|
||||||
index={qrIndex}
|
|
||||||
onChangeIndex={setQrIndex}
|
|
||||||
onClose={() => setQrOpen(false)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<View style={styles.empty}>
|
|
||||||
<Ionicons name="ticket-outline" size={44} color={colors.neutral200} />
|
|
||||||
<Text style={styles.emptyTitle}>{t('tickets.emptyTitle')}</Text>
|
|
||||||
<Text style={styles.emptyBody}>{t('tickets.emptyBody')}</Text>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
style={styles.emptyBtn}
|
|
||||||
onPress={() => router.push('/tickets/select')}
|
|
||||||
>
|
|
||||||
<Text style={styles.emptyBtnText}>{t('tickets.emptyBtn')}</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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' },
|
|
||||||
});
|
|
||||||
|
|||||||
BIN
mobile/assets/logo-wordmark-white.png
Normal file
BIN
mobile/assets/logo-wordmark-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
mobile/assets/logo-wordmark.png
Normal file
BIN
mobile/assets/logo-wordmark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 54 KiB |
51
mobile/components/CongestionPill.tsx
Normal file
51
mobile/components/CongestionPill.tsx
Normal file
@ -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<CongestionLevel, { color: string; bg: string }> = {
|
||||||
|
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 (
|
||||||
|
<View style={[styles.pill, { backgroundColor: s.bg }, size === 'sm' && styles.pillSm]}>
|
||||||
|
<View style={[styles.dot, { backgroundColor: s.color }]} />
|
||||||
|
<Text style={[styles.text, { color: s.color }, size === 'sm' && styles.textSm]}>{text}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
173
mobile/components/LiveNoticeFeed.tsx
Normal file
173
mobile/components/LiveNoticeFeed.tsx
Normal file
@ -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<LiveNoticeCategory, { color: string; bg: string }> = {
|
||||||
|
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<LiveNoticeDto[] | null>(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 (
|
||||||
|
<View style={styles.wrap}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.headerLeft}>
|
||||||
|
<Ionicons name="megaphone-outline" size={18} color={colors.primary600} />
|
||||||
|
<Text style={styles.title}>{t('notices.title')}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.liveDot}>
|
||||||
|
<View style={styles.liveDotInner} />
|
||||||
|
<Text style={styles.liveText}>{t('notices.live')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{items == null ? (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<ActivityIndicator color={colors.primary600} />
|
||||||
|
</View>
|
||||||
|
) : visible.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>
|
||||||
|
{degraded ? t('notices.degraded') : t('notices.empty')}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<View style={styles.list}>
|
||||||
|
{degraded ? <Text style={styles.degradedNote}>{t('notices.degraded')}</Text> : null}
|
||||||
|
{visible.map((n) => (
|
||||||
|
<NoticeRow key={n.id} notice={n} catLabel={catLabel} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={[styles.row, urgent && styles.rowUrgent]}>
|
||||||
|
<View style={styles.rowTop}>
|
||||||
|
<View style={[styles.catBadge, { backgroundColor: cat.bg }]}>
|
||||||
|
<Text style={[styles.catText, { color: cat.color }]}>{catLabel(notice.category)}</Text>
|
||||||
|
</View>
|
||||||
|
{notice.pinned ? (
|
||||||
|
<View style={styles.pinBadge}>
|
||||||
|
<Ionicons name="bookmark" size={10} color={colors.primary700} />
|
||||||
|
<Text style={styles.pinText}>{t('notices.pinned')}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.rowTitle, urgent && { color: colors.error }]} numberOfLines={2}>
|
||||||
|
{notice.title}
|
||||||
|
</Text>
|
||||||
|
{notice.body ? (
|
||||||
|
<Text style={styles.rowBody} numberOfLines={2}>
|
||||||
|
{notice.body}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{notice.authorName ? <Text style={styles.rowMeta}>{notice.authorName}</Text> : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
177
mobile/components/tickets/TicketWallet.tsx
Normal file
177
mobile/components/tickets/TicketWallet.tsx
Normal file
@ -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<TicketFilter>('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 (
|
||||||
|
<View style={styles.flex}>
|
||||||
|
<ScrollView contentContainerStyle={styles.scroll}>
|
||||||
|
{/* 오프라인 표시 배지 */}
|
||||||
|
<View style={styles.offlineWrap}>
|
||||||
|
<View style={styles.offlineBadge}>
|
||||||
|
<View style={styles.offlineDot} />
|
||||||
|
<Text style={styles.offlineText}>{t('tickets.offline')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 필터 탭(세그먼트) */}
|
||||||
|
<View style={styles.segment}>
|
||||||
|
{FILTER_TABS.map((tab) => {
|
||||||
|
const on = filter === tab.key;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={tab.key}
|
||||||
|
accessibilityRole="tab"
|
||||||
|
accessibilityState={{ selected: on }}
|
||||||
|
style={[styles.segBtn, on && styles.segBtnOn]}
|
||||||
|
onPress={() => setFilter(tab.key)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.segText, on && styles.segTextOn]}>{tab.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 배지 전환 안내 배너 */}
|
||||||
|
<Banner tone="info">{t('tickets.badgeNotice')}</Banner>
|
||||||
|
|
||||||
|
{/* 티켓 리스트 / 빈 상태 */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
filtered.map((tk) => (
|
||||||
|
<TicketCard key={tk.id} ticket={tk} onOpenQr={openQr} onDetail={openDetail} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.footNote}>{t('tickets.footNote')}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<QrViewerModal
|
||||||
|
visible={qrOpen}
|
||||||
|
tickets={usableInView}
|
||||||
|
index={qrIndex}
|
||||||
|
onChangeIndex={setQrIndex}
|
||||||
|
onClose={() => setQrOpen(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Ionicons name="ticket-outline" size={44} color={colors.neutral200} />
|
||||||
|
<Text style={styles.emptyTitle}>{t('tickets.emptyTitle')}</Text>
|
||||||
|
<Text style={styles.emptyBody}>{t('tickets.emptyBody')}</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={styles.emptyBtn}
|
||||||
|
onPress={() => router.push('/tickets/select')}
|
||||||
|
>
|
||||||
|
<Text style={styles.emptyBtnText}>{t('tickets.emptyBtn')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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' },
|
||||||
|
});
|
||||||
@ -13,3 +13,10 @@ export const API_BASE: string =
|
|||||||
/** AI 생성 이미지 기본 고지문 (백엔드 watermarkText 부재 시 폴백 — 계약 §0-3). */
|
/** AI 생성 이미지 기본 고지문 (백엔드 watermarkText 부재 시 폴백 — 계약 §0-3). */
|
||||||
export const AI_IMAGE_NOTICE =
|
export const AI_IMAGE_NOTICE =
|
||||||
'AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다';
|
'AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개(B2C) 조회 폴백 행사 ID — 로그인 관람객이 참여 워크스페이스가 없거나
|
||||||
|
* 게스트로 공개 혼잡/주차/공지를 조회할 때 사용하는 데모 라이브 행사.
|
||||||
|
* 시크릿 아님(공개 식별자). 활성 워크스페이스가 있으면 그것을 우선한다.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PUBLIC_EVENT_ID = 'e-2026-live';
|
||||||
|
|||||||
@ -201,4 +201,91 @@ export const en: Translations = {
|
|||||||
emptyBtn: 'Book entry pass',
|
emptyBtn: 'Book entry pass',
|
||||||
detailTitle: 'Booking detail · cancel',
|
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',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -201,4 +201,91 @@ export const ja: Translations = {
|
|||||||
emptyBtn: '入場券を予約',
|
emptyBtn: '入場券を予約',
|
||||||
detailTitle: '予約詳細 · キャンセル',
|
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: '駐車状況 · 事前駐車券',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -203,6 +203,93 @@ export const ko = {
|
|||||||
emptyBtn: '입장권 예매',
|
emptyBtn: '입장권 예매',
|
||||||
detailTitle: '예매 상세·취소',
|
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;
|
} as const;
|
||||||
|
|
||||||
export type Resource = typeof ko;
|
export type Resource = typeof ko;
|
||||||
|
|||||||
@ -200,4 +200,91 @@ export const zh: Translations = {
|
|||||||
emptyBtn: '预订入场券',
|
emptyBtn: '预订入场券',
|
||||||
detailTitle: '预订详情 · 取消',
|
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: '停车现况 · 预约停车券',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -52,14 +52,14 @@ export function resolveLandingTrack({ user, workspaces }: LandingInput): Track {
|
|||||||
* 트랙 → 로그인 직후 랜딩 라우트(모바일).
|
* 트랙 → 로그인 직후 랜딩 라우트(모바일).
|
||||||
* · admin/ops/business → 탭 홈(/(tabs)) — 모바일엔 별도 admin 화면 없음, 홈이 역할 진입 허브
|
* · admin/ops/business → 탭 홈(/(tabs)) — 모바일엔 별도 admin 화면 없음, 홈이 역할 진입 허브
|
||||||
* · agency(장치·시공) → 현장 탭(/(tabs)/field) — 체크리스트 허브
|
* · agency(장치·시공) → 현장 탭(/(tabs)/field) — 체크리스트 허브
|
||||||
* · visitor(관람객) → 티켓 지갑(/tickets) — B2C 진입
|
* · visitor(관람객) → 관람객(B2C) 탭 셸(/(visitor)) — 홈·행사·티켓·현장·마이
|
||||||
*/
|
*/
|
||||||
export function landingPathForTrack(track: Track): string {
|
export function landingPathForTrack(track: Track): string {
|
||||||
switch (track) {
|
switch (track) {
|
||||||
case 'agency':
|
case 'agency':
|
||||||
return '/(tabs)/field';
|
return '/(tabs)/field';
|
||||||
case 'visitor':
|
case 'visitor':
|
||||||
return '/tickets';
|
return '/(visitor)';
|
||||||
case 'admin':
|
case 'admin':
|
||||||
case 'ops':
|
case 'ops':
|
||||||
case 'business':
|
case 'business':
|
||||||
|
|||||||
@ -129,3 +129,94 @@ export interface OtpStatusDto {
|
|||||||
export interface AvatarUploadResponse {
|
export interface AvatarUploadResponse {
|
||||||
photoUrl: string;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
47
mobile/lib/visitor.ts
Normal file
47
mobile/lib/visitor.ts
Normal file
@ -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<ParkingLotStatusDto[]> {
|
||||||
|
const q = eventId ? `?eventId=${encodeURIComponent(eventId)}` : '';
|
||||||
|
return api.get<ParkingLotStatusDto[]>(`/api/public/parking/lots${q}`, { anonymous: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 혼잡 요약 — 공개. eventId 필수. */
|
||||||
|
export function getCongestion(eventId: string): Promise<CongestionOverviewDto> {
|
||||||
|
return api.get<CongestionOverviewDto>(
|
||||||
|
`/api/public/congestion?eventId=${encodeURIComponent(eventId)}`,
|
||||||
|
{ anonymous: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 라이브 공지 — 공개. 게시(pinned 우선→최신) 건만. */
|
||||||
|
export function getLiveNotices(eventId: string, limit = 100): Promise<LiveNoticeDto[]> {
|
||||||
|
return api.get<LiveNoticeDto[]>(
|
||||||
|
`/api/public/events/${encodeURIComponent(eventId)}/live-notices?limit=${limit}`,
|
||||||
|
{ anonymous: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 사전 주차권 구매(mock 결제) — 인증. 구매자 = JWT 주체. */
|
||||||
|
export function purchaseParkingPass(req: PurchasePassRequest): Promise<ParkingPassDto> {
|
||||||
|
return api.post<ParkingPassDto>('/api/parking/passes', req);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 내 주차권 조회 — 인증(본인 소유분만). */
|
||||||
|
export function getMyParkingPasses(): Promise<MyPassesDto> {
|
||||||
|
return api.get<MyPassesDto>('/api/parking/passes/me');
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user