- (visitor) route group, visitor lib, tickets wallet - CongestionPill + LiveNoticeFeed components - login/tickets screens, i18n 4 locales, wordmark assets, splash Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
673 lines
22 KiB
TypeScript
673 lines
22 KiB
TypeScript
/*
|
|
* F-C2/F-C3 [모바일] 관람객(B2C) 현장 — 실시간 혼잡 + 주차 현황 + 사전 주차권(mock).
|
|
* 세그먼트: 혼잡도 | 주차. 혼잡/주차 현황은 공개 조회, 주차권은 인증(본인).
|
|
* 계약: _workspace/parking_congestion_contract.md. PII(차량번호)는 서버 마스킹 — 원문 미저장.
|
|
*/
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { router } from 'expo-router';
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
ActivityIndicator,
|
|
Modal,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
TextInput,
|
|
View,
|
|
} from 'react-native';
|
|
import { Banner } from '../../components/Banner';
|
|
import { CongestionPill } from '../../components/CongestionPill';
|
|
import { useAuth } from '../../context/AuthContext';
|
|
import { isDegraded } from '../../lib/api';
|
|
import { DEFAULT_PUBLIC_EVENT_ID } from '../../lib/config';
|
|
import type {
|
|
CongestionAreaDto,
|
|
CongestionOverviewDto,
|
|
ParkingLotStatusDto,
|
|
ParkingPassDto,
|
|
} from '../../lib/types';
|
|
import {
|
|
getCongestion,
|
|
getMyParkingPasses,
|
|
getParkingLots,
|
|
purchaseParkingPass,
|
|
} from '../../lib/visitor';
|
|
import { colors, radius, spacing, touch, type } from '../../theme';
|
|
|
|
type Seg = 'congestion' | 'parking';
|
|
|
|
function won(n: number): string {
|
|
return `${n.toLocaleString('ko-KR')}`;
|
|
}
|
|
function ymd(d: Date): string {
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
}
|
|
|
|
export default function VisitorOnsiteScreen() {
|
|
const { t } = useTranslation();
|
|
const { token, activeWorkspace } = useAuth();
|
|
const eventId = activeWorkspace?.eventId ?? DEFAULT_PUBLIC_EVENT_ID;
|
|
|
|
const [seg, setSeg] = useState<Seg>('congestion');
|
|
|
|
return (
|
|
<View style={styles.flex}>
|
|
{/* 세그먼트 */}
|
|
<View style={styles.segment}>
|
|
{(['congestion', 'parking'] as Seg[]).map((s) => {
|
|
const on = seg === s;
|
|
return (
|
|
<Pressable
|
|
key={s}
|
|
accessibilityRole="tab"
|
|
accessibilityState={{ selected: on }}
|
|
style={[styles.segBtn, on && styles.segBtnOn]}
|
|
onPress={() => setSeg(s)}
|
|
>
|
|
<Text style={[styles.segText, on && styles.segTextOn]}>
|
|
{t(s === 'congestion' ? 'onsite.segCongestion' : 'onsite.segParking')}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{seg === 'congestion' ? (
|
|
<CongestionView eventId={eventId} />
|
|
) : (
|
|
<ParkingView eventId={eventId} hasToken={!!token} />
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ── 혼잡도 ──
|
|
function CongestionView({ eventId }: { eventId: string }) {
|
|
const { t } = useTranslation();
|
|
const [data, setData] = useState<CongestionOverviewDto | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [degraded, setDegraded] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const res = await getCongestion(eventId);
|
|
setData(res);
|
|
setDegraded(false);
|
|
} catch (e) {
|
|
setDegraded(isDegraded(e));
|
|
setData(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [eventId]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<View style={styles.loading}>
|
|
<ActivityIndicator color={colors.primary600} size="large" />
|
|
</View>
|
|
);
|
|
}
|
|
if (!data) {
|
|
return (
|
|
<ScrollView contentContainerStyle={styles.scroll}>
|
|
<Banner tone={degraded ? 'degraded' : 'info'}>
|
|
{degraded ? t('congestion.degraded') : t('congestion.empty')}
|
|
</Banner>
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ScrollView contentContainerStyle={styles.scroll}>
|
|
{/* 종합 */}
|
|
<View style={styles.overallCard}>
|
|
<View style={styles.overallTop}>
|
|
<Text style={styles.overallLabel}>{t('congestion.overall')}</Text>
|
|
<CongestionPill level={data.overallLevel} label={data.overallLabel} />
|
|
</View>
|
|
<View style={styles.overallMetric}>
|
|
<Ionicons name="people-outline" size={18} color={colors.neutral500} />
|
|
<Text style={styles.overallCount}>
|
|
{t('congestion.onSite')} {t('congestion.people', { n: won(data.onSiteCount) })}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<AreaGroup title={t('congestion.gates')} icon="enter-outline" areas={data.entryGates} />
|
|
<AreaGroup title={t('congestion.popular')} icon="star-outline" areas={data.popularSessions} />
|
|
<AreaGroup title={t('congestion.parking')} icon="car-outline" areas={data.parking} />
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
function AreaGroup({
|
|
title,
|
|
icon,
|
|
areas,
|
|
}: {
|
|
title: string;
|
|
icon: keyof typeof Ionicons.glyphMap;
|
|
areas: CongestionAreaDto[];
|
|
}) {
|
|
if (!areas || areas.length === 0) return null;
|
|
return (
|
|
<View style={styles.group}>
|
|
<View style={styles.groupHeader}>
|
|
<Ionicons name={icon} size={16} color={colors.primary600} />
|
|
<Text style={styles.groupTitle}>{title}</Text>
|
|
</View>
|
|
{areas.map((a) => (
|
|
<View key={a.id} style={styles.areaRow}>
|
|
<Text style={styles.areaLabel} numberOfLines={1}>
|
|
{a.label}
|
|
</Text>
|
|
<CongestionPill level={a.level} label={a.levelLabel} percent={a.occupancyPercent} size="sm" />
|
|
</View>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ── 주차 ──
|
|
function ParkingView({ eventId, hasToken }: { eventId: string; hasToken: boolean }) {
|
|
const { t } = useTranslation();
|
|
const [lots, setLots] = useState<ParkingLotStatusDto[] | null>(null);
|
|
const [degraded, setDegraded] = useState(false);
|
|
const [passes, setPasses] = useState<ParkingPassDto[] | null>(null);
|
|
const [purchaseFor, setPurchaseFor] = useState<ParkingLotStatusDto | null>(null);
|
|
|
|
const loadLots = useCallback(async () => {
|
|
try {
|
|
const res = await getParkingLots(eventId);
|
|
setLots(res ?? []);
|
|
setDegraded(false);
|
|
} catch (e) {
|
|
setDegraded(isDegraded(e));
|
|
setLots([]);
|
|
}
|
|
}, [eventId]);
|
|
|
|
const loadPasses = useCallback(async () => {
|
|
if (!hasToken) {
|
|
setPasses(null);
|
|
return;
|
|
}
|
|
try {
|
|
const res = await getMyParkingPasses();
|
|
setPasses(res?.passes ?? []);
|
|
} catch {
|
|
setPasses([]); // degraded/미배포 시 빈 목록(정직)
|
|
}
|
|
}, [hasToken]);
|
|
|
|
useEffect(() => {
|
|
loadLots();
|
|
loadPasses();
|
|
}, [loadLots, loadPasses]);
|
|
|
|
return (
|
|
<ScrollView contentContainerStyle={styles.scroll}>
|
|
{/* 주차 현황 */}
|
|
<View style={styles.groupHeader}>
|
|
<Ionicons name="car-outline" size={16} color={colors.primary600} />
|
|
<Text style={styles.groupTitle}>{t('parking.title')}</Text>
|
|
</View>
|
|
|
|
{lots == null ? (
|
|
<View style={styles.loading}>
|
|
<ActivityIndicator color={colors.primary600} />
|
|
</View>
|
|
) : lots.length === 0 ? (
|
|
<Banner tone={degraded ? 'degraded' : 'info'}>
|
|
{degraded ? t('parking.degraded') : t('parking.empty')}
|
|
</Banner>
|
|
) : (
|
|
lots.map((lot) => (
|
|
<LotCard key={lot.lotId} lot={lot} onBuy={() => setPurchaseFor(lot)} canBuy={hasToken} />
|
|
))
|
|
)}
|
|
|
|
{/* 내 주차권 */}
|
|
<View style={[styles.groupHeader, { marginTop: spacing.sm }]}>
|
|
<Ionicons name="pricetag-outline" size={16} color={colors.primary600} />
|
|
<Text style={styles.groupTitle}>{t('parking.myPasses')}</Text>
|
|
</View>
|
|
{!hasToken ? (
|
|
<Pressable style={styles.loginNote} onPress={() => router.push('/login')}>
|
|
<Text style={styles.loginNoteText}>{t('parking.loginRequired')}</Text>
|
|
<Ionicons name="chevron-forward" size={16} color={colors.primary600} />
|
|
</Pressable>
|
|
) : passes == null ? (
|
|
<View style={styles.loading}>
|
|
<ActivityIndicator color={colors.primary600} />
|
|
</View>
|
|
) : passes.length === 0 ? (
|
|
<Text style={styles.emptyNote}>{t('parking.noPasses')}</Text>
|
|
) : (
|
|
passes.map((p) => <PassCard key={p.passNo} pass={p} />)
|
|
)}
|
|
|
|
<PurchaseModal
|
|
lot={purchaseFor}
|
|
onClose={() => setPurchaseFor(null)}
|
|
onPurchased={() => {
|
|
setPurchaseFor(null);
|
|
loadPasses();
|
|
loadLots();
|
|
}}
|
|
eventId={eventId}
|
|
/>
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
function LotCard({
|
|
lot,
|
|
onBuy,
|
|
canBuy,
|
|
}: {
|
|
lot: ParkingLotStatusDto;
|
|
onBuy: () => void;
|
|
canBuy: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const pct = Math.max(0, Math.min(100, lot.occupancyPercent));
|
|
return (
|
|
<View style={styles.lotCard}>
|
|
<View style={styles.lotTop}>
|
|
<Text style={styles.lotName} numberOfLines={1}>
|
|
{lot.name}
|
|
</Text>
|
|
<CongestionPill level={lot.congestionLevel} label={lot.congestionLabel} percent={pct} size="sm" />
|
|
</View>
|
|
{/* 점유율 바 */}
|
|
<View style={styles.bar}>
|
|
<View style={[styles.barFill, { width: `${pct}%` }]} />
|
|
</View>
|
|
<View style={styles.lotMetaRow}>
|
|
<Text style={styles.lotMeta}>
|
|
{t('parking.available', { n: won(lot.available) })} · {t('parking.capacity', { n: won(lot.totalCapacity) })}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.lotMetaRow}>
|
|
<Text style={styles.lotMeta}>
|
|
{t('parking.rate', { n: won(lot.hourlyRate) })}
|
|
{lot.dailyMax != null ? ` · ${t('parking.daily', { n: won(lot.dailyMax) })}` : ''}
|
|
</Text>
|
|
</View>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
style={[styles.buyBtn, !canBuy && styles.buyBtnGhost]}
|
|
onPress={onBuy}
|
|
disabled={!canBuy}
|
|
>
|
|
<Ionicons
|
|
name="ticket-outline"
|
|
size={16}
|
|
color={canBuy ? colors.white : colors.neutral500}
|
|
/>
|
|
<Text style={[styles.buyText, !canBuy && { color: colors.neutral500 }]}>
|
|
{t('parking.buyPass')} · {t('parking.passPrice', { n: won(lot.passPrice) })}
|
|
</Text>
|
|
</Pressable>
|
|
{!canBuy ? <Text style={styles.buyHint}>{t('parking.loginRequired')}</Text> : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const PASS_STATUS_TONE: Record<ParkingPassDto['status'], { color: string; bg: string }> = {
|
|
PAID: { color: colors.success, bg: '#E7F6EF' },
|
|
USED: { color: colors.neutral700, bg: colors.neutral050 },
|
|
CANCELLED: { color: colors.error, bg: '#FEF3F2' },
|
|
};
|
|
|
|
function PassCard({ pass }: { pass: ParkingPassDto }) {
|
|
const { t } = useTranslation();
|
|
const tone = PASS_STATUS_TONE[pass.status] ?? PASS_STATUS_TONE.USED;
|
|
const dim = pass.status !== 'PAID';
|
|
return (
|
|
<View style={[styles.passCard, dim && styles.passDim]}>
|
|
<View style={styles.passTop}>
|
|
<Text style={styles.passLot} numberOfLines={1}>
|
|
{pass.lotName}
|
|
</Text>
|
|
<View style={[styles.passBadge, { backgroundColor: tone.bg }]}>
|
|
<Text style={[styles.passBadgeText, { color: tone.color }]}>
|
|
{t(`parking.passStatus.${pass.status}` as const)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<Text style={styles.passMeta}>
|
|
{pass.useDate} · {pass.passNo}
|
|
</Text>
|
|
<View style={styles.passBottom}>
|
|
<Text style={styles.passMeta}>
|
|
{pass.vehiclePlateMasked ?? '-'}
|
|
</Text>
|
|
<Text style={styles.passAmount}>{t('parking.passPrice', { n: won(pass.amount) })}</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function PurchaseModal({
|
|
lot,
|
|
eventId,
|
|
onClose,
|
|
onPurchased,
|
|
}: {
|
|
lot: ParkingLotStatusDto | null;
|
|
eventId: string;
|
|
onClose: () => void;
|
|
onPurchased: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [dayOffset, setDayOffset] = useState(1); // 내일 기본(오늘 이후)
|
|
const [plate, setPlate] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
const useDate = useMemo(() => {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() + dayOffset);
|
|
return d;
|
|
}, [dayOffset]);
|
|
|
|
// 모달이 열릴 때마다 초기화
|
|
useEffect(() => {
|
|
if (lot) {
|
|
setDayOffset(1);
|
|
setPlate('');
|
|
setErr(null);
|
|
setBusy(false);
|
|
}
|
|
}, [lot]);
|
|
|
|
async function submit() {
|
|
if (!lot) return;
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
await purchaseParkingPass({
|
|
lotId: lot.lotId,
|
|
useDate: ymd(useDate),
|
|
eventId,
|
|
vehiclePlate: plate.trim() ? plate.trim() : undefined,
|
|
payMethod: 'card',
|
|
});
|
|
onPurchased();
|
|
} catch (e) {
|
|
setErr((e as Error).message || t('parking.purchaseErr'));
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal visible={!!lot} transparent animationType="slide" onRequestClose={onClose}>
|
|
<View style={styles.modalBackdrop}>
|
|
<View style={styles.modalSheet}>
|
|
<View style={styles.modalHeader}>
|
|
<Text style={styles.modalTitle}>{t('parking.purchaseTitle')}</Text>
|
|
<Pressable onPress={onClose} accessibilityLabel={t('common.close')} hitSlop={8}>
|
|
<Ionicons name="close" size={22} color={colors.neutral500} />
|
|
</Pressable>
|
|
</View>
|
|
|
|
{lot ? (
|
|
<>
|
|
{/* 선택 주차장 */}
|
|
<Text style={styles.modalLabel}>{t('parking.selectLot')}</Text>
|
|
<View style={styles.modalLotBox}>
|
|
<Text style={styles.modalLotName}>{lot.name}</Text>
|
|
<Text style={styles.modalLotPrice}>{t('parking.passPrice', { n: won(lot.passPrice) })}</Text>
|
|
</View>
|
|
|
|
{/* 이용일 스텝퍼 */}
|
|
<Text style={styles.modalLabel}>{t('parking.useDate')}</Text>
|
|
<View style={styles.dateRow}>
|
|
<Pressable
|
|
style={styles.dateBtn}
|
|
disabled={dayOffset <= 1}
|
|
onPress={() => setDayOffset((v) => Math.max(1, v - 1))}
|
|
accessibilityLabel={t('parking.prevDay')}
|
|
>
|
|
<Ionicons
|
|
name="chevron-back"
|
|
size={20}
|
|
color={dayOffset <= 1 ? colors.neutral200 : colors.neutral700}
|
|
/>
|
|
</Pressable>
|
|
<Text style={styles.dateText}>{ymd(useDate)}</Text>
|
|
<Pressable
|
|
style={styles.dateBtn}
|
|
disabled={dayOffset >= 14}
|
|
onPress={() => setDayOffset((v) => Math.min(14, v + 1))}
|
|
accessibilityLabel={t('parking.nextDay')}
|
|
>
|
|
<Ionicons
|
|
name="chevron-forward"
|
|
size={20}
|
|
color={dayOffset >= 14 ? colors.neutral200 : colors.neutral700}
|
|
/>
|
|
</Pressable>
|
|
</View>
|
|
|
|
{/* 차량번호(선택) */}
|
|
<Text style={styles.modalLabel}>{t('parking.plate')}</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={plate}
|
|
onChangeText={setPlate}
|
|
placeholder={t('parking.platePlaceholder')}
|
|
placeholderTextColor={colors.neutral500}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
/>
|
|
<Text style={styles.modalNote}>{t('parking.plateNote')}</Text>
|
|
<Text style={styles.modalNote}>{t('parking.payNote')}</Text>
|
|
|
|
{err ? <Banner tone="error">{err}</Banner> : null}
|
|
|
|
<Pressable
|
|
style={[styles.confirmBtn, busy && styles.confirmBusy]}
|
|
onPress={submit}
|
|
disabled={busy}
|
|
accessibilityRole="button"
|
|
>
|
|
{busy ? (
|
|
<ActivityIndicator color={colors.white} />
|
|
) : (
|
|
<Text style={styles.confirmText}>{t('parking.confirm')}</Text>
|
|
)}
|
|
</Pressable>
|
|
</>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
flex: { flex: 1, backgroundColor: colors.neutral050 },
|
|
scroll: { padding: spacing.md, gap: spacing.sm, paddingBottom: 48 },
|
|
loading: { paddingVertical: spacing.xl, alignItems: 'center' },
|
|
segment: {
|
|
flexDirection: 'row',
|
|
margin: spacing.md,
|
|
marginBottom: 0,
|
|
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 },
|
|
// 혼잡
|
|
overallCard: {
|
|
backgroundColor: colors.white,
|
|
borderWidth: 1,
|
|
borderColor: colors.neutral200,
|
|
borderRadius: radius.md,
|
|
padding: spacing.md,
|
|
gap: 10,
|
|
},
|
|
overallTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
|
overallLabel: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
overallMetric: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
|
overallCount: { fontSize: type.body.fontSize, color: colors.neutral700, fontWeight: '600' },
|
|
group: {
|
|
backgroundColor: colors.white,
|
|
borderWidth: 1,
|
|
borderColor: colors.neutral200,
|
|
borderRadius: radius.md,
|
|
padding: spacing.md,
|
|
gap: 8,
|
|
},
|
|
groupHeader: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
|
groupTitle: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
areaRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 10,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.neutral200,
|
|
paddingTop: 8,
|
|
},
|
|
areaLabel: { flex: 1, fontSize: type.body.fontSize, color: colors.neutral700 },
|
|
// 주차 lot
|
|
lotCard: {
|
|
backgroundColor: colors.white,
|
|
borderWidth: 1,
|
|
borderColor: colors.neutral200,
|
|
borderRadius: radius.md,
|
|
padding: spacing.md,
|
|
gap: 8,
|
|
},
|
|
lotTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
|
|
lotName: { flex: 1, fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
bar: {
|
|
height: 8,
|
|
borderRadius: 4,
|
|
backgroundColor: colors.neutral200,
|
|
overflow: 'hidden',
|
|
},
|
|
barFill: { height: 8, borderRadius: 4, backgroundColor: colors.primary600 },
|
|
lotMetaRow: { flexDirection: 'row' },
|
|
lotMeta: { fontSize: type.caption.fontSize, color: colors.neutral500 },
|
|
buyBtn: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 6,
|
|
minHeight: touch.min,
|
|
borderRadius: radius.sm,
|
|
backgroundColor: colors.primary600,
|
|
marginTop: 2,
|
|
},
|
|
buyBtnGhost: { backgroundColor: colors.neutral050, borderWidth: 1, borderColor: colors.neutral200 },
|
|
buyText: { color: colors.white, fontSize: type.body.fontSize, fontWeight: '700' },
|
|
buyHint: { fontSize: 11, color: colors.neutral500, textAlign: 'center' },
|
|
// 주차권
|
|
loginNote: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
backgroundColor: colors.primary050,
|
|
borderRadius: radius.md,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 12,
|
|
},
|
|
loginNoteText: { fontSize: type.body.fontSize, color: colors.primary700, fontWeight: '600' },
|
|
emptyNote: { fontSize: type.caption.fontSize, color: colors.neutral500, paddingVertical: spacing.sm },
|
|
passCard: {
|
|
backgroundColor: colors.white,
|
|
borderWidth: 1,
|
|
borderColor: colors.neutral200,
|
|
borderRadius: radius.md,
|
|
padding: spacing.md,
|
|
gap: 6,
|
|
},
|
|
passDim: { opacity: 0.6 },
|
|
passTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
|
|
passLot: { flex: 1, fontSize: type.body.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
passBadge: { borderRadius: radius.pill, paddingHorizontal: 10, paddingVertical: 3 },
|
|
passBadgeText: { fontSize: 11, fontWeight: '700' },
|
|
passMeta: { fontSize: type.caption.fontSize, color: colors.neutral500 },
|
|
passBottom: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
|
passAmount: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.primary700 },
|
|
// 모달
|
|
modalBackdrop: { flex: 1, backgroundColor: 'rgba(16,24,40,0.45)', justifyContent: 'flex-end' },
|
|
modalSheet: {
|
|
backgroundColor: colors.white,
|
|
borderTopLeftRadius: 16,
|
|
borderTopRightRadius: 16,
|
|
padding: spacing.md,
|
|
paddingBottom: spacing.xl,
|
|
gap: 8,
|
|
},
|
|
modalHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
|
modalTitle: { fontSize: type.h2.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
modalLabel: { fontSize: type.caption.fontSize, color: colors.neutral500, fontWeight: '600', marginTop: 6 },
|
|
modalLotBox: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
backgroundColor: colors.neutral050,
|
|
borderRadius: radius.sm,
|
|
padding: 12,
|
|
},
|
|
modalLotName: { flex: 1, fontSize: type.body.fontSize, fontWeight: '600', color: colors.neutral900 },
|
|
modalLotPrice: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.primary700 },
|
|
dateRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
backgroundColor: colors.neutral050,
|
|
borderRadius: radius.sm,
|
|
borderWidth: 1,
|
|
borderColor: colors.neutral200,
|
|
},
|
|
dateBtn: { width: touch.min, height: touch.min, alignItems: 'center', justifyContent: 'center' },
|
|
dateText: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
input: {
|
|
minHeight: touch.min,
|
|
borderWidth: 1,
|
|
borderColor: colors.neutral200,
|
|
borderRadius: radius.sm,
|
|
paddingHorizontal: 12,
|
|
fontSize: type.body.fontSize,
|
|
color: colors.neutral900,
|
|
backgroundColor: colors.white,
|
|
},
|
|
modalNote: { fontSize: 11, color: colors.neutral500, lineHeight: 16 },
|
|
confirmBtn: {
|
|
minHeight: 52,
|
|
borderRadius: radius.md,
|
|
backgroundColor: colors.primary600,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginTop: 8,
|
|
},
|
|
confirmBusy: { opacity: 0.7 },
|
|
confirmText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '700' },
|
|
});
|