CheckoutModal.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. import { Image } from 'expo-image';
  2. import React, { forwardRef, useImperativeHandle, useState } from 'react';
  3. import {
  4. ActivityIndicator,
  5. Alert,
  6. Dimensions,
  7. Modal,
  8. ScrollView,
  9. StyleSheet,
  10. Text,
  11. TouchableOpacity,
  12. View
  13. } from 'react-native';
  14. import { applyOrder, getApplyResult, previewOrder } from '@/services/award';
  15. const { width: SCREEN_WIDTH } = Dimensions.get('window');
  16. // 等级配置
  17. const LEVEL_MAP: Record<string, { title: string; color: string }> = {
  18. A: { title: '超神款', color: '#FF4444' },
  19. B: { title: '欧皇款', color: '#FF9600' },
  20. C: { title: '隐藏款', color: '#9B59B6' },
  21. D: { title: '普通款', color: '#666666' },
  22. };
  23. interface CheckoutModalProps {
  24. data: any;
  25. poolId: string;
  26. onSuccess: (param: { num: number; tradeNo: string }) => void;
  27. boxNumber?: string;
  28. }
  29. export interface CheckoutModalRef {
  30. show: (num: number, preview: any, boxNum?: string, seatNumbers?: number[], packFlag?: boolean) => void;
  31. showFreedom: () => void;
  32. close: () => void;
  33. }
  34. interface LotteryItem {
  35. id: string;
  36. name: string;
  37. cover: string;
  38. level: string;
  39. spu?: { marketPrice: number };
  40. }
  41. // 自由购买数量选项
  42. const FREEDOM_NUMS = [10, 20, 30, 40, 50];
  43. export const CheckoutModal = forwardRef<CheckoutModalRef, CheckoutModalProps>(
  44. ({ data, poolId, onSuccess, boxNumber }, ref) => {
  45. const [visible, setVisible] = useState(false);
  46. const [num, setNum] = useState(1);
  47. const [checked, setChecked] = useState(true);
  48. const [loading, setLoading] = useState(false);
  49. const [freedomNum, setFreedomNum] = useState(10);
  50. const [freedomSelectVisible, setFreedomSelectVisible] = useState(false);
  51. // 预览数据
  52. const [coin, setCoin] = useState<number | null>(null);
  53. const [couponAmount, setCouponAmount] = useState<number | null>(null);
  54. const [lastPrice, setLastPrice] = useState<number | null>(null);
  55. const [magic, setMagic] = useState<any>(null);
  56. const [cash, setCash] = useState<any>(null);
  57. const [cashChecked, setCashChecked] = useState(false);
  58. // 盒子相关
  59. const [boxNum, setBoxNum] = useState<string | undefined>(boxNumber);
  60. const [seatNumbers, setSeatNumbers] = useState<number[] | undefined>();
  61. const [packFlag, setPackFlag] = useState<boolean | undefined>();
  62. // 抽奖结果
  63. const [resultVisible, setResultVisible] = useState(false);
  64. const [resultLoading, setResultLoading] = useState(false);
  65. const [resultList, setResultList] = useState<LotteryItem[]>([]);
  66. // 设置预览数据
  67. const setPreviewData = (previewData: any) => {
  68. setCoin(previewData.magicAmount || null);
  69. setCouponAmount(previewData.couponAmount || null);
  70. setLastPrice(previewData.paymentAmount);
  71. setMagic(previewData.magic || null);
  72. if (previewData.cash && previewData.cash.balance > previewData.paymentAmount) {
  73. setCash(previewData.cash);
  74. setCashChecked(true);
  75. } else {
  76. setCash(previewData.cash || null);
  77. setCashChecked(false);
  78. }
  79. };
  80. useImperativeHandle(ref, () => ({
  81. show: (n: number, previewData: any = {}, bNum?: string, seats?: number[], pack?: boolean) => {
  82. setNum(n);
  83. setBoxNum(bNum);
  84. setSeatNumbers(seats);
  85. setPackFlag(pack || undefined);
  86. setPreviewData(previewData);
  87. setVisible(true);
  88. },
  89. showFreedom: () => {
  90. setFreedomNum(10);
  91. setFreedomSelectVisible(true);
  92. },
  93. close: () => {
  94. setVisible(false);
  95. setFreedomSelectVisible(false);
  96. setResultVisible(false);
  97. },
  98. }));
  99. const handleFreedomSelect = async (selectedNum: number) => {
  100. setFreedomSelectVisible(false);
  101. setLoading(true);
  102. try {
  103. const preview = await previewOrder(poolId, selectedNum);
  104. if (preview) {
  105. setNum(selectedNum);
  106. setFreedomNum(selectedNum);
  107. setPreviewData(preview);
  108. setVisible(true);
  109. }
  110. } catch (error: any) {
  111. Alert.alert('提示', error?.message || '获取订单信息失败');
  112. } finally {
  113. setLoading(false);
  114. }
  115. };
  116. const close = () => {
  117. setVisible(false);
  118. setFreedomSelectVisible(false);
  119. };
  120. const closeResult = () => {
  121. setResultVisible(false);
  122. setResultList([]);
  123. onSuccess({ tradeNo: '', num });
  124. };
  125. // 支付
  126. const pay = async () => {
  127. if (loading) return;
  128. if (!checked) {
  129. Alert.alert('提示', '请同意《宝箱服务协议》');
  130. return;
  131. }
  132. setLoading(true);
  133. try {
  134. const paymentType = 'WALLET';
  135. const payNum = packFlag ? 1 : num;
  136. const res = await applyOrder(poolId, payNum, paymentType, boxNum, seatNumbers, packFlag);
  137. if (res?.paySuccess || res?.bizTradeNo || res?.tradeNo) {
  138. const tradeNo = res.bizTradeNo || res.tradeNo;
  139. setVisible(false);
  140. fetchLotteryResult(tradeNo);
  141. } else {
  142. Alert.alert('提示', res?.message || '支付失败,请重试');
  143. }
  144. } catch (error: any) {
  145. Alert.alert('支付失败', error?.message || '请稍后重试');
  146. } finally {
  147. setLoading(false);
  148. }
  149. };
  150. // 获取抽奖结果
  151. const fetchLotteryResult = async (tradeNo: string) => {
  152. setResultLoading(true);
  153. setResultVisible(true);
  154. setResultList([]);
  155. let attempts = 0;
  156. const maxAttempts = 5;
  157. const poll = async () => {
  158. try {
  159. const res = await getApplyResult(tradeNo);
  160. if (res?.inventoryList && res.inventoryList.length > 0) {
  161. setResultList(res.inventoryList);
  162. setResultLoading(false);
  163. } else if (attempts < maxAttempts) {
  164. attempts++;
  165. setTimeout(poll, 1000);
  166. } else {
  167. setResultLoading(false);
  168. Alert.alert('提示', '获取结果超时,请在仓库中查看');
  169. }
  170. } catch {
  171. if (attempts < maxAttempts) {
  172. attempts++;
  173. setTimeout(poll, 1000);
  174. } else {
  175. setResultLoading(false);
  176. Alert.alert('提示', '获取结果失败,请在仓库中查看');
  177. }
  178. }
  179. };
  180. poll();
  181. };
  182. const displayPrice = lastPrice ?? (data?.price || 0) * num;
  183. return (
  184. <>
  185. {/* 自由购买数量选择弹窗 */}
  186. <Modal visible={freedomSelectVisible} transparent animationType="fade" onRequestClose={() => setFreedomSelectVisible(false)}>
  187. <View style={styles.overlay}>
  188. <TouchableOpacity style={styles.mask} activeOpacity={1} onPress={() => setFreedomSelectVisible(false)} />
  189. <View style={styles.freedomContainer}>
  190. <View style={styles.header}>
  191. <Text style={styles.title}>购买多盒</Text>
  192. <TouchableOpacity onPress={() => setFreedomSelectVisible(false)} style={styles.closeBtn}>
  193. <Text style={styles.closeText}>×</Text>
  194. </TouchableOpacity>
  195. </View>
  196. <View style={styles.freedomContent}>
  197. <View style={styles.freedomBtnList}>
  198. {FREEDOM_NUMS.map((item) => (
  199. <TouchableOpacity
  200. key={item}
  201. style={[styles.freedomBtn, freedomNum === item && styles.freedomBtnActive]}
  202. onPress={() => setFreedomNum(item)}
  203. >
  204. <Text style={[styles.freedomBtnText, freedomNum === item && styles.freedomBtnTextActive]}>
  205. {item}<Text style={styles.freedomUnit}>盒</Text>
  206. </Text>
  207. {freedomNum === item && <Text style={styles.checkIcon}>✓</Text>}
  208. </TouchableOpacity>
  209. ))}
  210. </View>
  211. <TouchableOpacity
  212. style={[styles.freedomSubmitBtn, loading && styles.payBtnDisabled]}
  213. onPress={() => handleFreedomSelect(freedomNum)}
  214. disabled={loading}
  215. >
  216. {loading ? (
  217. <ActivityIndicator color="#fff" size="small" />
  218. ) : (
  219. <Text style={styles.freedomSubmitText}>确认 ¥{(data?.price || 0) * freedomNum}</Text>
  220. )}
  221. </TouchableOpacity>
  222. </View>
  223. </View>
  224. </View>
  225. </Modal>
  226. {/* 支付确认弹窗 */}
  227. <Modal visible={visible} transparent animationType="slide" onRequestClose={close}>
  228. <View style={styles.overlay}>
  229. <TouchableOpacity style={styles.mask} activeOpacity={1} onPress={close} />
  230. <View style={styles.container}>
  231. <View style={styles.header}>
  232. <Text style={styles.title}>{data?.name}</Text>
  233. <TouchableOpacity onPress={close} style={styles.closeBtn}>
  234. <Text style={styles.closeText}>×</Text>
  235. </TouchableOpacity>
  236. </View>
  237. <View style={styles.content}>
  238. <View style={styles.row}>
  239. <Text style={styles.label}>购买件数</Text>
  240. <Text style={styles.priceText}>¥{data?.price} x {num}</Text>
  241. </View>
  242. <View style={styles.row}>
  243. <Text style={styles.label}>优惠券</Text>
  244. <Text style={[styles.valueText, couponAmount ? styles.themeColor : {}]}>
  245. {couponAmount ? `已使用优惠¥${couponAmount}` : '暂无优惠券可选'}
  246. </Text>
  247. </View>
  248. {magic && magic.balance > 0 && (
  249. <View style={styles.row}>
  250. <View style={styles.rowLeft}>
  251. <Text style={styles.label}>果实</Text>
  252. <Text style={styles.balanceText}>(剩余:{magic.balance})</Text>
  253. </View>
  254. <Text style={styles.balanceText}>
  255. 已抵扣 <Text style={styles.themeColor}>¥{coin || 0}</Text>
  256. </Text>
  257. </View>
  258. )}
  259. {cash && (
  260. <View style={styles.row}>
  261. <View style={styles.rowLeft}>
  262. <Text style={styles.label}>钱包支付</Text>
  263. <Text style={styles.themeColor}>(余额:¥{cash.balance})</Text>
  264. </View>
  265. <TouchableOpacity
  266. style={[styles.radio, cashChecked && styles.radioChecked]}
  267. onPress={() => setCashChecked(!cashChecked)}
  268. >
  269. {cashChecked && <View style={styles.radioInner} />}
  270. </TouchableOpacity>
  271. </View>
  272. )}
  273. <View style={styles.agreementRow}>
  274. <View style={styles.agreementLeft}>
  275. <Text style={styles.agreementText}>
  276. 我已满18周岁,已阅读并同意<Text style={styles.link}>《宝箱服务协议》</Text>
  277. </Text>
  278. <Text style={styles.tips}>宝箱商品存在概率性,请谨慎消费</Text>
  279. </View>
  280. <TouchableOpacity
  281. style={[styles.radio, checked && styles.radioChecked]}
  282. onPress={() => setChecked(!checked)}
  283. >
  284. {checked && <View style={styles.radioInner} />}
  285. </TouchableOpacity>
  286. </View>
  287. </View>
  288. <View style={styles.footer}>
  289. <View style={styles.priceInfo}>
  290. <Text style={styles.totalLabel}>实付:</Text>
  291. <Text style={styles.totalPrice}>¥{displayPrice.toFixed(2)}</Text>
  292. </View>
  293. <TouchableOpacity
  294. style={[styles.payBtn, loading && styles.payBtnDisabled]}
  295. onPress={pay}
  296. disabled={loading}
  297. >
  298. {loading ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.payBtnText}>立即支付</Text>}
  299. </TouchableOpacity>
  300. </View>
  301. </View>
  302. </View>
  303. </Modal>
  304. {/* 抽奖结果弹窗 */}
  305. <Modal visible={resultVisible} transparent animationType="fade" onRequestClose={closeResult}>
  306. <View style={styles.resultOverlay}>
  307. <View style={styles.resultContainer}>
  308. <View style={styles.resultHeader}>
  309. <Text style={styles.resultTitle}>🎉 恭喜您获得 🎉</Text>
  310. <TouchableOpacity onPress={closeResult} style={styles.resultCloseBtn}>
  311. <Text style={styles.closeText}>×</Text>
  312. </TouchableOpacity>
  313. </View>
  314. {resultLoading ? (
  315. <View style={styles.resultLoading}>
  316. <ActivityIndicator size="large" color="#ff9600" />
  317. <Text style={styles.resultLoadingText}>正在开启宝箱...</Text>
  318. </View>
  319. ) : (
  320. <ScrollView style={styles.resultScroll} showsVerticalScrollIndicator={false}>
  321. <View style={styles.resultList}>
  322. {resultList.map((item, index) => (
  323. <View key={item.id || index} style={styles.resultItem}>
  324. <View style={[styles.levelBadge, { backgroundColor: LEVEL_MAP[item.level]?.color || '#666' }]}>
  325. <Text style={styles.levelText}>{LEVEL_MAP[item.level]?.title || item.level}</Text>
  326. </View>
  327. <View style={styles.resultImageBox}>
  328. <Image source={{ uri: item.cover }} style={styles.resultImage} contentFit="contain" />
  329. </View>
  330. <Text style={styles.resultName} numberOfLines={2}>{item.name}</Text>
  331. {item.spu?.marketPrice && (
  332. <Text style={styles.resultPrice}>参考价:¥{item.spu.marketPrice}</Text>
  333. )}
  334. </View>
  335. ))}
  336. </View>
  337. </ScrollView>
  338. )}
  339. <View style={styles.resultFooter}>
  340. <TouchableOpacity style={styles.resultBtn} onPress={closeResult}>
  341. <Text style={styles.resultBtnText}>继续抽奖</Text>
  342. </TouchableOpacity>
  343. </View>
  344. </View>
  345. </View>
  346. </Modal>
  347. </>
  348. );
  349. }
  350. );
  351. const styles = StyleSheet.create({
  352. overlay: {
  353. flex: 1,
  354. backgroundColor: 'rgba(0,0,0,0.5)',
  355. justifyContent: 'flex-end',
  356. },
  357. mask: { flex: 1 },
  358. container: {
  359. backgroundColor: '#fff',
  360. borderTopLeftRadius: 20,
  361. borderTopRightRadius: 20,
  362. paddingBottom: 34,
  363. },
  364. header: {
  365. flexDirection: 'row',
  366. alignItems: 'center',
  367. justifyContent: 'center',
  368. padding: 15,
  369. borderBottomWidth: 1,
  370. borderBottomColor: '#eee',
  371. position: 'relative',
  372. },
  373. title: { fontSize: 16, fontWeight: '600', color: '#333' },
  374. closeBtn: { position: 'absolute', right: 15, top: 10 },
  375. closeText: { fontSize: 24, color: '#999' },
  376. content: { padding: 15 },
  377. row: {
  378. flexDirection: 'row',
  379. justifyContent: 'space-between',
  380. alignItems: 'center',
  381. paddingVertical: 10,
  382. },
  383. rowLeft: { flexDirection: 'row', alignItems: 'center', flex: 1 },
  384. label: { fontSize: 14, color: '#333' },
  385. priceText: { fontSize: 14, color: '#ff9600', fontWeight: '600' },
  386. valueText: { fontSize: 12, color: '#999' },
  387. themeColor: { color: '#ff9600' },
  388. balanceText: { fontSize: 12, color: '#999', marginLeft: 5 },
  389. radio: {
  390. width: 20,
  391. height: 20,
  392. borderRadius: 10,
  393. borderWidth: 2,
  394. borderColor: '#ddd',
  395. justifyContent: 'center',
  396. alignItems: 'center',
  397. },
  398. radioChecked: { borderColor: '#ff9600' },
  399. radioInner: { width: 10, height: 10, borderRadius: 5, backgroundColor: '#ff9600' },
  400. agreementRow: {
  401. flexDirection: 'row',
  402. justifyContent: 'space-between',
  403. alignItems: 'flex-start',
  404. paddingVertical: 10,
  405. marginTop: 10,
  406. },
  407. agreementLeft: { flex: 1, marginRight: 10 },
  408. agreementText: { fontSize: 12, color: '#333', lineHeight: 18 },
  409. link: { color: '#ff9600' },
  410. tips: { fontSize: 11, color: '#999', marginTop: 5 },
  411. footer: {
  412. flexDirection: 'row',
  413. alignItems: 'center',
  414. justifyContent: 'space-between',
  415. paddingHorizontal: 15,
  416. paddingTop: 15,
  417. borderTopWidth: 1,
  418. borderTopColor: '#eee',
  419. },
  420. priceInfo: { flexDirection: 'row', alignItems: 'center' },
  421. totalLabel: { fontSize: 14, color: '#333' },
  422. totalPrice: { fontSize: 20, color: '#ff9600', fontWeight: 'bold' },
  423. payBtn: {
  424. backgroundColor: '#ff9600',
  425. paddingHorizontal: 30,
  426. paddingVertical: 12,
  427. borderRadius: 25,
  428. },
  429. payBtnDisabled: { opacity: 0.6 },
  430. payBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  431. // 自由购买弹窗
  432. freedomContainer: {
  433. backgroundColor: '#fff',
  434. borderTopLeftRadius: 20,
  435. borderTopRightRadius: 20,
  436. paddingBottom: 34,
  437. },
  438. freedomContent: { padding: 20 },
  439. freedomBtnList: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between' },
  440. freedomBtn: {
  441. width: '48%',
  442. height: 50,
  443. backgroundColor: '#fff',
  444. borderRadius: 8,
  445. justifyContent: 'center',
  446. alignItems: 'center',
  447. marginBottom: 12,
  448. borderWidth: 1,
  449. borderColor: '#eee',
  450. position: 'relative',
  451. },
  452. freedomBtnActive: { backgroundColor: '#F1423D', borderColor: '#F1423D' },
  453. freedomBtnText: { fontSize: 18, fontWeight: 'bold', color: '#333' },
  454. freedomBtnTextActive: { color: '#fff' },
  455. freedomUnit: { fontSize: 12, fontWeight: '500' },
  456. checkIcon: {
  457. position: 'absolute',
  458. bottom: 0,
  459. right: 0,
  460. backgroundColor: '#fff',
  461. color: '#F1423D',
  462. fontSize: 12,
  463. paddingHorizontal: 6,
  464. paddingVertical: 2,
  465. borderTopLeftRadius: 8,
  466. borderBottomRightRadius: 8,
  467. },
  468. freedomSubmitBtn: {
  469. backgroundColor: '#ff9600',
  470. height: 50,
  471. borderRadius: 25,
  472. justifyContent: 'center',
  473. alignItems: 'center',
  474. marginTop: 20,
  475. },
  476. freedomSubmitText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  477. // 抽奖结果弹窗
  478. resultOverlay: {
  479. flex: 1,
  480. backgroundColor: 'rgba(0,0,0,0.7)',
  481. justifyContent: 'center',
  482. alignItems: 'center',
  483. padding: 20,
  484. },
  485. resultContainer: {
  486. width: '100%',
  487. maxHeight: '80%',
  488. backgroundColor: '#fff',
  489. borderRadius: 16,
  490. overflow: 'hidden',
  491. },
  492. resultHeader: {
  493. alignItems: 'center',
  494. padding: 20,
  495. backgroundColor: '#ff9600',
  496. position: 'relative',
  497. },
  498. resultTitle: {
  499. fontSize: 20,
  500. fontWeight: 'bold',
  501. color: '#fff',
  502. },
  503. resultCloseBtn: {
  504. position: 'absolute',
  505. right: 15,
  506. top: 15,
  507. width: 30,
  508. height: 30,
  509. backgroundColor: 'rgba(255,255,255,0.3)',
  510. borderRadius: 15,
  511. justifyContent: 'center',
  512. alignItems: 'center',
  513. },
  514. resultLoading: {
  515. padding: 60,
  516. alignItems: 'center',
  517. },
  518. resultLoadingText: {
  519. marginTop: 15,
  520. fontSize: 14,
  521. color: '#666',
  522. },
  523. resultScroll: {
  524. maxHeight: 400,
  525. },
  526. resultList: {
  527. flexDirection: 'row',
  528. flexWrap: 'wrap',
  529. padding: 10,
  530. justifyContent: 'space-between',
  531. },
  532. resultItem: {
  533. width: (SCREEN_WIDTH - 80) / 2,
  534. backgroundColor: '#f9f9f9',
  535. borderRadius: 10,
  536. padding: 10,
  537. marginBottom: 10,
  538. alignItems: 'center',
  539. },
  540. levelBadge: {
  541. paddingHorizontal: 10,
  542. paddingVertical: 3,
  543. borderRadius: 10,
  544. marginBottom: 8,
  545. },
  546. levelText: {
  547. color: '#fff',
  548. fontSize: 11,
  549. fontWeight: 'bold',
  550. },
  551. resultImageBox: {
  552. width: '100%',
  553. aspectRatio: 1,
  554. backgroundColor: '#fff',
  555. borderRadius: 8,
  556. overflow: 'hidden',
  557. },
  558. resultImage: {
  559. width: '100%',
  560. height: '100%',
  561. },
  562. resultName: {
  563. fontSize: 12,
  564. color: '#333',
  565. textAlign: 'center',
  566. marginTop: 8,
  567. lineHeight: 16,
  568. },
  569. resultPrice: {
  570. fontSize: 10,
  571. color: '#999',
  572. marginTop: 4,
  573. },
  574. resultFooter: {
  575. padding: 15,
  576. borderTopWidth: 1,
  577. borderTopColor: '#eee',
  578. },
  579. resultBtn: {
  580. backgroundColor: '#ff9600',
  581. height: 46,
  582. borderRadius: 23,
  583. justifyContent: 'center',
  584. alignItems: 'center',
  585. },
  586. resultBtnText: {
  587. color: '#fff',
  588. fontSize: 16,
  589. fontWeight: '600',
  590. },
  591. });