- (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>
178 lines
6.0 KiB
TypeScript
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' },
|
|
});
|