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 (
+
+
+
+ 샘플
+
+
+ );
+}
+
+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',
+ },
+];