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:
zio 2026-07-24 00:41:57 +09:00
parent 9faeb8892b
commit 0933dbea41
24 changed files with 2078 additions and 188 deletions

View File

@ -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"

View 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>
);
}

View 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 },
});

View 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
View 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 },
});

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

View 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 />;
}

View File

@ -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') }} />

View File

@ -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: {

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

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

View 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 },
});

View 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 },
});

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

View File

@ -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';

View File

@ -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',
},
}; };

View File

@ -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: '駐車状況 · 事前駐車券',
},
}; };

View File

@ -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;

View File

@ -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: '停车现况 · 预约停车券',
},
}; };

View File

@ -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':

View File

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