CheckoutModal.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. import { Image } from 'expo-image';
  2. import { useRouter } from 'expo-router';
  3. import React, { forwardRef, useImperativeHandle, useState } from 'react';
  4. import {
  5. Alert,
  6. Modal,
  7. ScrollView,
  8. StyleSheet,
  9. Switch,
  10. Text,
  11. TextInput,
  12. TouchableOpacity,
  13. View,
  14. } from 'react-native';
  15. import { useSafeAreaInsets } from 'react-native-safe-area-context';
  16. import { Address, getDefaultAddress } from '@/services/address';
  17. import { GoodsDetail, previewSubmit, PreviewSubmitResult, submitOrder } from '@/services/mall';
  18. interface CheckoutModalProps {
  19. data: GoodsDetail;
  20. goodsId: string;
  21. subjectId?: string;
  22. }
  23. export interface CheckoutModalRef {
  24. show: (preview: PreviewSubmitResult) => void;
  25. close: () => void;
  26. }
  27. export const CheckoutModal = forwardRef<CheckoutModalRef, CheckoutModalProps>(
  28. ({ data, goodsId, subjectId }, ref) => {
  29. const router = useRouter();
  30. const insets = useSafeAreaInsets();
  31. const [visible, setVisible] = useState(false);
  32. const [preview, setPreview] = useState<PreviewSubmitResult | null>(null);
  33. const [address, setAddress] = useState<Address | null>(null);
  34. const [quantity, setQuantity] = useState(1);
  35. const [phone, setPhone] = useState('');
  36. const [checked, setChecked] = useState(true);
  37. const [useWallet, setUseWallet] = useState(false);
  38. const [payType, setPayType] = useState<'alipay' | 'wxpay'>('alipay');
  39. const [submitting, setSubmitting] = useState(false);
  40. useImperativeHandle(ref, () => ({
  41. show: (previewData: PreviewSubmitResult) => {
  42. setPreview(previewData);
  43. setVisible(true);
  44. loadAddress();
  45. },
  46. close: () => {
  47. setVisible(false);
  48. },
  49. }));
  50. // 加载默认地址
  51. const loadAddress = async () => {
  52. try {
  53. const addr = await getDefaultAddress();
  54. setAddress(addr);
  55. } catch (error) {
  56. console.error('加载地址失败:', error);
  57. }
  58. };
  59. // 计算最终价格
  60. const lastPrice = preview
  61. ? data.sellType === 2
  62. ? preview.depositAmount || 0
  63. : preview.paymentAmount
  64. : data.price;
  65. // 增加数量
  66. const increment = async () => {
  67. if (subjectId) return; // 秒杀不能改数量
  68. if (quantity >= 5) return;
  69. const newQty = quantity + 1;
  70. setQuantity(newQty);
  71. // 重新获取价格
  72. const newPreview = await previewSubmit({ goodsId, quantity: newQty, subjectId });
  73. if (newPreview) setPreview(newPreview);
  74. };
  75. // 减少数量
  76. const decrement = async () => {
  77. if (quantity <= 1) return;
  78. const newQty = quantity - 1;
  79. setQuantity(newQty);
  80. const newPreview = await previewSubmit({ goodsId, quantity: newQty, subjectId });
  81. if (newPreview) setPreview(newPreview);
  82. };
  83. // 提交订单
  84. const handlePay = async () => {
  85. if (!address) {
  86. Alert.alert('提示', '请选择收货地址');
  87. return;
  88. }
  89. if (data.sellType === 2 && !phone) {
  90. Alert.alert('提示', '请填写尾款提醒手机号');
  91. return;
  92. }
  93. if (!checked) {
  94. Alert.alert('提示', '请勾选协议');
  95. return;
  96. }
  97. setSubmitting(true);
  98. try {
  99. const paymentType = useWallet ? 'WALLET' : payType === 'alipay' ? 'ALIPAY' : 'WXPAY';
  100. const result = await submitOrder({
  101. goodsId: data.id,
  102. paymentType,
  103. addressBookId: address.id,
  104. quantity,
  105. restNotifyMobile: phone,
  106. subjectId,
  107. });
  108. if (result) {
  109. if (result.paySuccess) {
  110. setVisible(false);
  111. Alert.alert('成功', '支付成功', [
  112. { text: '查看订单', onPress: () => router.push('/orders' as any) },
  113. ]);
  114. } else {
  115. // 需要跳转支付
  116. Alert.alert('提示', '请在支付页面完成支付');
  117. }
  118. }
  119. } catch (error) {
  120. console.error('提交订单失败:', error);
  121. Alert.alert('错误', '提交订单失败');
  122. }
  123. setSubmitting(false);
  124. };
  125. // 选择地址
  126. const selectAddress = () => {
  127. setVisible(false);
  128. router.push('/address?type=1' as any);
  129. };
  130. return (
  131. <Modal visible={visible} transparent animationType="slide">
  132. <View style={styles.overlay}>
  133. <TouchableOpacity style={styles.mask} onPress={() => setVisible(false)} />
  134. <View style={[styles.content, { paddingBottom: insets.bottom + 20 }]}>
  135. {/* 标题 */}
  136. <View style={styles.header}>
  137. <Text style={styles.title}>确认订单</Text>
  138. <TouchableOpacity onPress={() => setVisible(false)}>
  139. <Text style={styles.closeBtn}>×</Text>
  140. </TouchableOpacity>
  141. </View>
  142. <ScrollView style={styles.scrollContent} showsVerticalScrollIndicator={false}>
  143. {/* 商品信息 */}
  144. <View style={styles.goodsInfo}>
  145. <Image source={data.cover} style={styles.goodsImage} contentFit="cover" />
  146. <View style={styles.goodsDetail}>
  147. <Text style={styles.goodsName} numberOfLines={2}>{data.name}</Text>
  148. <View style={styles.priceRow}>
  149. <Text style={styles.price}>¥{data.subjectPrice || data.price}</Text>
  150. {data.sellType === 2 && (
  151. <Text style={styles.deposit}>定金:¥{data.deposit}</Text>
  152. )}
  153. </View>
  154. {/* 数量选择 */}
  155. <View style={styles.quantityRow}>
  156. <TouchableOpacity style={styles.qtyBtn} onPress={decrement}>
  157. <Text style={styles.qtyBtnText}>-</Text>
  158. </TouchableOpacity>
  159. <Text style={styles.qtyNum}>{quantity}</Text>
  160. <TouchableOpacity
  161. style={[styles.qtyBtn, subjectId ? styles.qtyBtnDisabled : null]}
  162. onPress={increment}
  163. >
  164. <Text style={styles.qtyBtnText}>+</Text>
  165. </TouchableOpacity>
  166. </View>
  167. </View>
  168. </View>
  169. {/* 优惠券 */}
  170. <View style={styles.row}>
  171. <Text style={styles.rowLabel}>优惠券</Text>
  172. <Text style={styles.rowValue}>-¥{preview?.couponAmount || 0}</Text>
  173. </View>
  174. {/* 钱包支付 */}
  175. {preview?.cash && (
  176. <View style={styles.row}>
  177. <Text style={styles.rowLabel}>
  178. 使用钱包支付 <Text style={styles.balance}>(余额:¥{preview.cash.balance})</Text>
  179. </Text>
  180. <Switch value={useWallet} onValueChange={setUseWallet} />
  181. </View>
  182. )}
  183. {/* 支付方式 */}
  184. {!useWallet && (
  185. <View style={styles.payTypeSection}>
  186. <TouchableOpacity
  187. style={styles.payTypeItem}
  188. onPress={() => setPayType('alipay')}
  189. >
  190. <Text style={styles.payTypeName}>支付宝</Text>
  191. <View style={[styles.radio, payType === 'alipay' && styles.radioActive]} />
  192. </TouchableOpacity>
  193. <TouchableOpacity
  194. style={styles.payTypeItem}
  195. onPress={() => setPayType('wxpay')}
  196. >
  197. <Text style={styles.payTypeName}>微信支付</Text>
  198. <View style={[styles.radio, payType === 'wxpay' && styles.radioActive]} />
  199. </TouchableOpacity>
  200. </View>
  201. )}
  202. {/* 尾款手机号 */}
  203. {data.sellType === 2 && (
  204. <View style={styles.phoneRow}>
  205. <Text style={styles.phoneLabel}>
  206. <Text style={styles.required}>*</Text> 尾款提醒手机号
  207. </Text>
  208. <TextInput
  209. style={styles.phoneInput}
  210. value={phone}
  211. onChangeText={setPhone}
  212. placeholder="尾款支付提醒短信将发至此号码"
  213. placeholderTextColor="#999"
  214. keyboardType="phone-pad"
  215. />
  216. </View>
  217. )}
  218. {/* 收货地址 */}
  219. <TouchableOpacity style={styles.addressRow} onPress={selectAddress}>
  220. {address ? (
  221. <View style={styles.addressInfo}>
  222. <Text style={styles.addressName}>
  223. {address.contactName} {address.contactNo}
  224. </Text>
  225. <Text style={styles.addressDetail}>
  226. {address.province}{address.city}{address.district}{address.address}
  227. </Text>
  228. </View>
  229. ) : (
  230. <Text style={styles.noAddress}>
  231. <Text style={styles.required}>*</Text> 请填写收货地址
  232. </Text>
  233. )}
  234. <Text style={styles.arrow}>›</Text>
  235. </TouchableOpacity>
  236. {/* 协议 */}
  237. <View style={styles.agreementRow}>
  238. <Text style={styles.agreementText}>同意《平台服务协议》详情</Text>
  239. <Switch value={checked} onValueChange={setChecked} />
  240. </View>
  241. </ScrollView>
  242. {/* 支付按钮 */}
  243. <TouchableOpacity
  244. style={[styles.payBtn, submitting && styles.payBtnDisabled]}
  245. onPress={handlePay}
  246. disabled={submitting}
  247. >
  248. <Text style={styles.payBtnText}>
  249. ¥{lastPrice} 立即支付
  250. </Text>
  251. </TouchableOpacity>
  252. </View>
  253. </View>
  254. </Modal>
  255. );
  256. }
  257. );
  258. const styles = StyleSheet.create({
  259. overlay: {
  260. flex: 1,
  261. justifyContent: 'flex-end',
  262. },
  263. mask: {
  264. ...StyleSheet.absoluteFillObject,
  265. backgroundColor: 'rgba(0,0,0,0.5)',
  266. },
  267. content: {
  268. backgroundColor: '#fff',
  269. borderTopLeftRadius: 15,
  270. borderTopRightRadius: 15,
  271. maxHeight: '80%',
  272. },
  273. header: {
  274. flexDirection: 'row',
  275. justifyContent: 'center',
  276. alignItems: 'center',
  277. paddingVertical: 15,
  278. borderBottomWidth: 1,
  279. borderBottomColor: '#eee',
  280. },
  281. title: {
  282. fontSize: 16,
  283. fontWeight: '600',
  284. color: '#333',
  285. },
  286. closeBtn: {
  287. position: 'absolute',
  288. right: 15,
  289. fontSize: 28,
  290. color: '#999',
  291. },
  292. scrollContent: {
  293. paddingHorizontal: 15,
  294. },
  295. goodsInfo: {
  296. flexDirection: 'row',
  297. paddingVertical: 15,
  298. borderBottomWidth: 1,
  299. borderBottomColor: '#eee',
  300. },
  301. goodsImage: {
  302. width: 90,
  303. height: 90,
  304. borderRadius: 8,
  305. backgroundColor: '#f5f5f5',
  306. },
  307. goodsDetail: {
  308. flex: 1,
  309. marginLeft: 12,
  310. justifyContent: 'space-between',
  311. },
  312. goodsName: {
  313. fontSize: 14,
  314. color: '#333',
  315. lineHeight: 20,
  316. },
  317. priceRow: {
  318. flexDirection: 'row',
  319. alignItems: 'center',
  320. },
  321. price: {
  322. fontSize: 18,
  323. fontWeight: 'bold',
  324. color: '#ff6b00',
  325. },
  326. deposit: {
  327. fontSize: 12,
  328. color: '#8b3dff',
  329. marginLeft: 10,
  330. backgroundColor: 'rgba(139,61,255,0.1)',
  331. paddingHorizontal: 8,
  332. paddingVertical: 2,
  333. borderRadius: 10,
  334. },
  335. quantityRow: {
  336. flexDirection: 'row',
  337. alignItems: 'center',
  338. },
  339. qtyBtn: {
  340. width: 28,
  341. height: 28,
  342. borderRadius: 14,
  343. backgroundColor: '#f5f5f5',
  344. justifyContent: 'center',
  345. alignItems: 'center',
  346. },
  347. qtyBtnDisabled: {
  348. opacity: 0.3,
  349. },
  350. qtyBtnText: {
  351. fontSize: 18,
  352. color: '#333',
  353. },
  354. qtyNum: {
  355. fontSize: 16,
  356. color: '#333',
  357. marginHorizontal: 15,
  358. },
  359. row: {
  360. flexDirection: 'row',
  361. justifyContent: 'space-between',
  362. alignItems: 'center',
  363. paddingVertical: 15,
  364. borderBottomWidth: 1,
  365. borderBottomColor: '#eee',
  366. },
  367. rowLabel: {
  368. fontSize: 14,
  369. color: '#333',
  370. },
  371. rowValue: {
  372. fontSize: 14,
  373. color: '#ff6b00',
  374. },
  375. balance: {
  376. color: '#ff6b00',
  377. },
  378. payTypeSection: {
  379. paddingVertical: 10,
  380. borderBottomWidth: 1,
  381. borderBottomColor: '#eee',
  382. },
  383. payTypeItem: {
  384. flexDirection: 'row',
  385. justifyContent: 'space-between',
  386. alignItems: 'center',
  387. paddingVertical: 10,
  388. },
  389. payTypeName: {
  390. fontSize: 14,
  391. color: '#333',
  392. },
  393. radio: {
  394. width: 20,
  395. height: 20,
  396. borderRadius: 10,
  397. borderWidth: 2,
  398. borderColor: '#ddd',
  399. },
  400. radioActive: {
  401. borderColor: '#ff6b00',
  402. backgroundColor: '#ff6b00',
  403. },
  404. phoneRow: {
  405. paddingVertical: 15,
  406. borderBottomWidth: 1,
  407. borderBottomColor: '#eee',
  408. },
  409. phoneLabel: {
  410. fontSize: 14,
  411. color: '#333',
  412. marginBottom: 10,
  413. },
  414. required: {
  415. color: '#dd524d',
  416. },
  417. phoneInput: {
  418. height: 40,
  419. backgroundColor: '#f5f5f5',
  420. borderRadius: 8,
  421. paddingHorizontal: 12,
  422. fontSize: 14,
  423. },
  424. addressRow: {
  425. flexDirection: 'row',
  426. justifyContent: 'space-between',
  427. alignItems: 'center',
  428. paddingVertical: 15,
  429. borderBottomWidth: 1,
  430. borderBottomColor: '#eee',
  431. },
  432. addressInfo: {
  433. flex: 1,
  434. },
  435. addressName: {
  436. fontSize: 14,
  437. color: '#333',
  438. fontWeight: '500',
  439. },
  440. addressDetail: {
  441. fontSize: 12,
  442. color: '#666',
  443. marginTop: 4,
  444. },
  445. noAddress: {
  446. fontSize: 14,
  447. color: '#999',
  448. },
  449. arrow: {
  450. fontSize: 20,
  451. color: '#999',
  452. marginLeft: 10,
  453. },
  454. agreementRow: {
  455. flexDirection: 'row',
  456. justifyContent: 'space-between',
  457. alignItems: 'center',
  458. paddingVertical: 15,
  459. },
  460. agreementText: {
  461. fontSize: 14,
  462. color: '#666',
  463. },
  464. payBtn: {
  465. marginHorizontal: 15,
  466. marginTop: 15,
  467. height: 50,
  468. backgroundColor: '#ff6b00',
  469. borderRadius: 25,
  470. justifyContent: 'center',
  471. alignItems: 'center',
  472. },
  473. payBtnDisabled: {
  474. opacity: 0.6,
  475. },
  476. payBtnText: {
  477. color: '#fff',
  478. fontSize: 16,
  479. fontWeight: '600',
  480. },
  481. });