kintex/mobile/app/tickets/select.tsx

363 lines
14 KiB
TypeScript

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