PaypalApplepayButton.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. "use client";
  2. import {useRef,useEffect,useCallback} from "react";
  3. import { useRouter } from 'next/navigation';
  4. import { usePlaceOrder } from "@/utils/hooks/usePlaceOrder";
  5. import {
  6. usePayPal,
  7. useEligibleMethods,
  8. INSTANCE_LOADING_STATE,
  9. useApplePayOneTimePaymentSession,
  10. type ConfirmOrderResponse,
  11. type UseApplePayOneTimePaymentSessionProps,
  12. } from "@paypal/react-paypal-js/sdk-v6";
  13. import { overlayLoading } from "@/components/theme/ui/kernel/loading/api";
  14. import { confirmDialog } from "@/components/theme/ui/kernel/confirm/api";
  15. import { CartAddress } from "@/types/cart/type";
  16. import { useConfig } from "@/utils/hooks/useConfig";
  17. import {ApplePayAddressContact} from "@/lib/Applepay/type";
  18. // import {getPaypalApplePayConfig, type PaypalApplePaySession} from "@/lib/Applepay/applepay";
  19. export default function PaypalApplepayButton({
  20. createOrder,
  21. validateCheckout,
  22. billingContact,
  23. shippingContact,
  24. grandTotal,
  25. isRePay
  26. }:{
  27. validateCheckout: () => Promise<boolean>;
  28. createOrder: () => Promise<{orderId: string;webOrderId: string;}>;
  29. billingContact: CartAddress | null;
  30. shippingContact: CartAddress | null;
  31. grandTotal: number;
  32. isRePay?: boolean;
  33. }) {
  34. const router = useRouter();
  35. const {createPaymentCallback} = usePlaceOrder();
  36. const {countries,store,getCurrentCurrencyItem} = useConfig();
  37. const currentCurrency = getCurrentCurrencyItem();
  38. const currencyCode = currentCurrency.code;
  39. const storeName = store.storeName;
  40. const gatewayOrderIdRef = useRef("");
  41. const webOrderIdRef = useRef("");
  42. // const [configCountryCode, setConfigCountryCode] = useState('');
  43. // paypal sdk 是否加载完成
  44. const { loadingStatus, isHydrated } = usePayPal();
  45. // Fetch eligibility(资格) for one-time payment flow
  46. const {
  47. error: eligibilityError,
  48. eligiblePaymentMethods,
  49. isLoading: isEligibilityLoading,
  50. } = useEligibleMethods({
  51. payload: {
  52. currencyCode: currencyCode, // 需要根据用户选择的货币走
  53. paymentFlow: "ONE_TIME_PAYMENT", // 一次性支付
  54. },
  55. });
  56. const isLoading = loadingStatus === INSTANCE_LOADING_STATE.PENDING;
  57. const isApplepayEligible = eligiblePaymentMethods?.isEligible("applepay");
  58. const applePayConfig = eligiblePaymentMethods?.getDetails("applepay");
  59. // 支付回调配置
  60. /**
  61. * when the user has authorized the Apple Pay payment with Touch ID, Face ID, or a passcode.
  62. * ApplePaySession onpaymentauthorized 时触发 */
  63. const configCreateOrder = useCallback(async () => {
  64. const res = await createOrder();
  65. gatewayOrderIdRef.current = res.orderId;
  66. webOrderIdRef.current = res.webOrderId;
  67. return res;
  68. },[createOrder]);
  69. /**await paypal.Applepay().confirmOrder() 后调onApprove */
  70. const onApprove = useCallback(async (data: ConfirmOrderResponse) => {
  71. console.log("applepay approved:", data);
  72. /**
  73. * approveApplePayPayment: {
  74. * id: string; //gatewayOrderId
  75. * status: "APPROVED"
  76. * }
  77. */
  78. const resCallback = await createPaymentCallback({
  79. orderId: Number(webOrderIdRef.current),
  80. gatewayOrderId: data.approveApplePayPayment.id || gatewayOrderIdRef.current,
  81. status: 'success',
  82. });
  83. overlayLoading.stop();
  84. if(!resCallback.error) {
  85. // 支付成功,跳转到成功落地页
  86. confirmDialog({
  87. title: "Payment Success",
  88. content: "Your payment was successful. Will redirect to success page.",
  89. noCancel: true,
  90. }).then(() => {
  91. router.replace('/paymentresult/result?orderId=' + webOrderIdRef.current + '&return_result=success');
  92. });
  93. } else {
  94. // callback 失败,提示错误
  95. confirmDialog({
  96. title: "Somthing Wrong",
  97. content: resCallback.msg + " Please contact customer service." + " OrderId: "+ webOrderIdRef.current,
  98. noCancel: true,
  99. }).then(() => {
  100. router.replace('/');
  101. });
  102. }
  103. // overlayLoading.stop();
  104. // router.replace('/paymentresult/result?orderId=' + webOrderIdRef.current + '&return_result=success');
  105. },[router]);
  106. /**ApplePaySession oncancel */
  107. const onCancel = useCallback(() => {
  108. console.log("applepay cancelled:");
  109. overlayLoading.stop();
  110. confirmDialog({
  111. title: "Payment cancelled",
  112. content: "You canceled the payment.",
  113. noCancel: true,
  114. }).then(() => { });
  115. },[]);
  116. /**
  117. * 1. ApplePaySession onvalidatemerchant 出错
  118. * 2. ApplePaySession completePayment 出错
  119. * 3. createOrder->paypal.Applepay().confirmOrder()->onApprove() 链条出错
  120. * 4. 用户点击后的整个链条出错
  121. */
  122. const onError = useCallback(async (error: Error) => {
  123. console.error("applepay error:", error);
  124. if(!isRePay) {
  125. if(webOrderIdRef.current || gatewayOrderIdRef.current) {
  126. await createPaymentCallback({
  127. orderId: Number(webOrderIdRef.current),
  128. gatewayOrderId: gatewayOrderIdRef.current,
  129. status: 'failure',
  130. });
  131. overlayLoading.stop();
  132. // 订单创建成功了,跳转二次支付页面
  133. confirmDialog({
  134. title: "Payment failed",
  135. content: error.message,
  136. noCancel: true,
  137. }).then(() => {
  138. router.replace('/paymentresult/result?orderId=' + webOrderIdRef.current + '&return_result=failure');
  139. });
  140. } else {
  141. overlayLoading.stop();
  142. // 订单创建失败,留在当前页面
  143. confirmDialog({
  144. title: "Payment failed",
  145. content: error.message + " Please try again.",
  146. noCancel: true,
  147. }).then(() => { });
  148. }
  149. } else {
  150. overlayLoading.stop();
  151. // 登录用户 二次支付页面
  152. confirmDialog({
  153. title: "Payment Failed",
  154. content: error.message,
  155. noCancel: true,
  156. }).then(() => { });
  157. }
  158. },[router,isRePay]);
  159. const addressContact: ApplePayAddressContact = {};
  160. if(billingContact) {
  161. const billCountry = countries.find((item) => item.code === billingContact.country);
  162. addressContact.billingContact = {
  163. addressLines: [billingContact.address],
  164. administrativeArea: billingContact.state,
  165. country: billCountry?.name,
  166. countryCode: billingContact.country,
  167. emailAddress: billingContact.email,
  168. familyName: billingContact.lastName,
  169. givenName: billingContact.firstName,
  170. locality: billingContact.city,
  171. postalCode:billingContact.postcode,
  172. }
  173. }
  174. if(shippingContact) {
  175. const shipCountry = countries.find((item) => item.code === shippingContact.country);
  176. addressContact.shippingContact = {
  177. addressLines: [shippingContact.address],
  178. administrativeArea: shippingContact.state,
  179. country: shipCountry?.name,
  180. countryCode: shippingContact.country,
  181. emailAddress: shippingContact.email,
  182. familyName: shippingContact.lastName,
  183. givenName: shippingContact.firstName,
  184. locality: shippingContact.city,
  185. postalCode:shippingContact.postcode,
  186. }
  187. }
  188. console.log('applePayConfig ---- ', applePayConfig?.config);
  189. /*
  190. useEffect(() => {
  191. if(!sdkInstance || !isHydrated) return;
  192. const getConfig = async () => {
  193. const paypalSdkApplePayPaymentSession = await sdkInstance.createApplePayOneTimePaymentSession() as PaypalApplePaySession;
  194. const res = await getPaypalApplePayConfig(paypalSdkApplePayPaymentSession);
  195. console.log('res ---- ', res);
  196. setConfigCountryCode(res.countryCode);
  197. };
  198. getConfig();
  199. }, [isHydrated, sdkInstance]);
  200. */
  201. const merchantCountry = applePayConfig?.config.merchantCountry;
  202. if(!isApplepayEligible) {
  203. return <p className="text-ly-12 text-ly-errorcolor text-center">Applepay is not available on your device.</p>;
  204. }
  205. return (
  206. <div className="box-border w-full">
  207. {isLoading || !isHydrated || isEligibilityLoading || applePayConfig === undefined ?
  208. <p className="text-ly-12 text-center">Applepay is loading...</p>
  209. :
  210. <ApplePayButtonBox
  211. onClickHandler={validateCheckout}
  212. eligibilityError={eligibilityError}
  213. applePayConfig={applePayConfig.config}
  214. applePaySessionVersion={4}
  215. paymentRequest={{
  216. countryCode: merchantCountry === 'C2' ? 'CN' : merchantCountry as string,
  217. currencyCode: currencyCode,
  218. requiredBillingContactFields: ["postalAddress"],
  219. requiredShippingContactFields: ["name", "email", "postalAddress"],
  220. total: {
  221. label: storeName,
  222. amount: String(grandTotal),
  223. type: "final",
  224. },
  225. ...addressContact
  226. }}
  227. createOrder={configCreateOrder}
  228. onApprove={onApprove}
  229. onCancel={onCancel}
  230. onError={onError}
  231. />
  232. }
  233. </div>
  234. );
  235. }
  236. // ApplePayButtonBox是 @paypal/react-paypal-js/sdk-v6 中的<ApplePayOneTimePaymentButton>组件的代码复制过来的
  237. function ApplePayButtonBox({
  238. eligibilityError,
  239. onClickHandler, // 点击苹果支付按钮
  240. ...hookProps
  241. }: {
  242. eligibilityError: Error | null;
  243. onClickHandler: () => Promise<boolean>;
  244. } & UseApplePayOneTimePaymentSessionProps
  245. ) {
  246. const { error, handleClick, handleDestroy } = useApplePayOneTimePaymentSession(hookProps);
  247. const buttonRef = useRef<HTMLElement>(null);
  248. const handleClickRef = useRef(handleClick);
  249. // handleClickRef.current = handleClick;
  250. useEffect(() => {
  251. handleClickRef.current = handleClick;
  252. }, [handleClick]);
  253. // Apple's <apple-pay-button> manages its own enabled/disabled state internally
  254. // via canMakePayments(); we deliberately don't add an SDK-level disabled layer
  255. // (merchants control presentation themselves). React's onClick also doesn't
  256. // work on the element due to its shadow DOM, so we attach the handler directly.
  257. useEffect(() => {
  258. const el = buttonRef.current;
  259. if (!el) { return; }
  260. const applepayButtonClick = () => {
  261. overlayLoading.start();
  262. onClickHandler().then((res) => {
  263. if(res) {
  264. handleClickRef.current().catch(() => {
  265. // Errors are captured by the hook's setError
  266. });
  267. } else {
  268. overlayLoading.stop();
  269. }
  270. }).catch((err) => {
  271. overlayLoading.stop();
  272. console.error(err);
  273. });
  274. };
  275. el.addEventListener("click", applepayButtonClick);
  276. return () => el.removeEventListener("click", applepayButtonClick);
  277. }, [onClickHandler]);
  278. useEffect(() => {
  279. if (error) {
  280. console.error(error);
  281. }
  282. }, [error]);
  283. // Cleanup on unmount
  284. useEffect(() => {
  285. return () => {
  286. handleDestroy();
  287. };
  288. }, [handleDestroy]);
  289. return (
  290. <>
  291. <apple-pay-button
  292. ref={buttonRef}
  293. buttonstyle="black"
  294. type="pay"
  295. locale="en"
  296. />
  297. { eligibilityError && <p className="text-ly-errorcolor text-ly-12 text-center">{eligibilityError.message}</p> }
  298. </>
  299. );
  300. };