From becbc55152777311c102e434a0ec8e6d058c547f Mon Sep 17 00:00:00 2001 From: zio Date: Sun, 12 Jul 2026 05:26:30 +0900 Subject: [PATCH] feat(mobile): SCR-M15 ticket wallet + ticket-type select (sample, QR placeholder) Co-Authored-By: Claude Opus 4.8 (1M context) --- mobile/app/_layout.tsx | 2 + mobile/app/tickets/index.tsx | 188 ++++++++++ mobile/app/tickets/select.tsx | 362 ++++++++++++++++++++ mobile/components/tickets/QrPlaceholder.tsx | 122 +++++++ mobile/components/tickets/QrViewerModal.tsx | 177 ++++++++++ mobile/components/tickets/TicketCard.tsx | 165 +++++++++ mobile/components/tickets/sampleTickets.ts | 78 +++++ 7 files changed, 1094 insertions(+) create mode 100644 mobile/app/tickets/index.tsx create mode 100644 mobile/app/tickets/select.tsx create mode 100644 mobile/components/tickets/QrPlaceholder.tsx create mode 100644 mobile/components/tickets/QrViewerModal.tsx create mode 100644 mobile/components/tickets/TicketCard.tsx create mode 100644 mobile/components/tickets/sampleTickets.ts diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 575585c..449ae49 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -31,6 +31,8 @@ export default function RootLayout() { + + diff --git a/mobile/app/tickets/index.tsx b/mobile/app/tickets/index.tsx new file mode 100644 index 0000000..cb1767b --- /dev/null +++ b/mobile/app/tickets/index.tsx @@ -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('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 ( + + + + + {/* 오프라인 표시 배지 */} + + + + 오프라인 — 저장된 티켓 표시 중 + + + + {/* 필터 탭(세그먼트) */} + + {FILTER_TABS.map((tab) => { + const on = filter === tab.key; + return ( + setFilter(tab.key)} + > + {tab.label} + + ); + })} + + + {/* 배지 전환 안내 배너 */} + + 티켓 QR로 현장 체크인하면 모바일 배지로 전환됩니다 + + + {/* 티켓 리스트 / 빈 상태 */} + {filtered.length === 0 ? ( + + ) : ( + filtered.map((t) => ( + + )) + )} + + + 표시된 티켓은 샘플입니다 — 티켓 백엔드(M10) 연동 시 실데이터로 대체됩니다. + + + + setQrOpen(false)} + /> + + ); +} + +function EmptyState() { + return ( + + + 보유한 티켓이 없습니다 + 입장권을 예매하면 이곳에 티켓이 표시됩니다. + router.push('/tickets/select')} + > + 입장권 예매 + + + ); +} + +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' }, +}); diff --git a/mobile/app/tickets/select.tsx b/mobile/app/tickets/select.tsx new file mode 100644 index 0000000..0c46e94 --- /dev/null +++ b/mobile/app/tickets/select.tsx @@ -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>({ 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 ( + + + + + {/* 행사 요약 칩 */} + + + + 스마트팩토리 코리아 2026 · 제2전시장 홀7 + + + + {/* 스텝 인디케이터 */} + + {STEPS.map((s, i) => ( + + + + {i + 1} + + {s} + + {i < STEPS.length - 1 ? : null} + + ))} + + + {/* 권종 카드 */} + + {TICKET_TYPES.map((t) => { + const count = qty[t.id] ?? 0; + return ( + + + + + + + {t.name} + {t.badge ? ( + + {t.badge.tone === 'ai' ? ( + + ) : null} + + {t.badge.text} + + + ) : null} + {t.soldOut ? ( + + 매진 + + ) : null} + + {t.desc ? ( + {t.desc} + ) : ( + {won(t.price)} + )} + {t.priceNote ? {t.priceNote} : null} + + + {/* 수량 스테퍼 */} + + change(t.id, -1)} + style={styles.stepperBtn} + > + + + {count} + change(t.id, 1)} + style={styles.stepperBtn} + > + + + + + {t.badge?.tone === 'ai' ? ( + + ※ 업종 및 직무 AI 매칭을 통해 추천된 권종입니다. + + ) : null} + + + ); + })} + + + {/* 결제 수단 미리보기 */} + + 결제 수단 미리보기 + + {['신용카드', '간편결제', '계좌이체'].map((p) => ( + + {p} + + ))} + + + + + 결제는 PG사 보안 페이지에서 진행되며 카드정보는 저장되지 않습니다. + + + + + + {/* 하단 스티키 합계 바 */} + + + 선택한 티켓 {totalCount}매 + 합계 {won(totalPrice)} + + + 다음 + + + router.back()}> + 내 티켓 지갑으로 돌아가기 + + + + ); +} + +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' }, +}); diff --git a/mobile/components/tickets/QrPlaceholder.tsx b/mobile/components/tickets/QrPlaceholder.tsx new file mode 100644 index 0000000..7109509 --- /dev/null +++ b/mobile/components/tickets/QrPlaceholder.tsx @@ -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 ( + + + + {cells.map((c, idx) => ( + + ))} + {/* 3개 파인더 패턴(좌상·우상·좌하) */} + + + + + + 샘플 + + + ); +} + +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 ( + <> + + + + + ); +} + +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' }, +}); diff --git a/mobile/components/tickets/QrViewerModal.tsx b/mobile/components/tickets/QrViewerModal.tsx new file mode 100644 index 0000000..c7969f7 --- /dev/null +++ b/mobile/components/tickets/QrViewerModal.tsx @@ -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 ( + + + + {/* 헤더 (그라디언트 대체: 브랜드 딥블루 단색) */} + + + + + 입장 QR 코드 + + {ticket.eventName} + + + + + + + + {ticket.holderName} + {ticket.ticketType} + + + {ticket.bookingNoMasked} + + + {/* 밝기 부스트 안내 */} + + + + 입장 시 화면을 최대 밝기로 유지하세요 + + + + {/* 스와이프(버튼) 전환 */} + {tickets.length > 1 ? ( + + onChangeIndex(index - 1)} + style={[styles.navBtn, !canPrev && styles.navBtnOff]} + > + + + + {tickets.map((t, i) => ( + + ))} + + onChangeIndex(index + 1)} + style={[styles.navBtn, !canNext && styles.navBtnOff]} + > + + + + ) : null} + 다른 티켓 보기 + + + + + ); +} + +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 }, +}); diff --git a/mobile/components/tickets/TicketCard.tsx b/mobile/components/tickets/TicketCard.tsx new file mode 100644 index 0000000..1bbd08c --- /dev/null +++ b/mobile/components/tickets/TicketCard.tsx @@ -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 ( + + + + + + {ticket.eventName} + + + {sm.label} + + + + + + {ticket.period} + + + + {ticket.hallLabel} + + + + + + + + + + + 권종 + {ticket.ticketType} + + + 수량 + {ticket.quantity}매 + + + 예매번호 + {ticket.bookingNoMasked} + + + + + {ticket.status === 'usable' ? ( + [styles.qrBtn, pressed && { opacity: 0.85 }]} + onPress={() => onOpenQr(ticket)} + > + + 입장 QR 보기 + + ) : ( + + + {ticket.status === 'used' ? '입장 완료된 티켓입니다' : '취소된 티켓입니다'} + + + )} + + onDetail(ticket)} + > + 예매 상세·취소 + + + + ); +} + +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', + }, +}); diff --git a/mobile/components/tickets/sampleTickets.ts b/mobile/components/tickets/sampleTickets.ts new file mode 100644 index 0000000..b753b4b --- /dev/null +++ b/mobile/components/tickets/sampleTickets.ts @@ -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', + }, +];