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

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

178 lines
6.0 KiB
TypeScript

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