kintex/mobile/app/tickets/index.tsx
zio 214b6df1ef feat(public,security,data): visitor public site + AI guide, security hardening, V42-V45 migrations
- frontend: visitor public home (AiAssistant, AiPlanningBriefing, VisitorTrackPage),
  3-track public routing (PublicShell/App), i18n ko/en/zh/ja, favicon
- backend: public AI visitor-assistant + event calendar API (publicsite/*),
  profile avatar API (auth/profile/*), SecurityConfig CORS whitelist,
  SecretStartupValidator (B12 fail-fast, prod only), application-prod.yml,
  AppIntegrity, /api/auth/me expansion (MeResponse)
- db: V42 bulk demo seed, V43 visitor_guide/transport + event calendar view,
  V44 performance indexes, V45 app_user profile photo columns (all idempotent)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:05:00 +09:00

193 lines
6.5 KiB
TypeScript

/*
* SCR-M15 [모바일] 내 티켓 지갑 — 관람객(B2C) 트랙.
* 필터 탭(진행중·예정·지난) + 티켓 카드 리스트 + QR 풀스크린 뷰어.
* 배지 전환 안내 배너 + 오프라인 표시 배지. 사용됨/취소 카드 흐리게.
* ※ M10(티켓)·M9(환불) 백엔드 미구현 → 샘플 데이터("샘플" 배지). API 호출 없음.
*/
import { Ionicons } from '@expo/vector-icons';
import { router, Stack } from 'expo-router';
import React, { useMemo, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Banner } from '../../components/Banner';
import { useSecureScreen } from '../../context/SecureScreenContext';
import { QrViewerModal } from '../../components/tickets/QrViewerModal';
import { TicketCard } from '../../components/tickets/TicketCard';
import {
FILTER_TABS,
SAMPLE_TICKETS,
type SampleTicket,
type TicketFilter,
} from '../../components/tickets/sampleTickets';
import { colors, radius, spacing, type } from '../../theme';
export default function TicketWalletScreen() {
// 티켓 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(t: SampleTicket) {
// SCR-P8(예매 확인·취소) 미구현 → 안내만(샘플)
Alert.alert(
'예매 상세·취소',
`${t.eventName}\n예매번호 ${t.bookingNoMasked}\n\n예매 확인·취소 화면(SCR-P8)은 티켓 백엔드(M10) 연동 후 제공됩니다.`,
);
}
return (
<View style={styles.flex}>
<Stack.Screen options={{ title: '내 티켓', headerTitleAlign: 'center' }} />
<ScrollView contentContainerStyle={styles.scroll}>
{/* 오프라인 표시 배지 */}
<View style={styles.offlineWrap}>
<View style={styles.offlineBadge}>
<View style={styles.offlineDot} />
<Text style={styles.offlineText}> </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">
QR로
</Banner>
{/* 티켓 리스트 / 빈 상태 */}
{filtered.length === 0 ? (
<EmptyState />
) : (
filtered.map((t) => (
<TicketCard key={t.id} ticket={t} onOpenQr={openQr} onDetail={openDetail} />
))
)}
<Text style={styles.footNote}>
(M10) .
</Text>
</ScrollView>
<QrViewerModal
visible={qrOpen}
tickets={usableInView}
index={qrIndex}
onChangeIndex={setQrIndex}
onClose={() => setQrOpen(false)}
/>
</View>
);
}
function EmptyState() {
return (
<View style={styles.empty}>
<Ionicons name="ticket-outline" size={44} color={colors.neutral200} />
<Text style={styles.emptyTitle}> </Text>
<Text style={styles.emptyBody}> .</Text>
<Pressable
accessibilityRole="button"
style={styles.emptyBtn}
onPress={() => router.push('/tickets/select')}
>
<Text style={styles.emptyBtnText}> </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' },
});