CheckoutModal.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import { Image } from 'expo-image';
  2. import Alipay from 'expo-native-alipay';
  3. import { useRouter } from 'expo-router';
  4. import React, { useEffect, useState } from 'react';
  5. import {
  6. ActivityIndicator,
  7. Alert,
  8. ImageBackground,
  9. Modal,
  10. Platform,
  11. ScrollView,
  12. StyleSheet,
  13. Text,
  14. TouchableOpacity,
  15. View,
  16. } from 'react-native';
  17. import { useSafeAreaInsets } from 'react-native-safe-area-context';
  18. import { Images } from '@/constants/images';
  19. import { Address, getDefaultAddress } from '@/services/address';
  20. import { takeApply, takePreview } from '@/services/award';
  21. interface GroupedGoods {
  22. total: number;
  23. data: { id: string; cover: string; spuId: string; level: string; name?: string };
  24. }
  25. interface CheckoutModalProps {
  26. visible: boolean;
  27. selectedItems: Array<{ id: string; spu: { id: string; name: string; cover: string }; level: string }>;
  28. onClose: () => void;
  29. onSuccess: () => void;
  30. }
  31. export default function CheckoutModal({ visible, selectedItems, onClose, onSuccess }: CheckoutModalProps) {
  32. const router = useRouter();
  33. const insets = useSafeAreaInsets();
  34. const [loading, setLoading] = useState(false);
  35. const [submitting, setSubmitting] = useState(false);
  36. const [address, setAddress] = useState<Address | null>(null);
  37. const [expressAmount, setExpressAmount] = useState(0);
  38. const [goodsList, setGoodsList] = useState<GroupedGoods[]>([]);
  39. const showAlert = (msg: string) => {
  40. if (Platform.OS === 'web') window.alert(msg);
  41. else Alert.alert('提示', msg);
  42. };
  43. useEffect(() => {
  44. if (visible && selectedItems.length > 0) {
  45. loadData();
  46. }
  47. }, [visible, selectedItems]);
  48. const loadData = async () => {
  49. setLoading(true);
  50. try {
  51. // 获取默认地址
  52. const addr = await getDefaultAddress();
  53. setAddress(addr);
  54. // 获取提货预览
  55. const ids = selectedItems.map(item => item.id);
  56. const res = await takePreview(ids, addr?.id || '');
  57. if (res) {
  58. setExpressAmount(res.expressAmount || 0);
  59. }
  60. // 合并相同商品
  61. const goodsMap: Record<string, GroupedGoods> = {};
  62. selectedItems.forEach((item) => {
  63. const key = `${item.spu.id}_${item.level}`;
  64. if (goodsMap[key]) {
  65. goodsMap[key].total += 1;
  66. } else {
  67. goodsMap[key] = {
  68. total: 1,
  69. data: {
  70. id: item.id,
  71. cover: item.spu.cover,
  72. spuId: item.spu.id,
  73. level: item.level,
  74. name: item.spu.name,
  75. },
  76. };
  77. }
  78. });
  79. setGoodsList(Object.values(goodsMap));
  80. } catch (e) {
  81. console.error('加载提货信息失败:', e);
  82. }
  83. setLoading(false);
  84. };
  85. const goToAddress = () => {
  86. onClose();
  87. router.push('/address?type=1' as any);
  88. };
  89. /*
  90. * Handle Submit with Payment Choice
  91. */
  92. const handleSubmit = async () => {
  93. if (!address) {
  94. showAlert('请选择收货地址');
  95. return;
  96. }
  97. if (expressAmount > 0) {
  98. Alert.alert(
  99. '支付运费',
  100. `需支付运费 ¥${expressAmount}`,
  101. [
  102. { text: '取消', style: 'cancel' },
  103. { text: '钱包支付', onPress: () => processTakeApply('WALLET') },
  104. { text: '支付宝支付', onPress: () => processTakeApply('ALIPAY_APP') }
  105. ]
  106. );
  107. } else {
  108. processTakeApply('WALLET');
  109. }
  110. };
  111. const processTakeApply = async (paymentType: string) => {
  112. if (submitting) return;
  113. setSubmitting(true);
  114. try {
  115. const ids = selectedItems.map(item => item.id);
  116. const res: any = await takeApply(ids, address!.id, paymentType);
  117. console.log('Take Apply Res:', res, paymentType);
  118. if (paymentType === 'ALIPAY_APP' && res?.payInfo) {
  119. Alipay.setAlipayScheme('alipay2021005175632205');
  120. const result = await Alipay.pay(res.payInfo);
  121. console.log('Alipay Result:', result);
  122. if (result?.resultStatus === '9000') {
  123. showAlert('提货成功');
  124. onSuccess();
  125. } else {
  126. showAlert('支付未完成');
  127. }
  128. } else if (res) {
  129. // Wallet payment or free success
  130. showAlert('提货成功');
  131. onSuccess();
  132. }
  133. } catch (e) {
  134. console.error('提货失败:', e);
  135. // Usually axios interceptor handles error alerts, but just in case
  136. // showAlert('提货失败');
  137. }
  138. setSubmitting(false);
  139. };
  140. return (
  141. <Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
  142. <View style={styles.overlay}>
  143. <TouchableOpacity style={styles.overlayBg} onPress={onClose} activeOpacity={1} />
  144. <View style={[styles.container, { paddingBottom: insets.bottom + 20 }]}>
  145. {/* 标题 */}
  146. <View style={styles.header}>
  147. <Text style={styles.title}>提货</Text>
  148. <TouchableOpacity style={styles.closeBtn} onPress={onClose}>
  149. <Text style={styles.closeBtnText}>×</Text>
  150. </TouchableOpacity>
  151. </View>
  152. {loading ? (
  153. <View style={styles.loadingBox}>
  154. <ActivityIndicator size="large" color="#FC7D2E" />
  155. </View>
  156. ) : (
  157. <>
  158. {/* 商品列表 */}
  159. <View style={styles.goodsSection}>
  160. <ScrollView horizontal showsHorizontalScrollIndicator={false}>
  161. {goodsList.map((goods, idx) => (
  162. <View key={idx} style={styles.goodsItem}>
  163. <Image source={{ uri: goods.data.cover }} style={styles.goodsImg} contentFit="contain" />
  164. <View style={styles.goodsCount}>
  165. <Text style={styles.goodsCountText}>x{goods.total}</Text>
  166. </View>
  167. </View>
  168. ))}
  169. </ScrollView>
  170. </View>
  171. {/* 运费 */}
  172. {expressAmount > 0 && (
  173. <View style={styles.feeRow}>
  174. <Text style={styles.feeLabel}>运费</Text>
  175. <Text style={styles.feeValue}>¥{expressAmount}</Text>
  176. </View>
  177. )}
  178. {/* 收货地址 */}
  179. <TouchableOpacity style={styles.addressSection} onPress={goToAddress}>
  180. {!address ? (
  181. <Text style={styles.noAddress}>请填写收货地址</Text>
  182. ) : (
  183. <View style={styles.addressInfo}>
  184. <Text style={styles.addressName}>{address.contactName} {address.contactNo}</Text>
  185. <Text style={styles.addressDetail}>{address.province}{address.city}{address.district}{address.address}</Text>
  186. </View>
  187. )}
  188. <Text style={styles.arrowIcon}>›</Text>
  189. </TouchableOpacity>
  190. {/* 提交按钮 */}
  191. <TouchableOpacity style={styles.submitBtn} onPress={handleSubmit} disabled={submitting}>
  192. <ImageBackground source={{ uri: Images.common.loginBtn }} style={styles.submitBtnBg} resizeMode="contain">
  193. <Text style={styles.submitBtnText}>{submitting ? '提交中...' : '确定发货'}</Text>
  194. </ImageBackground>
  195. </TouchableOpacity>
  196. </>
  197. )}
  198. </View>
  199. </View>
  200. </Modal>
  201. );
  202. }
  203. const styles = StyleSheet.create({
  204. overlay: { flex: 1, justifyContent: 'flex-end' },
  205. overlayBg: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)' },
  206. container: { backgroundColor: '#fff', borderTopLeftRadius: 15, borderTopRightRadius: 15, paddingHorizontal: 14 },
  207. header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 20, position: 'relative' },
  208. title: { fontSize: 16, fontWeight: 'bold', color: '#000' },
  209. closeBtn: { position: 'absolute', right: 0, top: 15, width: 24, height: 24, backgroundColor: '#ebebeb', borderRadius: 12, justifyContent: 'center', alignItems: 'center' },
  210. closeBtnText: { fontSize: 18, color: '#a2a2a2', lineHeight: 20 },
  211. loadingBox: { height: 200, justifyContent: 'center', alignItems: 'center' },
  212. goodsSection: { paddingVertical: 10 },
  213. goodsItem: { width: 79, height: 103, backgroundColor: '#fff', borderRadius: 6, marginRight: 8, alignItems: 'center', justifyContent: 'center', position: 'relative', borderWidth: 1, borderColor: '#eaeaea' },
  214. goodsImg: { width: 73, height: 85 },
  215. goodsCount: { position: 'absolute', top: 0, right: 0, backgroundColor: '#ff6b00', borderRadius: 2, paddingHorizontal: 4, paddingVertical: 2 },
  216. goodsCountText: { color: '#fff', fontSize: 10, fontWeight: 'bold' },
  217. feeRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8 },
  218. feeLabel: { fontSize: 14, color: '#333' },
  219. feeValue: { fontSize: 14, color: '#ff6b00', fontWeight: 'bold' },
  220. addressSection: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, borderTopWidth: 1, borderTopColor: '#f0f0f0' },
  221. noAddress: { flex: 1, fontSize: 16, fontWeight: 'bold', color: '#333' },
  222. addressInfo: { flex: 1 },
  223. addressName: { fontSize: 14, color: '#333', fontWeight: 'bold' },
  224. addressDetail: { fontSize: 12, color: '#666', marginTop: 4 },
  225. arrowIcon: { fontSize: 20, color: '#999', marginLeft: 10 },
  226. submitBtn: { alignItems: 'center', marginTop: 15 },
  227. submitBtnBg: { width: 260, height: 60, justifyContent: 'center', alignItems: 'center' },
  228. submitBtnText: { color: '#000', fontSize: 16, fontWeight: 'bold' },
  229. });