CheckoutWrapper.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. "use client";
  2. import {useState, useEffect, useRef} from "react";
  3. import { LoadingSpinner } from "@components/common/LoadingSpinner";
  4. import CheckoutShippingMethodLoading from "./CheckoutShippingMethodLoading";
  5. import { useForm, FormProvider } from "react-hook-form";
  6. import {
  7. Country,
  8. ShipAddressFormData,
  9. BillAddressFormData,
  10. FullAddressFormData,
  11. CreateCheckoutAddressVariables,
  12. } from "@/types/checkout/type";
  13. import AddressResultDisplay from "./AddressResultDisplay";
  14. import AddressResultDisplayLoading from "./AddressResultDisplayLoading";
  15. import ShippingAddressCheckout from "./ShippingAddressCheckout";
  16. import BillingAddressCheckout from "./BillingAddressCheckout";
  17. import {ShippingMethodCheckout,RefShippingMethodsHandle} from "./ShippingMethodCheckout";
  18. import { useCustomToast } from "@/utils/hooks/useToast";
  19. import {useCheckoutAddress} from "@/utils/hooks/useCheckoutAddress"
  20. import {useCheckoutPaymentMethod} from "@/utils/hooks/useCheckoutPaymentMethod";
  21. import {useCheckoutShippingMethod} from "@/utils/hooks/useCheckoutShippingMethod";
  22. import { useAppDispatch,useAppStore } from "@/store/hooks";
  23. import { updateCart } from "@/store/slices/cart-slice";
  24. import { useCartDetail } from "@utils/hooks/useCartDetail";
  25. import {PaymentMethodCheckout,RefPaymentMethodsHandle} from "./PaymentMethodCheckout";
  26. import LoadingPaymentMethod from "./LoadingPaymentMethod";
  27. import CheckoutPlaceOrder from "./CheckoutPlaceOrder";
  28. import CommonModal from "@/components/theme/ui/CommonModal";
  29. import {useSaveCheckoutCart} from "@/utils/hooks/useSaveCheckoutCart";
  30. import { formatCartDetail, shippingAddressToCartAddress, billingAddressToCartAddress } from "@/utils/cartDetailTools";
  31. import { overlayLoading } from "@/components/theme/ui/kernel/loading/api";
  32. import { CartDetail } from "@/types/cart/type";
  33. /***
  34. * 不用useForShipping字段了
  35. * 以shipping address 为准,根据shippingaddress 设置billing address
  36. * 保存完地址之后要重新获取运输方式和支付方式
  37. * 变更运输方式后重新获取支付方式
  38. * @todo 购物车为空 跳转到空购物车页面
  39. */
  40. /**
  41. * 生成createCheckoutAddress接口的参数
  42. */
  43. function generateSaveCheckoutAddressParam(formData: FullAddressFormData):CreateCheckoutAddressVariables {
  44. const formDataShippingAddress:ShipAddressFormData = {
  45. shippingAddressId: formData.shippingAddressId,
  46. shippingEmail: formData.shippingEmail,
  47. shippingFirstName: formData.shippingFirstName,
  48. shippingLastName: formData.shippingLastName,
  49. shippingCompanyName: formData.shippingCompanyName,
  50. shippingAddress: formData.shippingAddress,
  51. shippingCountry: formData.shippingCountry,
  52. shippingState: formData.shippingState,
  53. shippingCity: formData.shippingCity,
  54. shippingPostcode: formData.shippingPostcode,
  55. shippingPhoneNumber: formData.shippingPhoneNumber,
  56. };
  57. let formDataBillingAddress = {
  58. billingAddressId: formData.billingAddressId,
  59. billingEmail: formData.billingEmail,
  60. billingFirstName: formData.billingFirstName,
  61. billingLastName: formData.billingLastName,
  62. billingCompanyName : formData.billingCompanyName,
  63. billingAddress: formData.billingAddress,
  64. billingCountry: formData.billingCountry,
  65. billingState: formData.billingState,
  66. billingCity: formData.billingCity,
  67. billingPostcode: formData.billingPostcode,
  68. billingPhoneNumber: formData.billingPhoneNumber,
  69. useForShipping: false,
  70. };
  71. if(formData.billingSameAsShipping) {
  72. formDataBillingAddress = {
  73. billingAddressId: formData.billingAddressId,
  74. billingEmail: formDataShippingAddress.shippingEmail,
  75. billingFirstName: formDataShippingAddress.shippingFirstName,
  76. billingLastName: formDataShippingAddress.shippingLastName,
  77. billingCompanyName : formDataShippingAddress.shippingCompanyName,
  78. billingAddress: formDataShippingAddress.shippingAddress,
  79. billingCountry: formDataShippingAddress.shippingCountry,
  80. billingState: formDataShippingAddress.shippingState,
  81. billingCity: formDataShippingAddress.shippingCity,
  82. billingPostcode: formDataShippingAddress.shippingPostcode,
  83. billingPhoneNumber: formDataShippingAddress.shippingPhoneNumber,
  84. useForShipping: true,
  85. }
  86. }
  87. return {
  88. ...formDataShippingAddress,
  89. ...formDataBillingAddress
  90. };
  91. }
  92. export default function CheckoutWrapper({
  93. loginEmail,
  94. countries,
  95. }: {
  96. loginEmail: string;
  97. countries: Country[],
  98. }) {
  99. const dispatch = useAppDispatch();
  100. const { cartData } = useCartDetail();
  101. const {saveCheckoutCart} = useSaveCheckoutCart();
  102. const {
  103. data: shippingMethodDatas,
  104. loading: shippingMethodLoading,
  105. error: shippingMethodError,
  106. getShippingMethod,
  107. } = useCheckoutShippingMethod();
  108. const selectedPaymentMethod = cartData?.paymentMethod ?? 'paypal_smart_button';
  109. const {
  110. data: paymentMethodDatas,
  111. loading: paymentMethodLoading,
  112. error: paymentMethodError,
  113. getPaymentMethod,
  114. } = useCheckoutPaymentMethod();
  115. const { showToast } = useCustomToast();
  116. const { getCheckoutAddress, saveCheckoutAddress } = useCheckoutAddress(loginEmail);
  117. const methodShiippingRef = useRef<RefShippingMethodsHandle>(null);
  118. const methodPaymentRef = useRef<RefPaymentMethodsHandle>(null);
  119. // useForm() 只会在组件初始化时读取一次 defaultValues
  120. const addressForm = useForm<FullAddressFormData>({
  121. mode: "onBlur", // 或 "onChange"
  122. reValidateMode: "onChange",
  123. defaultValues: {
  124. "shippingAddressId": "",
  125. "shippingEmail": loginEmail,
  126. "shippingFirstName": "",
  127. "shippingLastName": "jack",
  128. "shippingCompanyName": "",
  129. "shippingAddress": "",
  130. "shippingCountry": "US",
  131. "shippingState": "",
  132. "shippingCity": "",
  133. "shippingPostcode": "",
  134. "shippingPhoneNumber": "",
  135. "billingAddressId": "",
  136. "billingEmail": "",
  137. "billingFirstName": "",
  138. "billingLastName": "",
  139. "billingCompanyName": "",
  140. "billingAddress": "",
  141. "billingCountry": "US",
  142. "billingState": "",
  143. "billingCity": "",
  144. "billingPostcode": "",
  145. "billingPhoneNumber": "",
  146. "billingSameAsShipping": true
  147. }
  148. });
  149. const [addressModalOpen, setAddressModalOpen] = useState(false);
  150. const [loadingAddress, setLoadingAddress] = useState(true); // 初始加载地址数据时的loading状态
  151. const serverShippingAddress = useRef<ShipAddressFormData | null>(null);
  152. const serverBillingAddress = useRef<BillAddressFormData | null>(null);
  153. const [addressSaving, setAddressSaving] = useState(false); // 保存地址时的loading状态
  154. // 开发环境会执行两次 https://zh-hans.react.dev/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development
  155. useEffect(() => {
  156. getCheckoutAddress((shippingAddress,billingAddress)=> {
  157. addressForm.reset({
  158. ...shippingAddress,
  159. ...billingAddress,
  160. });
  161. serverShippingAddress.current = shippingAddress;
  162. serverBillingAddress.current = billingAddress;
  163. setLoadingAddress(false);
  164. }, ()=> {
  165. setLoadingAddress(false);
  166. });
  167. }, []);
  168. const openAddressModal = () => {
  169. setAddressModalOpen(true);
  170. if(serverShippingAddress.current && serverBillingAddress.current) {
  171. addressForm.reset({
  172. ...serverShippingAddress.current,
  173. ...serverBillingAddress.current,
  174. });
  175. }
  176. };
  177. const closeAddressModal = (e:boolean) => {
  178. setAddressModalOpen(e);
  179. // 重置表单
  180. addressForm.reset();
  181. };
  182. const addressFormOnSubmit = async (formData: FullAddressFormData) => {
  183. // console.log('addressFormOnSubmit ---- ',formData); return;
  184. if(addressSaving) {
  185. return;
  186. }
  187. const saveAddressParam = generateSaveCheckoutAddressParam(formData);
  188. setAddressSaving(true);
  189. try {
  190. const res = await saveCheckoutAddress(saveAddressParam);
  191. console.log('CREATE_CHECKOUT_ADDRESS res ====== ',res);
  192. // 地址保存成功后重新获取运输方式和支付方式
  193. getShippingMethod();
  194. getPaymentMethod();
  195. // 保存完地址之后还要重新请求地址,把保存后的地址id同步到表单里;
  196. const queryAddressRes = await getCheckoutAddress();
  197. if(queryAddressRes !== null) {
  198. addressForm.resetField('shippingAddressId',{
  199. defaultValue: queryAddressRes.shippingAddress.shippingAddressId
  200. });
  201. addressForm.resetField('billingAddressId',{
  202. defaultValue: queryAddressRes.billingAddress.billingAddressId
  203. });
  204. serverShippingAddress.current = queryAddressRes.shippingAddress;
  205. serverBillingAddress.current = queryAddressRes.billingAddress;
  206. // 同步地址到购物车详情
  207. dispatch(updateCart({
  208. billingAddress: billingAddressToCartAddress(queryAddressRes.billingAddress),
  209. shippingAddress: shippingAddressToCartAddress(queryAddressRes.shippingAddress)
  210. }));
  211. }
  212. setAddressModalOpen(false);
  213. } catch(err) {
  214. // 错误处理
  215. console.error("save address error", err);
  216. showToast('Save address failed. Please try again.', 'danger');
  217. } finally {
  218. setAddressSaving(false);
  219. }
  220. };
  221. const handlePaymentMethodChange = async (method: string,title: string) => {
  222. let shippingMethod = '-1';
  223. if(methodShiippingRef.current) {
  224. shippingMethod = methodShiippingRef.current.getSelectShipMethod();
  225. }
  226. overlayLoading.start();
  227. const saveRes = await saveCheckoutCart({
  228. shippingMethod: shippingMethod,
  229. paymentMethod: method,
  230. couponCode: '-1'
  231. });
  232. overlayLoading.stop();
  233. if(!saveRes.error) {
  234. if(saveRes.data) {
  235. const newCartDetail = formatCartDetail(saveRes.data);
  236. dispatch(updateCart(newCartDetail));
  237. }
  238. } else {
  239. showToast(saveRes.msg, 'danger');
  240. }
  241. /*
  242. const res = await savePaymentMethod({ paymentMethod: method });
  243. console.log('savePaymentMethod res ====== ',res);
  244. if(!res.error) {
  245. // 更新paymentMethod paymentMethodTitle
  246. dispatch(updateCart({
  247. paymentMethod: method,
  248. paymentMethodTitle: title
  249. }));
  250. } else {
  251. showToast(res.msg, 'danger');
  252. }
  253. */
  254. };
  255. const handleShippingMethodChange = async (method: string) => {
  256. overlayLoading.start();
  257. const saveRes = await saveCheckoutCart({
  258. shippingMethod: method,
  259. paymentMethod: '-1',
  260. couponCode: '-1'
  261. });
  262. overlayLoading.stop();
  263. if(!saveRes.error) {
  264. if(saveRes.data) {
  265. const newCartDetail = formatCartDetail(saveRes.data);
  266. dispatch(updateCart(newCartDetail));
  267. }
  268. // 保存成功之后需要重新获取支付方式
  269. getPaymentMethod();
  270. } else {
  271. showToast(saveRes.msg, 'danger');
  272. }
  273. /*
  274. const res = await saveShippingMethod({ shippingMethod: method });
  275. if(!res.error) {
  276. // 保存成功之后需要重新获取购物车详情和支付方式
  277. getCartDetail();
  278. getPaymentMethod();
  279. } else {
  280. showToast(res.msg, 'danger');
  281. }*/
  282. };
  283. // 下单
  284. // 1. 校验地址 运输方式,支付方式;
  285. // 2. 根据购物车详情判断是否需要传下单参数: 地址,运输方式,支付方式
  286. const handlePlaceOrder = async () => {
  287. // 校验地址
  288. const addressValid = await addressForm.trigger();
  289. if(!addressValid) {
  290. showToast('Please fill in the address information correctly.', 'danger');
  291. return false;
  292. }
  293. if(methodShiippingRef.current) {
  294. const shippingMethodValid = await methodShiippingRef.current.validateShipMethod();
  295. if(!shippingMethodValid) {
  296. showToast('Please select a shipping method.', 'danger');
  297. return false;
  298. }
  299. } else {
  300. showToast('Please wait for shipping method loading.', 'danger');
  301. return false;
  302. }
  303. if(methodPaymentRef.current) {
  304. const paymentMethodValid = await methodPaymentRef.current.validatePaymentMethod();
  305. if(!paymentMethodValid) {
  306. showToast('Please select a payment method.', 'danger');
  307. return false;
  308. }
  309. } else {
  310. showToast('Please wait for payment method loading.', 'danger');
  311. return false;
  312. }
  313. if(!cartData) {
  314. showToast('Please add product to your cart.', 'danger');
  315. return false;
  316. }
  317. //如果购物车详情没有保存过支付方式,则需要保存支付方式
  318. if(cartData?.paymentMethod === null) {
  319. const pmethod = methodPaymentRef?.current.getSelectPaymentMethod();
  320. const saveRes = await saveCheckoutCart({
  321. shippingMethod: '-1',
  322. paymentMethod: pmethod,
  323. couponCode: '-1'
  324. });
  325. if(!saveRes.error) {
  326. if(saveRes.data) {
  327. const newCartDetail = formatCartDetail(saveRes.data);
  328. dispatch(updateCart(newCartDetail));
  329. }
  330. }
  331. }
  332. return true;
  333. };
  334. // function tt() {
  335. // const arr = [
  336. // ['US','3803800217'],
  337. // ['CH','+4186043315'],
  338. // ['US','334-208-6177'],
  339. // ['AU','+61 404103617'],
  340. // ['US','1-9166705105'],
  341. // ['CA','819 384 3221'],
  342. // ['US','(409) 239-9482'],
  343. // ['US','1-(651) 421-2762'],
  344. // ['CH', '1-792930242'],
  345. // ['US', '+1 4570438892'],
  346. // ['US', '1-+1 (404) 276-1068'],
  347. // ['CH', '+49 015164340021'], // 国家与phonecode不一致情况
  348. // ];
  349. // arr.forEach((item) => {
  350. // normalizePhoneForForm(item[1], item[0]);
  351. // });
  352. // }
  353. return (
  354. <section className="w-full">
  355. <div>
  356. {/* <button className="w-25 h-8 bg-amber-500 flex items-center justify-center" onClick={tt}>test</button> */}
  357. {loadingAddress ?
  358. <AddressResultDisplayLoading />
  359. :
  360. <AddressResultDisplay shippingAddress={cartData?.shippingAddress ?? null}
  361. onAddressModalOpenClick={openAddressModal}
  362. />
  363. }
  364. <CommonModal
  365. isOpen={addressModalOpen}
  366. onClose={closeAddressModal}
  367. header={
  368. <div className="w-full box-border p-3.75 h-14.5 flex justify-between items-center">
  369. <span>Shipping Address</span>
  370. <button className="w-6 h-6" onClick={() => closeAddressModal(false)}>
  371. <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon" className="h-6 transition-all ease-in-out hover:scale-110"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12"></path></svg>
  372. </button>
  373. </div>
  374. }
  375. body={
  376. <div className="box-border w-full h-full p-3.75">
  377. <p className="text-ly-12 leading-ly-20">
  378. Select a billing address from your address book or enter a new address.
  379. </p>
  380. <div>
  381. <FormProvider {...addressForm}>
  382. <form>
  383. <ShippingAddressCheckout countries={countries} />
  384. <BillingAddressCheckout countries={countries} />
  385. </form>
  386. </FormProvider>
  387. </div>
  388. </div>
  389. }
  390. footer={
  391. <div className="box-border w-full p-3.75">
  392. <button className="block flex justify-center items-center w-full h-12 bg-ly-green rounded-3xl text-white text-ly-16 font-bold"
  393. onClick={addressForm.handleSubmit(addressFormOnSubmit)}
  394. >
  395. {addressSaving ? <LoadingSpinner /> : 'Save'}
  396. </button>
  397. </div>
  398. }
  399. />
  400. </div>
  401. <div className="mt-6 box-border px-4">
  402. <h3 className="text-ly-24 font-medium">Shipping Method</h3>
  403. {shippingMethodLoading ?
  404. <CheckoutShippingMethodLoading />
  405. :
  406. <ShippingMethodCheckout
  407. ref={methodShiippingRef}
  408. shipMethods={shippingMethodDatas}
  409. errorMsg={shippingMethodError}
  410. onShipMethodChange={handleShippingMethodChange}
  411. />
  412. }
  413. </div>
  414. <div className="mt-6 box-border px-4">
  415. <h3 className="text-ly-24 font-medium">Payment Method</h3>
  416. { paymentMethodLoading ?
  417. <LoadingPaymentMethod />
  418. :
  419. <PaymentMethodCheckout
  420. ref={methodPaymentRef}
  421. errorMsg={paymentMethodError}
  422. paymentMethods={paymentMethodDatas}
  423. onPaymentMethodChange={handlePaymentMethodChange}
  424. />
  425. }
  426. </div>
  427. <div className="mt-3 box-border px-4">
  428. <p className="text-ly-12 text-[#666666] leading-ly-20">By providing your information, you agree to Wiggins'sPrivacy Policyand Terms of Use.</p>
  429. <div className="mt-6 w-full">
  430. <CheckoutPlaceOrder
  431. paymentMethod={selectedPaymentMethod}
  432. clickPlaceOrder={handlePlaceOrder}
  433. />
  434. </div>
  435. </div>
  436. </section>
  437. );
  438. };