- app.json: version 0.2.0 / versionCode 2, drop expo-screen-capture plugin - add lib/i18n (ko/en/ja/zh), lib/roleTrack, LanguageContext + LanguageSelector - profile/security hardening across screens (login/register/forgot/profile/tabs) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
6.3 KiB
TypeScript
192 lines
6.3 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 { useTranslation } from 'react-i18next';
|
|
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() {
|
|
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 (
|
|
<View style={styles.flex}>
|
|
<Stack.Screen options={{ title: t('nav.ticketsIndex'), headerTitleAlign: 'center' }} />
|
|
|
|
<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' },
|
|
});
|