feat(mobile): SCR-M15 ticket wallet + ticket-type select (sample, QR placeholder)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d949f322d2
commit
becbc55152
@ -31,6 +31,8 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="checklist" options={{ title: '현장 체크리스트' }} />
|
<Stack.Screen name="checklist" options={{ title: '현장 체크리스트' }} />
|
||||||
<Stack.Screen name="inspection" options={{ title: '현장 검수' }} />
|
<Stack.Screen name="inspection" options={{ title: '현장 검수' }} />
|
||||||
|
<Stack.Screen name="tickets/index" options={{ title: '내 티켓' }} />
|
||||||
|
<Stack.Screen name="tickets/select" options={{ title: '티켓 예매' }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
|||||||
188
mobile/app/tickets/index.tsx
Normal file
188
mobile/app/tickets/index.tsx
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
/*
|
||||||
|
* 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 { 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 [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' },
|
||||||
|
});
|
||||||
362
mobile/app/tickets/select.tsx
Normal file
362
mobile/app/tickets/select.tsx
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
/*
|
||||||
|
* SCR-M14 연계 [모바일] 티켓 권종 선택 — SCR-M15 예매 진입(보조 화면).
|
||||||
|
* 4단계 스텝(권종·예매자·결제·완료) 중 1단계. 권종 카드 + 수량 스테퍼 + 합계 스티키 바.
|
||||||
|
* ※ 결제는 PG 위임 — 카드번호 입력 UI 없음(design.md 보안 불변). M10 미구현 → 샘플 권종.
|
||||||
|
* "다음"은 후속 단계(예매자/결제) 미구현 안내(존재하지 않는 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 { colors, radius, spacing, touch, type } from '../../theme';
|
||||||
|
|
||||||
|
interface TicketType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number; // 원(0=무료)
|
||||||
|
priceNote?: string;
|
||||||
|
desc?: string;
|
||||||
|
badge?: { text: string; tone: 'discount' | 'ai' };
|
||||||
|
soldOut?: boolean;
|
||||||
|
accent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TICKET_TYPES: TicketType[] = [
|
||||||
|
{ id: 'std', name: '일반권', price: 0, priceNote: '사전등록 할인가', accent: colors.primary600 },
|
||||||
|
{
|
||||||
|
id: 'buyer',
|
||||||
|
name: '바이어권',
|
||||||
|
price: 0,
|
||||||
|
desc: '무료 (자격심사 필요)',
|
||||||
|
badge: { text: 'AI 추천', tone: 'ai' },
|
||||||
|
accent: colors.aiAccent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'group',
|
||||||
|
name: '단체권',
|
||||||
|
price: 9000,
|
||||||
|
priceNote: '10인 이상 단체 구매시 적용',
|
||||||
|
badge: { text: '10% OFF', tone: 'discount' },
|
||||||
|
accent: colors.success,
|
||||||
|
},
|
||||||
|
{ id: 'vip', name: 'VIP권', price: 50000, soldOut: true, accent: colors.neutral500 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STEPS = ['권종', '예매자', '결제', '완료'];
|
||||||
|
|
||||||
|
function won(n: number): string {
|
||||||
|
return n === 0 ? '₩0' : `₩${n.toLocaleString('ko-KR')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TicketSelectScreen() {
|
||||||
|
const [qty, setQty] = useState<Record<string, number>>({ group: 1 });
|
||||||
|
|
||||||
|
const totalCount = useMemo(
|
||||||
|
() => Object.values(qty).reduce((a, b) => a + b, 0),
|
||||||
|
[qty],
|
||||||
|
);
|
||||||
|
const totalPrice = useMemo(
|
||||||
|
() => TICKET_TYPES.reduce((sum, t) => sum + (qty[t.id] ?? 0) * t.price, 0),
|
||||||
|
[qty],
|
||||||
|
);
|
||||||
|
|
||||||
|
function change(id: string, delta: number) {
|
||||||
|
setQty((q) => ({ ...q, [id]: Math.max(0, (q[id] ?? 0) + delta) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (totalCount === 0) return;
|
||||||
|
Alert.alert(
|
||||||
|
'다음 단계',
|
||||||
|
'예매자 정보·결제 단계는 티켓 백엔드(M10) 및 PG 어댑터 연동 후 제공됩니다.\n결제는 PG사 보안 페이지에서 진행되며 카드정보는 저장되지 않습니다.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.flex}>
|
||||||
|
<Stack.Screen options={{ title: '티켓 예매', headerTitleAlign: 'center' }} />
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={styles.scroll}>
|
||||||
|
{/* 행사 요약 칩 */}
|
||||||
|
<View style={styles.eventChip}>
|
||||||
|
<Ionicons name="calendar-outline" size={16} color={colors.primary600} />
|
||||||
|
<Text style={styles.eventChipText} numberOfLines={1}>
|
||||||
|
스마트팩토리 코리아 2026 · 제2전시장 홀7
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 스텝 인디케이터 */}
|
||||||
|
<View style={styles.steps}>
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<React.Fragment key={s}>
|
||||||
|
<View style={styles.step}>
|
||||||
|
<View style={[styles.stepDot, i === 0 && styles.stepDotOn]}>
|
||||||
|
<Text style={[styles.stepNum, i === 0 && styles.stepNumOn]}>{i + 1}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.stepLabel, i === 0 && styles.stepLabelOn]}>{s}</Text>
|
||||||
|
</View>
|
||||||
|
{i < STEPS.length - 1 ? <View style={styles.stepLine} /> : null}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 권종 카드 */}
|
||||||
|
<View style={styles.cardList}>
|
||||||
|
{TICKET_TYPES.map((t) => {
|
||||||
|
const count = qty[t.id] ?? 0;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={t.id}
|
||||||
|
style={[styles.card, t.soldOut && styles.cardSoldOut]}
|
||||||
|
>
|
||||||
|
<View style={[styles.cardAccent, { backgroundColor: t.accent }]} />
|
||||||
|
<View style={styles.cardBody}>
|
||||||
|
<View style={styles.cardTop}>
|
||||||
|
<View style={styles.cardInfo}>
|
||||||
|
<View style={styles.nameRow}>
|
||||||
|
<Text style={styles.typeName}>{t.name}</Text>
|
||||||
|
{t.badge ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.typeBadge,
|
||||||
|
t.badge.tone === 'ai' ? styles.badgeAi : styles.badgeDiscount,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{t.badge.tone === 'ai' ? (
|
||||||
|
<Ionicons name="sparkles" size={11} color={colors.aiAccent} />
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.typeBadgeText,
|
||||||
|
{ color: t.badge.tone === 'ai' ? colors.aiAccent : colors.error },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{t.badge.text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{t.soldOut ? (
|
||||||
|
<View style={styles.soldOutBadge}>
|
||||||
|
<Text style={styles.soldOutText}>매진</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{t.desc ? (
|
||||||
|
<Text style={styles.typeDesc}>{t.desc}</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.typePrice}>{won(t.price)}</Text>
|
||||||
|
)}
|
||||||
|
{t.priceNote ? <Text style={styles.priceNote}>{t.priceNote}</Text> : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 수량 스테퍼 */}
|
||||||
|
<View style={styles.stepper}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="감소"
|
||||||
|
disabled={t.soldOut}
|
||||||
|
onPress={() => change(t.id, -1)}
|
||||||
|
style={styles.stepperBtn}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="remove"
|
||||||
|
size={20}
|
||||||
|
color={t.soldOut ? colors.neutral200 : colors.neutral700}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.stepperVal}>{count}</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="증가"
|
||||||
|
disabled={t.soldOut}
|
||||||
|
onPress={() => change(t.id, 1)}
|
||||||
|
style={styles.stepperBtn}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="add"
|
||||||
|
size={20}
|
||||||
|
color={t.soldOut ? colors.neutral200 : colors.neutral700}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{t.badge?.tone === 'ai' ? (
|
||||||
|
<Text style={styles.aiNote}>
|
||||||
|
※ 업종 및 직무 AI 매칭을 통해 추천된 권종입니다.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 결제 수단 미리보기 */}
|
||||||
|
<View style={styles.paySection}>
|
||||||
|
<Text style={styles.payTitle}>결제 수단 미리보기</Text>
|
||||||
|
<View style={styles.payChips}>
|
||||||
|
{['신용카드', '간편결제', '계좌이체'].map((p) => (
|
||||||
|
<View key={p} style={styles.payChip}>
|
||||||
|
<Text style={styles.payChipText}>{p}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={styles.secureNote}>
|
||||||
|
<Ionicons name="shield-checkmark-outline" size={20} color={colors.neutral500} />
|
||||||
|
<Text style={styles.secureText}>
|
||||||
|
결제는 PG사 보안 페이지에서 진행되며 카드정보는 저장되지 않습니다.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 하단 스티키 합계 바 */}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<View style={styles.totalRow}>
|
||||||
|
<Text style={styles.totalLabel}>선택한 티켓 {totalCount}매</Text>
|
||||||
|
<Text style={styles.totalPrice}>합계 {won(totalPrice)}</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={totalCount === 0}
|
||||||
|
onPress={next}
|
||||||
|
style={[styles.nextBtn, totalCount === 0 && styles.nextBtnOff]}
|
||||||
|
>
|
||||||
|
<Text style={styles.nextText}>다음</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={colors.white} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={styles.backLink} onPress={() => router.back()}>
|
||||||
|
<Text style={styles.backLinkText}>내 티켓 지갑으로 돌아가기</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
flex: { flex: 1, backgroundColor: colors.neutral050 },
|
||||||
|
scroll: { padding: spacing.md, gap: spacing.md, paddingBottom: 200 },
|
||||||
|
eventChip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
backgroundColor: colors.primary050,
|
||||||
|
borderRadius: radius.pill,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
maxWidth: '100%',
|
||||||
|
},
|
||||||
|
eventChipText: { flex: 1, fontSize: type.caption.fontSize, color: colors.neutral900, fontWeight: '600' },
|
||||||
|
steps: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 4 },
|
||||||
|
step: { alignItems: 'center', gap: 4 },
|
||||||
|
stepDot: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: colors.neutral200,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
stepDotOn: { backgroundColor: colors.primary600 },
|
||||||
|
stepNum: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.neutral500 },
|
||||||
|
stepNumOn: { color: colors.white },
|
||||||
|
stepLabel: { fontSize: 11, color: colors.neutral500 },
|
||||||
|
stepLabelOn: { color: colors.primary700, fontWeight: '700' },
|
||||||
|
stepLine: { flex: 1, height: 1, backgroundColor: colors.neutral200, marginHorizontal: 6, marginBottom: 18 },
|
||||||
|
cardList: { gap: spacing.md },
|
||||||
|
card: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: colors.white,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.neutral200,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
cardSoldOut: { opacity: 0.6 },
|
||||||
|
cardAccent: { width: 4 },
|
||||||
|
cardBody: { flex: 1, padding: spacing.md, gap: 8 },
|
||||||
|
cardTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 },
|
||||||
|
cardInfo: { flex: 1, gap: 4 },
|
||||||
|
nameRow: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
|
||||||
|
typeName: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
|
||||||
|
typeBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 3,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
badgeAi: { borderColor: colors.aiAccent, backgroundColor: colors.aiSurface },
|
||||||
|
badgeDiscount: { borderColor: colors.error, backgroundColor: '#FEF3F2' },
|
||||||
|
typeBadgeText: { fontSize: 10, fontWeight: '700' },
|
||||||
|
soldOutBadge: { backgroundColor: colors.neutral500, borderRadius: radius.sm, paddingHorizontal: 6, paddingVertical: 2 },
|
||||||
|
soldOutText: { color: colors.white, fontSize: 10, fontWeight: '700' },
|
||||||
|
typePrice: { fontSize: type.h2.fontSize, fontWeight: '700', color: colors.neutral900 },
|
||||||
|
typeDesc: { fontSize: type.body.fontSize, color: colors.neutral700 },
|
||||||
|
priceNote: { fontSize: type.caption.fontSize, color: colors.neutral500 },
|
||||||
|
aiNote: { fontSize: 11, color: colors.aiAccent, fontWeight: '500' },
|
||||||
|
stepper: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.neutral050,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.neutral200,
|
||||||
|
},
|
||||||
|
stepperBtn: { width: touch.min, height: touch.min, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
stepperVal: { width: 28, textAlign: 'center', fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
|
||||||
|
paySection: { gap: 8, marginTop: 4 },
|
||||||
|
payTitle: { fontSize: type.caption.fontSize, color: colors.neutral500, fontWeight: '600' },
|
||||||
|
payChips: { flexDirection: 'row', gap: 8 },
|
||||||
|
payChip: {
|
||||||
|
backgroundColor: colors.white,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.neutral200,
|
||||||
|
borderRadius: radius.pill,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
payChipText: { fontSize: type.caption.fontSize, color: colors.neutral700 },
|
||||||
|
secureNote: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
backgroundColor: colors.white,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.neutral200,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderRadius: radius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
secureText: { flex: 1, fontSize: type.caption.fontSize, color: colors.neutral700, lineHeight: 18 },
|
||||||
|
footer: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: colors.white,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.neutral200,
|
||||||
|
padding: spacing.md,
|
||||||
|
paddingBottom: spacing.lg,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
totalRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||||
|
totalLabel: { fontSize: type.body.fontSize, color: colors.neutral500 },
|
||||||
|
totalPrice: { fontSize: type.h2.fontSize, fontWeight: '700', color: colors.primary700 },
|
||||||
|
nextBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 6,
|
||||||
|
minHeight: 56,
|
||||||
|
backgroundColor: colors.primary600,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
},
|
||||||
|
nextBtnOff: { opacity: 0.5 },
|
||||||
|
nextText: { color: colors.white, fontSize: type.h2.fontSize, fontWeight: '700' },
|
||||||
|
backLink: { alignItems: 'center', minHeight: 32, justifyContent: 'center' },
|
||||||
|
backLinkText: { fontSize: type.caption.fontSize, color: colors.neutral500, textDecorationLine: 'underline' },
|
||||||
|
});
|
||||||
122
mobile/components/tickets/QrPlaceholder.tsx
Normal file
122
mobile/components/tickets/QrPlaceholder.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
/*
|
||||||
|
* QR 자리표시(placeholder) — 실제 스캔 가능한 QR 생성 라이브러리 미설치(GAP).
|
||||||
|
* react-native-svg로 시드 문자열 기반 결정론적 모듈 그리드 + 파인더 패턴을 그려
|
||||||
|
* "QR처럼 보이는" 미리보기를 제공한다. 스캔 불가 — 상시 "샘플" 라벨 노출.
|
||||||
|
* 실제 QR은 M10 티켓 백엔드 + QR 인코더 도입 시 교체(갭: _workspace/port_mobile_m15.md).
|
||||||
|
*/
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import Svg, { Rect } from 'react-native-svg';
|
||||||
|
import { colors, radius } from '../../theme';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
seed: string;
|
||||||
|
size?: number;
|
||||||
|
quiet?: boolean; // 여백(quiet zone) 포함 여부
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결정론적 해시(문자열 → 32bit) — 시드로 모듈 on/off 결정
|
||||||
|
function hashAt(seed: string, i: number): number {
|
||||||
|
let h = 2166136261 ^ i;
|
||||||
|
for (let k = 0; k < seed.length; k++) {
|
||||||
|
h ^= seed.charCodeAt(k);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return (h >>> 0) % 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODULES = 25; // 25x25 격자
|
||||||
|
|
||||||
|
export function QrPlaceholder({ seed, size = 192, quiet = true }: Props) {
|
||||||
|
const cells = useMemo(() => {
|
||||||
|
const out: { x: number; y: number }[] = [];
|
||||||
|
for (let y = 0; y < MODULES; y++) {
|
||||||
|
for (let x = 0; x < MODULES; x++) {
|
||||||
|
if (isFinderZone(x, y)) continue; // 파인더 영역은 별도 렌더
|
||||||
|
if (hashAt(seed, y * MODULES + x) < 48) out.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [seed]);
|
||||||
|
|
||||||
|
const pad = quiet ? 2 : 0;
|
||||||
|
const total = MODULES + pad * 2;
|
||||||
|
const cell = size / total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
accessibilityRole="image"
|
||||||
|
accessibilityLabel="입장 QR 코드 (샘플)"
|
||||||
|
style={[styles.wrap, { width: size, height: size }]}
|
||||||
|
>
|
||||||
|
<Svg width={size} height={size}>
|
||||||
|
<Rect x={0} y={0} width={size} height={size} fill={colors.white} />
|
||||||
|
{cells.map((c, idx) => (
|
||||||
|
<Rect
|
||||||
|
key={idx}
|
||||||
|
x={(c.x + pad) * cell}
|
||||||
|
y={(c.y + pad) * cell}
|
||||||
|
width={cell}
|
||||||
|
height={cell}
|
||||||
|
fill={colors.neutral900}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* 3개 파인더 패턴(좌상·우상·좌하) */}
|
||||||
|
<Finder x={pad} y={pad} cell={cell} />
|
||||||
|
<Finder x={pad + MODULES - 7} y={pad} cell={cell} />
|
||||||
|
<Finder x={pad} y={pad + MODULES - 7} cell={cell} />
|
||||||
|
</Svg>
|
||||||
|
<View style={styles.sampleTag}>
|
||||||
|
<Text style={styles.sampleText}>샘플</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFinderZone(x: number, y: number): boolean {
|
||||||
|
const inTL = x < 8 && y < 8;
|
||||||
|
const inTR = x >= MODULES - 8 && y < 8;
|
||||||
|
const inBL = x < 8 && y >= MODULES - 8;
|
||||||
|
return inTL || inTR || inBL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Finder({ x, y, cell }: { x: number; y: number; cell: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Rect x={x * cell} y={y * cell} width={cell * 7} height={cell * 7} fill={colors.neutral900} />
|
||||||
|
<Rect
|
||||||
|
x={(x + 1) * cell}
|
||||||
|
y={(y + 1) * cell}
|
||||||
|
width={cell * 5}
|
||||||
|
height={cell * 5}
|
||||||
|
fill={colors.white}
|
||||||
|
/>
|
||||||
|
<Rect
|
||||||
|
x={(x + 2) * cell}
|
||||||
|
y={(y + 2) * cell}
|
||||||
|
width={cell * 3}
|
||||||
|
height={cell * 3}
|
||||||
|
fill={colors.neutral900}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrap: {
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
overflow: 'hidden',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
sampleTag: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 4,
|
||||||
|
bottom: 4,
|
||||||
|
backgroundColor: colors.aiAccent,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
},
|
||||||
|
sampleText: { color: colors.white, fontSize: 10, fontWeight: '700' },
|
||||||
|
});
|
||||||
177
mobile/components/tickets/QrViewerModal.tsx
Normal file
177
mobile/components/tickets/QrViewerModal.tsx
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
* QR 풀스크린 뷰어(SCR-M15 3) — 대형 QR + 이름·유형 + 밝기 부스트 안내.
|
||||||
|
* 하단 스와이프 인디케이터로 다른 티켓 전환(좌우 버튼). 예매번호 마스킹 노출.
|
||||||
|
* ※ 자동 밝기 상승은 expo-brightness 미설치 → 안내 문구 + 갭 기록(placeholder).
|
||||||
|
*/
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import React from 'react';
|
||||||
|
import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { colors, radius, spacing, touch, type } from '../../theme';
|
||||||
|
import { QrPlaceholder } from './QrPlaceholder';
|
||||||
|
import type { SampleTicket } from './sampleTickets';
|
||||||
|
|
||||||
|
export function QrViewerModal({
|
||||||
|
visible,
|
||||||
|
tickets,
|
||||||
|
index,
|
||||||
|
onChangeIndex,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
tickets: SampleTicket[];
|
||||||
|
index: number;
|
||||||
|
onChangeIndex: (i: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const ticket = tickets[index];
|
||||||
|
if (!ticket) return null;
|
||||||
|
|
||||||
|
const canPrev = index > 0;
|
||||||
|
const canNext = index < tickets.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
|
<View style={styles.backdrop}>
|
||||||
|
<View style={styles.sheet}>
|
||||||
|
{/* 헤더 (그라디언트 대체: 브랜드 딥블루 단색) */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="닫기"
|
||||||
|
style={styles.closeBtn}
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color={colors.white} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.headerTitle}>입장 QR 코드</Text>
|
||||||
|
<Text style={styles.headerSub} numberOfLines={1}>
|
||||||
|
{ticket.eventName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
<QrPlaceholder seed={ticket.qrSeed} size={200} />
|
||||||
|
|
||||||
|
<View style={styles.nameRow}>
|
||||||
|
<Text style={styles.holderName}>{ticket.holderName}</Text>
|
||||||
|
<Text style={styles.holderType}> {ticket.ticketType}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.bookingChip}>
|
||||||
|
<Text style={styles.bookingText}>{ticket.bookingNoMasked}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 밝기 부스트 안내 */}
|
||||||
|
<View style={styles.hint}>
|
||||||
|
<Ionicons name="bulb-outline" size={20} color={colors.primary600} />
|
||||||
|
<Text style={styles.hintText}>
|
||||||
|
입장 시 화면을 <Text style={styles.hintBold}>최대 밝기</Text>로 유지하세요
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 스와이프(버튼) 전환 */}
|
||||||
|
{tickets.length > 1 ? (
|
||||||
|
<View style={styles.swipeRow}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="이전 티켓"
|
||||||
|
disabled={!canPrev}
|
||||||
|
onPress={() => onChangeIndex(index - 1)}
|
||||||
|
style={[styles.navBtn, !canPrev && styles.navBtnOff]}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={22} color={canPrev ? colors.primary600 : colors.neutral200} />
|
||||||
|
</Pressable>
|
||||||
|
<View style={styles.dots}>
|
||||||
|
{tickets.map((t, i) => (
|
||||||
|
<View
|
||||||
|
key={t.id}
|
||||||
|
style={[styles.dot, i === index ? styles.dotOn : null]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="다음 티켓"
|
||||||
|
disabled={!canNext}
|
||||||
|
onPress={() => onChangeIndex(index + 1)}
|
||||||
|
style={[styles.navBtn, !canNext && styles.navBtnOff]}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-forward" size={22} color={canNext ? colors.primary600 : colors.neutral200} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<Text style={styles.swipeHint}>다른 티켓 보기</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(16,24,40,0.6)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
sheet: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 360,
|
||||||
|
backgroundColor: colors.white,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: colors.primary700,
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
closeBtn: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
width: touch.min,
|
||||||
|
height: touch.min,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
headerTitle: { color: colors.white, fontSize: type.h2.fontSize, fontWeight: '700' },
|
||||||
|
headerSub: { color: 'rgba(255,255,255,0.85)', fontSize: type.caption.fontSize, maxWidth: 260 },
|
||||||
|
content: { alignItems: 'center', padding: spacing.lg, gap: spacing.md },
|
||||||
|
nameRow: { flexDirection: 'row', alignItems: 'baseline' },
|
||||||
|
holderName: { fontSize: type.h1.fontSize, fontWeight: '700', color: colors.neutral900 },
|
||||||
|
holderType: { fontSize: type.body.fontSize, fontWeight: '500', color: colors.neutral500 },
|
||||||
|
bookingChip: {
|
||||||
|
backgroundColor: colors.primary050,
|
||||||
|
borderRadius: radius.pill,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
bookingText: { color: colors.primary700, fontSize: type.caption.fontSize, fontVariant: ['tabular-nums'] },
|
||||||
|
hint: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
backgroundColor: colors.neutral050,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
hintText: { flex: 1, fontSize: type.caption.fontSize, color: colors.neutral700 },
|
||||||
|
hintBold: { fontWeight: '700', color: colors.neutral900 },
|
||||||
|
swipeRow: { flexDirection: 'row', alignItems: 'center', gap: 12, marginTop: 4 },
|
||||||
|
navBtn: {
|
||||||
|
width: touch.min,
|
||||||
|
height: touch.min,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.neutral200,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
navBtnOff: { opacity: 0.5 },
|
||||||
|
dots: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||||
|
dot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.neutral200 },
|
||||||
|
dotOn: { width: 20, backgroundColor: colors.primary600 },
|
||||||
|
swipeHint: { fontSize: 11, color: colors.neutral500, letterSpacing: 1 },
|
||||||
|
});
|
||||||
165
mobile/components/tickets/TicketCard.tsx
Normal file
165
mobile/components/tickets/TicketCard.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
/*
|
||||||
|
* 티켓 카드 — SCR-M15 리스트 항목.
|
||||||
|
* 행사명·권종·매수·상태 배지 + 미니 QR(placeholder) + "입장 QR 보기".
|
||||||
|
* 사용됨/취소 티켓은 흐리게(dim). 좌측 상태 액센트 바.
|
||||||
|
*/
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import React from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { colors, radius, spacing, touch, type } from '../../theme';
|
||||||
|
import { QrPlaceholder } from './QrPlaceholder';
|
||||||
|
import { STATUS_META, type SampleTicket } from './sampleTickets';
|
||||||
|
|
||||||
|
const badgeStyle: Record<'success' | 'muted' | 'error', { bg: string; fg: string }> = {
|
||||||
|
success: { bg: '#E7F6EF', fg: colors.success },
|
||||||
|
muted: { bg: colors.neutral050, fg: colors.neutral500 },
|
||||||
|
error: { bg: '#FEF3F2', fg: colors.error },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TicketCard({
|
||||||
|
ticket,
|
||||||
|
onOpenQr,
|
||||||
|
onDetail,
|
||||||
|
}: {
|
||||||
|
ticket: SampleTicket;
|
||||||
|
onOpenQr: (t: SampleTicket) => void;
|
||||||
|
onDetail: (t: SampleTicket) => void;
|
||||||
|
}) {
|
||||||
|
const dim = ticket.status !== 'usable';
|
||||||
|
const sm = STATUS_META[ticket.status];
|
||||||
|
const bs = badgeStyle[sm.tone];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.card, dim && styles.cardDim]}>
|
||||||
|
<View style={[styles.accent, { backgroundColor: dim ? colors.neutral200 : colors.primary600 }]} />
|
||||||
|
<View style={styles.body}>
|
||||||
|
<View style={styles.topRow}>
|
||||||
|
<Text style={styles.eventName} numberOfLines={2}>
|
||||||
|
{ticket.eventName}
|
||||||
|
</Text>
|
||||||
|
<View style={[styles.badge, { backgroundColor: bs.bg }]}>
|
||||||
|
<Text style={[styles.badgeText, { color: bs.fg }]}>{sm.label}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
<Ionicons name="calendar-outline" size={15} color={colors.neutral500} />
|
||||||
|
<Text style={styles.metaText}>{ticket.period}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
<Ionicons name="location-outline" size={15} color={colors.neutral500} />
|
||||||
|
<Text style={styles.metaText}>{ticket.hallLabel}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<View style={styles.miniQrWrap}>
|
||||||
|
<QrPlaceholder seed={ticket.qrSeed} size={64} quiet={false} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoCols}>
|
||||||
|
<View style={styles.infoCol}>
|
||||||
|
<Text style={styles.infoLabel}>권종</Text>
|
||||||
|
<Text style={styles.infoValue}>{ticket.ticketType}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoCol}>
|
||||||
|
<Text style={styles.infoLabel}>수량</Text>
|
||||||
|
<Text style={styles.infoValue}>{ticket.quantity}매</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoCol}>
|
||||||
|
<Text style={styles.infoLabel}>예매번호</Text>
|
||||||
|
<Text style={styles.infoMono}>{ticket.bookingNoMasked}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{ticket.status === 'usable' ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={({ pressed }: { pressed: boolean }) => [styles.qrBtn, pressed && { opacity: 0.85 }]}
|
||||||
|
onPress={() => onOpenQr(ticket)}
|
||||||
|
>
|
||||||
|
<Ionicons name="qr-code-outline" size={20} color={colors.white} />
|
||||||
|
<Text style={styles.qrBtnText}>입장 QR 보기</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.qrBtn, styles.qrBtnDisabled]}>
|
||||||
|
<Text style={styles.qrBtnDisabledText}>
|
||||||
|
{ticket.status === 'used' ? '입장 완료된 티켓입니다' : '취소된 티켓입니다'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="link"
|
||||||
|
style={styles.detailBtn}
|
||||||
|
onPress={() => onDetail(ticket)}
|
||||||
|
>
|
||||||
|
<Text style={styles.detailText}>예매 상세·취소</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: colors.white,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.neutral200,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
cardDim: { opacity: 0.6 },
|
||||||
|
accent: { width: 4 },
|
||||||
|
body: { flex: 1, padding: spacing.md, gap: 8 },
|
||||||
|
topRow: { flexDirection: 'row', justifyContent: 'space-between', gap: 8 },
|
||||||
|
eventName: { flex: 1, fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
|
||||||
|
badge: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
},
|
||||||
|
badgeText: { fontSize: 11, fontWeight: '700' },
|
||||||
|
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||||
|
metaText: { fontSize: type.caption.fontSize, color: colors.neutral700 },
|
||||||
|
divider: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.neutral200,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
marginVertical: 4,
|
||||||
|
},
|
||||||
|
infoRow: { flexDirection: 'row', gap: 12, alignItems: 'center' },
|
||||||
|
miniQrWrap: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.neutral200,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
padding: 3,
|
||||||
|
},
|
||||||
|
infoCols: { flex: 1, flexDirection: 'row', flexWrap: 'wrap', gap: 12 },
|
||||||
|
infoCol: { minWidth: 60 },
|
||||||
|
infoLabel: { fontSize: 11, color: colors.neutral500, marginBottom: 2 },
|
||||||
|
infoValue: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.neutral900 },
|
||||||
|
infoMono: { fontSize: type.caption.fontSize, color: colors.neutral700, fontVariant: ['tabular-nums'] },
|
||||||
|
qrBtn: {
|
||||||
|
minHeight: touch.min,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
backgroundColor: colors.primary600,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
qrBtnText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '700' },
|
||||||
|
qrBtnDisabled: { backgroundColor: colors.neutral050, borderWidth: 1, borderColor: colors.neutral200 },
|
||||||
|
qrBtnDisabledText: { color: colors.neutral500, fontSize: type.body.fontSize, fontWeight: '600' },
|
||||||
|
detailBtn: { minHeight: 40, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
detailText: {
|
||||||
|
fontSize: type.caption.fontSize,
|
||||||
|
color: colors.neutral500,
|
||||||
|
textDecorationLine: 'underline',
|
||||||
|
},
|
||||||
|
});
|
||||||
78
mobile/components/tickets/sampleTickets.ts
Normal file
78
mobile/components/tickets/sampleTickets.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* 샘플 티켓 데이터 — M10(티켓)·M9(환불) 백엔드 미구현 상태의 폴백.
|
||||||
|
* 실제 API 없음 → 존재하지 않는 엔드포인트 호출 금지(design.md SCR-M15 "신규·샘플").
|
||||||
|
* 서버 배선 시 lib/api.ts unwrap 봉투로 교체.
|
||||||
|
*/
|
||||||
|
export type TicketStatus = 'usable' | 'used' | 'canceled';
|
||||||
|
export type TicketFilter = 'active' | 'upcoming' | 'past';
|
||||||
|
|
||||||
|
export interface SampleTicket {
|
||||||
|
id: string;
|
||||||
|
eventName: string;
|
||||||
|
hallLabel: string;
|
||||||
|
period: string; // 표시용 기간 문자열
|
||||||
|
ticketType: string; // 권종
|
||||||
|
holderName: string; // 예매자(표시명)
|
||||||
|
quantity: number; // 매수
|
||||||
|
status: TicketStatus;
|
||||||
|
bookingNoMasked: string; // 마스킹된 예매번호
|
||||||
|
qrSeed: string; // QR placeholder 시드(실제 QR 아님)
|
||||||
|
filter: TicketFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_META: Record<
|
||||||
|
TicketStatus,
|
||||||
|
{ label: string; tone: 'success' | 'muted' | 'error' }
|
||||||
|
> = {
|
||||||
|
usable: { label: '사용가능', tone: 'success' },
|
||||||
|
used: { label: '사용됨', tone: 'muted' },
|
||||||
|
canceled: { label: '취소', tone: 'error' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FILTER_TABS: { key: TicketFilter; label: string }[] = [
|
||||||
|
{ key: 'active', label: '진행중' },
|
||||||
|
{ key: 'upcoming', label: '예정' },
|
||||||
|
{ key: 'past', label: '지난' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SAMPLE_TICKETS: SampleTicket[] = [
|
||||||
|
{
|
||||||
|
id: 'TKT-2026-0314-88',
|
||||||
|
eventName: '스마트팩토리 코리아 2026',
|
||||||
|
hallLabel: 'KINTEX 제1전시장 3-4홀',
|
||||||
|
period: '2026. 03.14(금) - 03.16(일)',
|
||||||
|
ticketType: '바이어권',
|
||||||
|
holderName: '정관람',
|
||||||
|
quantity: 1,
|
||||||
|
status: 'usable',
|
||||||
|
bookingNoMasked: 'KTX-****-8245',
|
||||||
|
qrSeed: 'KTX-SFK2026-BUYER-8245',
|
||||||
|
filter: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'TKT-2026-0210-12',
|
||||||
|
eventName: '2026 서울 국제 인공지능 컨퍼런스',
|
||||||
|
hallLabel: 'KINTEX 제2전시장',
|
||||||
|
period: '2026. 02.10 - 02.12',
|
||||||
|
ticketType: '일반권',
|
||||||
|
holderName: '정관람',
|
||||||
|
quantity: 2,
|
||||||
|
status: 'used',
|
||||||
|
bookingNoMasked: 'KTX-****-1120',
|
||||||
|
qrSeed: 'KTX-AI2026-STD-1120',
|
||||||
|
filter: 'past',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'TKT-2026-0505-31',
|
||||||
|
eventName: '그린에너지 엑스포 2026',
|
||||||
|
hallLabel: 'KINTEX 제1전시장 5홀',
|
||||||
|
period: '2026. 05.05 - 05.07',
|
||||||
|
ticketType: '단체권',
|
||||||
|
holderName: '정관람',
|
||||||
|
quantity: 1,
|
||||||
|
status: 'usable',
|
||||||
|
bookingNoMasked: 'KTX-****-0505',
|
||||||
|
qrSeed: 'KTX-GREEN2026-GRP-0505',
|
||||||
|
filter: 'upcoming',
|
||||||
|
},
|
||||||
|
];
|
||||||
Loading…
Reference in New Issue
Block a user