轻松聘
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

406 lines
11 KiB

import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLegalData, useLegalMembership } from '../../src/hooks';
import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView';
import { colors , spacing } from '../../src/theme';
import type { LegalPaymentChannel } from '../../src/types';
/* ===================== 付款方式 ===================== */
const CHANNELS: { id: LegalPaymentChannel; label: string; icon: keyof typeof Ionicons.glyphMap; color: string }[] = [
{ id: 'wechat', label: '微信支付', icon: 'logo-wechat', color: colors.success },
{ id: 'alipay', label: '支付宝', icon: 'wallet-outline', color: colors.primary },
];
/* ===================== 顶部导航 ===================== */
function Header({ onBack }: { onBack: () => void }) {
return (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>确认支付</Text>
<View style={styles.headerBtn} />
</View>
);
}
/* ===================== 页面 ===================== */
export default function LegalCheckoutScreen() {
const router = useRouter();
const { planId } = useLocalSearchParams();
const id = Array.isArray(planId) ? planId[0] : planId;
const { plans, loading, error, refresh } = useLegalData();
const { createOrder, payOrder, processing } = useLegalMembership();
const [channel, setChannel] = useState<LegalPaymentChannel>('wechat');
const [paid, setPaid] = useState(false);
const plan = plans.find((p) => p.id === id);
const handlePay = useCallback(async () => {
if (!plan) return;
try {
const order = await createOrder(plan.id, channel);
const result = await payOrder(order.id);
if (result.status === 'paid') {
setPaid(true);
} else {
Alert.alert('支付失败', '请稍后重试');
}
} catch (e) {
Alert.alert('支付失败', (e as Error).message || '请稍后重试');
}
}, [plan, channel, createOrder, payOrder]);
// 加载套餐中
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<LoadingState text="加载套餐中…" />
</SafeAreaView>
);
}
// 接口失败:必须先于「套餐不存在」判断,否则取数失败会伪装成业务空态,
// 把用户引向「从会员套餐页重新进入」这条无效的自救路径。
if (error) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<ErrorState message="套餐加载失败,请重试" onRetry={refresh} />
</SafeAreaView>
);
}
// 套餐不存在(异常入口)
if (!plan) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<EmptyState icon="alert-circle-outline" title="套餐不存在" subtitle="请从会员套餐页重新进入" />
</SafeAreaView>
);
}
// 支付成功
if (paid) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.replace('/legal')} />
<View style={styles.successBox}>
<View style={styles.successIconWrap}>
<Ionicons name="checkmark-circle" size={56} color={colors.success} />
</View>
<Text style={styles.successTitle}>支付成功</Text>
<Text style={styles.successDesc}>
已开通{plan.name},可畅享会员专属法律服务
</Text>
<Pressable style={styles.successBtn} onPress={() => router.replace('/legal')}>
<Text style={styles.successBtnText}>返回法律服务</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* 套餐摘要 */}
<View style={styles.summaryCard}>
<Text style={styles.summaryName}>{plan.name}</Text>
<View style={styles.summaryPriceRow}>
<Text style={styles.summaryCurrency}>¥</Text>
<Text style={styles.summaryPrice}>{plan.price}</Text>
<Text style={styles.summaryPeriod}>{plan.period}</Text>
</View>
<View style={styles.benefits}>
{plan.benefits.map((benefit, idx) => (
<View key={idx} style={styles.benefitRow}>
<Ionicons name="checkmark-circle" size={14} color={colors.success} />
<Text style={styles.benefitText}>{benefit}</Text>
</View>
))}
</View>
</View>
{/* 支付方式 */}
<Text style={styles.sectionTitle}>选择支付方式</Text>
<View style={styles.channelGroup}>
{CHANNELS.map((c) => {
const active = c.id === channel;
return (
<Pressable
key={c.id}
style={({ pressed }) => [
styles.channelRow,
active && styles.channelRowActive,
pressed && styles.channelPressed,
]}
onPress={() => setChannel(c.id)}
>
<Ionicons name={c.icon} size={24} color={c.color} />
<Text style={styles.channelLabel}>{c.label}</Text>
<View style={[styles.radio, active && styles.radioActive]}>
{active && <View style={styles.radioDot} />}
</View>
</Pressable>
);
})}
</View>
{/* 实付金额 */}
<View style={styles.amountRow}>
<Text style={styles.amountLabel}>实付金额</Text>
<View style={styles.amountValue}>
<Text style={styles.amountCurrency}>¥</Text>
<Text style={styles.amountPrice}>{plan.price}</Text>
</View>
</View>
</ScrollView>
{/* 确认支付(底部固定) */}
<View style={styles.footer}>
<Pressable
style={({ pressed }) => [styles.payBtn, pressed && styles.payBtnPressed, processing && styles.payBtnDisabled]}
onPress={handlePay}
disabled={processing}
>
{processing ? (
<ActivityIndicator color={colors.white} />
) : (
<Text style={styles.payBtnText}>确认支付 ¥{plan.price}</Text>
)}
</Pressable>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.card,
height: 48,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
scroll: {
flex: 1,
},
content: {
padding: spacing.card,
paddingBottom: 24,
},
summaryCard: {
padding: spacing.card,
borderRadius: spacing.card,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
},
summaryName: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
summaryPriceRow: {
flexDirection: 'row',
alignItems: 'baseline',
marginTop: 8,
marginBottom: 12,
},
summaryCurrency: {
fontSize: 15,
fontWeight: '700',
color: colors.actionText,
},
summaryPrice: {
fontSize: 30,
fontWeight: '700',
color: colors.actionText,
},
summaryPeriod: {
fontSize: 13,
color: colors.textSecondary,
marginLeft: 2,
},
benefits: {
gap: 6,
},
benefitRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
benefitText: {
fontSize: 12,
color: colors.textSecondary,
},
sectionTitle: {
marginTop: 20,
marginBottom: 10,
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
channelGroup: {
gap: 8,
},
channelRow: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.card,
borderRadius: spacing.card,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
gap: 12,
},
channelRowActive: {
borderColor: colors.action,
backgroundColor: colors.actionLight,
},
channelPressed: {
opacity: 0.85,
},
channelLabel: {
flex: 1,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
},
radio: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
},
radioActive: {
borderColor: colors.action,
},
radioDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.action,
},
amountRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 20,
paddingTop: 16,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
amountLabel: {
fontSize: 14,
color: colors.textSecondary,
},
amountValue: {
flexDirection: 'row',
alignItems: 'baseline',
},
amountCurrency: {
fontSize: 15,
fontWeight: '700',
color: colors.actionText,
},
amountPrice: {
fontSize: 22,
fontWeight: '700',
color: colors.actionText,
},
footer: {
padding: 12,
backgroundColor: colors.white,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
payBtn: {
height: 48,
borderRadius: spacing.card,
backgroundColor: colors.primaryBtn,
alignItems: 'center',
justifyContent: 'center',
},
payBtnPressed: {
opacity: 0.9,
},
payBtnDisabled: {
opacity: 0.6,
},
payBtnText: {
fontSize: 16,
fontWeight: '700',
color: colors.white,
},
successBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
successIconWrap: {
marginBottom: 16,
},
successTitle: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary,
},
successDesc: {
marginTop: 8,
fontSize: 14,
color: colors.textSecondary,
textAlign: 'center',
},
successBtn: {
marginTop: 28,
width: 200,
height: 46,
borderRadius: 23,
backgroundColor: colors.primaryBtn,
alignItems: 'center',
justifyContent: 'center',
},
successBtnText: {
fontSize: 15,
fontWeight: '700',
color: colors.white,
},
});