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:
zio 2026-07-12 05:26:30 +09:00
parent d949f322d2
commit becbc55152
7 changed files with 1094 additions and 0 deletions

View File

@ -31,6 +31,8 @@ export default function RootLayout() {
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="checklist" options={{ title: '현장 체크리스트' }} />
<Stack.Screen name="inspection" options={{ title: '현장 검수' }} />
<Stack.Screen name="tickets/index" options={{ title: '내 티켓' }} />
<Stack.Screen name="tickets/select" options={{ title: '티켓 예매' }} />
</Stack>
</AuthProvider>
</SafeAreaProvider>

View 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' },
});

View 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' },
});

View 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' },
});

View 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 },
});

View 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',
},
});

View 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',
},
];