Просмотр исходного кода

airwallex 卡支付 初始化逻辑优化

fogwind 6 дней назад
Родитель
Сommit
9cc21004e0

+ 9 - 2
README.md

@@ -340,11 +340,11 @@ useEffect 只会在客户端执行,具体是在浏览器绘制后执行,服
 37. paypal provider 支付参数根据环境变量配置 --- 已完成
 38. CartAndUserActions 组件在支付结果页多次挂载问题
 39. 服务端去掉apollo client。apollo client 只在客户端使用; apollo 错误处理(第9条);
-40. Authorization 写入header优化;
+40. Authorization 写入header优化;-- 已完成
     ---- https://chatgpt.com/share/6a6acb5d-e184-83ea-acfc-839fe0d34caf
 41. 货币切换功能,货币切换后货币符号没有修改问题
 42. 错误处理 https://nextjs.org/docs/app/getting-started/error-handling
-43. redux中的user删除
+43. redux中的user删除   --- 已完成
 44. 关于订单id,管理后台订单列表里显示的是increment_id(数据库里的字段名),前端页面上用的是id(数据库里的字段名)对应接口里的orderId
 
 45. nextjs缓存与bagisto后台管理打通(后台修改配置通知nextjs清除缓存)
@@ -440,6 +440,13 @@ public/.well-known/apple-developer-merchantid-domain-association
 ```
 本地开发时使用ngrok测试支付。
 
+## 关于airwallex卡支付
+以页面生命周期为准,只能初始化一个卡支付。
+
+useAirwallexCard -- 负责管理卡支付输入框的生命周期:创建 --> 销毁
+useAirwallexCardState -- 负责获取卡支付输入框状态,使用useSyncExternalStore让react订阅状态变化
+airwallexManager -- 负责提供接口,创建,销毁,绑定事件,暴漏第三方接口等
+
 ## 关于hook
 hook 的定义:
 

+ 13 - 7
src/app/(checkout)/checkout/_components/CheckoutWrapper.tsx

@@ -10,6 +10,7 @@ import { updateCart, clearCart } from "@/store/slices/cart-slice";
 import {useSaveCheckoutCart} from "@/utils/hooks/useSaveCheckoutCart";
 import { usePlaceOrder } from "@/utils/hooks/usePlaceOrder";
 import {usePaymentSDKContext} from "@/providers/PaymentSDKProvider";
+import {airwallexManager} from "@/lib/Airwallex/useAirwallexCard";
 import { 
     PlaceOrderResult,
     PlaceOrderFunction,
@@ -21,6 +22,7 @@ import { overlayLoading } from "@/components/theme/ui/kernel/loading/api";
 import { confirmDialog } from "@/components/theme/ui/kernel/confirm/api";
 import {ShippingMethodCheckout,RefShippingMethodsHandle} from "./ShippingMethodCheckout";
 import {PaymentMethodCheckout,RefPaymentMethodsHandle} from "./PaymentMethodCheckout";
+import LoadingPaymentMethod from "./LoadingPaymentMethod";
 import {CheckoutAddress, RefCheckoutAddressHandle} from "./CheckoutAddress/CheckoutAssress";
 import LoadingCheckoutPlaceOrder from "./LoadingCheckoutPlaceOrder";
 import CheckoutPlaceOrder from "./CheckoutPlaceOrder";
@@ -202,8 +204,11 @@ export default function CheckoutWrapper({
                 return false;
             }
             if(paymentMethod === 'airwallex') {
-                const paymentMethodValid = methodPaymentRef.current.validateAirwallexCard();
-                return paymentMethodValid;
+                const airwallexCardValid = airwallexManager.validateCard();
+                if(airwallexCardValid.msg) {
+                    showToast(airwallexCardValid.msg,'danger');
+                }
+                return airwallexCardValid.valid;
             }
 
         } else {
@@ -270,9 +275,8 @@ export default function CheckoutWrapper({
             }
             const pmethod = methodPaymentRef.current?.getSelectPaymentMethod();
             let resultData: PlaceOrderResult<T>;
-            if(pmethod === 'airwallex' && methodPaymentRef.current) {
-                const airwallexCardElements = methodPaymentRef.current.getAirwallexCardElements();
-                const airCardNumber = airwallexCardElements.cardNumber as T;
+            if(pmethod === 'airwallex') {
+                const airCardNumber = airwallexManager.getCardNumberElement() as T;
                 resultData = {
                     data: res.data, 
                     otherData: airCardNumber
@@ -384,14 +388,16 @@ export default function CheckoutWrapper({
 
             <div className="mt-6 box-border px-4">
                 <h3 className="text-ly-24 font-medium">Payment Method</h3>
+                { paymentMethodLoading && <LoadingPaymentMethod /> }
+                { (!paymentMethodLoading && paymentMethodError) && <p className="mt-3 text-red-500 text-ly-12">{paymentMethodError}</p>}
+                {(!paymentMethodLoading && !paymentMethodError) &&
                 <PaymentMethodCheckout 
                     ref={methodPaymentRef}
                     paymentMethods={paymentMethodDatas} 
                     selectedPaymentMethod={selectedPaymentMethod}
-                    paymentMethodLoading={paymentMethodLoading}
-                    loadError={paymentMethodError}
                     onPaymentMethodChange={handlePaymentMethodChange}
                 />
+                }
                 
                 
             </div>

+ 13 - 37
src/app/(checkout)/checkout/_components/ContinueToPay/PaymentMethodContinueTpPay.tsx

@@ -7,7 +7,10 @@ import { useForm, get, useWatch } from "react-hook-form";
 import { useRouter } from 'next/navigation'
 import { useCustomToast } from "@/utils/hooks/useToast";
 import AirwallexCardInput from "../PaymentMethodAdditional/AirwallexCardInput";
-import {useAirwallexCard} from "@/lib/Airwallex/useAirwallexCard";
+import {
+    airwallexManager,
+    useAirwallexCardState
+} from "@/lib/Airwallex/useAirwallexCard";
 import {RepayOrderPaymentMethodsData} from "@/types/checkout/type";
 import { OrderDetails } from "@/types/customer/type";
 import {FetchGraphqlResult} from "@/types/graphqlFetch/type";
@@ -47,11 +50,8 @@ export function PaymentMethodContinueTpPay({
     const orderCurrency = currencies.find(item => item.code === orderDetail.orderCurrencyCode) || null;
     const orderCurrencyCode = orderCurrency?.code || currentCurrencyCode;
 
-    const {airwallexCardState,getCardElements} = useAirwallexCard(true,{
-        cardNumber: 'airwallex_cardNumber', 
-        cvc: 'airwallex_cvc', 
-        expiry: 'airwallex_expiry'
-    });
+
+    const airwallexCardState = useAirwallexCardState();
     const [isPay, setIsPay] = useState(false);
     const shippingContact = getAddressFromOrderDetailAddressList(orderDetail.addresses,"order_shipping");
     const billingContact = getAddressFromOrderDetailAddressList(orderDetail.addresses,"order_billing");
@@ -135,32 +135,12 @@ export function PaymentMethodContinueTpPay({
 
     const validateAirwallexCard = () => {
 
-        if(!airwallexCardState.cardNumberReady) {
-            showToast('Card number input has not ready. Please wait a minute.', 'danger');
-            return false;
-        }
-        if(!airwallexCardState.expiryReady) {
-            showToast('Card expiry date input has not ready. Please wait a minute.', 'danger');
-            return false;
-        }
-        if(!airwallexCardState.cvcReady) {
-            showToast('Card cvc input has not ready. Please wait a minute.', 'danger');
-            return false;
-        }
-
-        if(!airwallexCardState.cardNumberValid) {
-            showToast('Please input your card number.', 'danger');
-            return false;
-        }
-        if(!airwallexCardState.cvcValid) {
-            showToast('Please input cvc.', 'danger');
-            return false;
-        }
-        if(!airwallexCardState.expiryValid) {
-            showToast('Please input expiry date.', 'danger');
-            return false;
+        const res = airwallexManager.validateCard();
+        if(res.msg) {
+            showToast(res.msg, 'danger');
         }
-        return true;
+        return res.valid;
+       
     };
 
     const cardAirwallexRepay = async () => { 
@@ -176,7 +156,7 @@ export function PaymentMethodContinueTpPay({
             
             if(!res.error && res.data !== null) {
                 // 创建订单成功
-                const cardNumberElement = getCardElements().cardNumber;
+                const cardNumberElement = airwallexManager.getCardNumberElement();
                 const paymentIntentIdClientSecret = res.data.gatewayOrderId.split(':');
                 const paymentIntentId = paymentIntentIdClientSecret[0];
                 const paymentIntentClientSecret = paymentIntentIdClientSecret[1];
@@ -487,11 +467,7 @@ export function PaymentMethodContinueTpPay({
                                     <div className="w-full mt-3"
                                         style={{display: airwallexCardElementShow ? 'block' : 'none'}}
                                     >
-                                        <AirwallexCardInput 
-                                            cardNumberErrorMsg={airwallexCardState.cardNumberErrorMsg}
-                                            expiryErrorMsg={airwallexCardState.expiryErrorMsg}
-                                            cvcErrorMsg={airwallexCardState.cvcErrorMsg}
-                                        />
+                                        <AirwallexCardInput />
                                     </div>
                                 }                          
                             </li>)

+ 24 - 12
src/app/(checkout)/checkout/_components/PaymentMethodAdditional/AirwallexCardInput.tsx

@@ -1,29 +1,41 @@
 "use client";
 
-export default function AirwallexCardInput({ 
-    cardNumberErrorMsg,
-    expiryErrorMsg,
-    cvcErrorMsg,
-} : {
-    cardNumberErrorMsg: string;
-    expiryErrorMsg: string;
-    cvcErrorMsg: string;
-}) {
+import { useId } from "react";
+import {
+    useAirwallexCard,
+    useAirwallexCardState
+} from "@/lib/Airwallex/useAirwallexCard";
+
+export default function AirwallexCardInput() {
+    const id = useId();
+    const {
+        cardNumberErrorMsg,
+        expiryErrorMsg,
+        cvcErrorMsg
+    } = useAirwallexCardState();
+    const cardNumberId = `airwallex_cardNumber_${id}`;
+    const cvcId = `airwallex_cvc_${id}`;
+    const expiryId = `airwallex_expiry_${id}`;
+    useAirwallexCard({
+        cardNumber: cardNumberId, 
+        cvc: cvcId, 
+        expiry: expiryId
+    });
 
     return (
 
         <div id="airwallex-dropIn-card-container" className="w-full">
             <div className="w-full">
-                <div id="airwallex_cardNumber" className="w-full box-border border-1 border-ly-inputborder h-11.5 pl-3 flex items-center"></div>
+                <div id={cardNumberId} className="w-full box-border border-1 border-ly-inputborder h-11.5 pl-3 flex items-center"></div>
                 {cardNumberErrorMsg && <p className="text-ly-12 text-ly-errorcolor">{cardNumberErrorMsg}</p>}
             </div>
             <div className="mt-3 flex justify-between gap-4">
                 <div className="w-full">
-                    <div id="airwallex_expiry" className="w-full box-border border-1 border-ly-inputborder h-11.5 pl-3 flex items-center"></div>
+                    <div id={expiryId} className="w-full box-border border-1 border-ly-inputborder h-11.5 pl-3 flex items-center"></div>
                     {expiryErrorMsg && <p className="text-ly-12 text-ly-errorcolor">{expiryErrorMsg}</p>}
                 </div>
                 <div className="w-full">
-                    <div id="airwallex_cvc" className="w-full box-border border-1 border-ly-inputborder h-11.5 pl-3 flex items-center"></div>
+                    <div id={cvcId} className="w-full box-border border-1 border-ly-inputborder h-11.5 pl-3 flex items-center"></div>
                     {cvcErrorMsg && <p className="text-ly-12 text-ly-errorcolor">{cvcErrorMsg}</p>}
                 </div>
             </div>

+ 6 - 62
src/app/(checkout)/checkout/_components/PaymentMethodCheckout.tsx

@@ -1,15 +1,11 @@
 "use client";
 
-import clsx from "clsx";
 import Image from "next/image";
 import { Ref, useEffect, useImperativeHandle } from "react";
 import { useForm, get } from "react-hook-form";
 import { CheckoutPaymentMethod } from "@/types/checkout/type";
-import { AirwallexCardElements } from "@/lib/Airwallex/airwallexInit";
-import { useCustomToast } from "@/utils/hooks/useToast";
 import AirwallexCardInput from "./PaymentMethodAdditional/AirwallexCardInput";
-import {useAirwallexCard} from "@/lib/Airwallex/useAirwallexCard";
-import LoadingPaymentMethod from "./LoadingPaymentMethod";
+import {useAirwallexCardState} from "@/lib/Airwallex/useAirwallexCard";
 
 /**
  * 关于如何确定默认选中哪个支付方式:
@@ -19,33 +15,21 @@ export interface RefPaymentMethodsHandle {
   getSelectPaymentMethod: () => string;
   getSelectPaymentTitle: () => string;
   validatePaymentMethod: () => Promise<boolean>;
-  getAirwallexCardElements: () => AirwallexCardElements;
-  validateAirwallexCard: () => boolean;
 }
 export function PaymentMethodCheckout({
     ref,
     paymentMethods,
     selectedPaymentMethod,
-    paymentMethodLoading,
-    loadError,
     onPaymentMethodChange,
 }: {
     ref: Ref<RefPaymentMethodsHandle>;
     paymentMethods: CheckoutPaymentMethod[];
     selectedPaymentMethod: string;
-    paymentMethodLoading: boolean;
-    loadError: string | null;
     onPaymentMethodChange: (value: string) => Promise<boolean>;
 }) {
 
-    const { showToast } = useCustomToast();
 
-
-    const {airwallexCardState,getCardElements} = useAirwallexCard(!paymentMethodLoading,{
-        cardNumber: 'airwallex_cardNumber', 
-        cvc: 'airwallex_cvc', 
-        expiry: 'airwallex_expiry'
-    });
+    const airwallexCardState = useAirwallexCardState();
   
     const {
         register,
@@ -100,54 +84,18 @@ export function PaymentMethodCheckout({
             // 触发校验
             validatePaymentMethod: () => {
                 return trigger();
-            },
-            // 获取airwallex card 支付创建的elements
-            getAirwallexCardElements: () => {
-                return getCardElements();
-            },
-            validateAirwallexCard: () => {
-
-                if(!airwallexCardState.cardNumberReady) {
-                    showToast('Card number input has not ready. Please wait a minute.', 'danger');
-                    return false;
-                }
-                if(!airwallexCardState.expiryReady) {
-                    showToast('Card expiry date input has not ready. Please wait a minute.', 'danger');
-                    return false;
-                }
-                if(!airwallexCardState.cvcReady) {
-                    showToast('Card cvc input has not ready. Please wait a minute.', 'danger');
-                    return false;
-                }
-
-                if(!airwallexCardState.cardNumberValid) {
-                    showToast('Please input your card number.', 'danger');
-                    return false;
-                }
-                if(!airwallexCardState.cvcValid) {
-                    showToast('Please input cvc.', 'danger');
-                    return false;
-                }
-                if(!airwallexCardState.expiryValid) {
-                    showToast('Please input expiry date.', 'danger');
-                    return false;
-                }
-                return true;
             }
         }
         
-    },[getCardElements,paymentMethods,airwallexCardState]);
+    },[paymentMethods]);
 
     const airwallexOtherInfoShow = selectedPaymentMethod === 'airwallex' && airwallexCardState.cardNumberReady && airwallexCardState.expiryReady && airwallexCardState.cvcReady;
 
     
     return (<>
 
-        { paymentMethodLoading && <LoadingPaymentMethod /> }
-        { (!paymentMethodLoading && loadError) && <p className="mt-3 text-red-500 text-ly-12">{loadError}</p>}
-        <form name="shipping method" className={clsx("w-full", {
-            "hidden": (paymentMethodLoading || loadError)
-        })}>
+   
+        <form name="shipping method" className="w-full">
         
             <div className="mt-3 w-full">
                 <ul className="w-full">
@@ -183,11 +131,7 @@ export function PaymentMethodCheckout({
                                     <div className="w-full mt-3"
                                         style={{display: airwallexOtherInfoShow ? 'block' : 'none'}}
                                     >
-                                        <AirwallexCardInput 
-                                            cardNumberErrorMsg={airwallexCardState.cardNumberErrorMsg}
-                                            expiryErrorMsg={airwallexCardState.expiryErrorMsg}
-                                            cvcErrorMsg={airwallexCardState.cvcErrorMsg}
-                                        />
+                                        <AirwallexCardInput />
                                     </div>
                                 }
                             </li>

+ 397 - 13
src/lib/Airwallex/airwallexInit.ts

@@ -1,6 +1,8 @@
 import { init,InitResult,createElement, type Payment } from '@airwallex/components-sdk';
 
-// export {Payment};
+// ============================================================
+// Types
+// ============================================================
 export type AirwallexCartNumberElementType = Payment.CardNumberElementType;
 export type AirwallexCvcElementType = Payment.CvcElementType;
 export type AirwallexExpiryDateElementType = Payment.ExpiryDateElementType;
@@ -16,6 +18,43 @@ type ElementType =
   | AirwallexCvcElementType
   | AirwallexExpiryDateElementType;
 
+// ============================================================
+// Snapshot
+// ============================================================
+export interface AirwallexCardSnapshot {
+    cardNumberReady: boolean;
+    cvcReady: boolean;
+    expiryReady: boolean;
+
+    cardNumberValid: boolean;
+    cvcValid: boolean;
+    expiryValid: boolean;
+
+    cardNumberErrorMsg: string;
+    cvcErrorMsg: string;
+    expiryErrorMsg: string;
+}
+
+// ============================================================
+// Initial Snapshot
+// ============================================================
+
+const initialSnapshot: AirwallexCardSnapshot = {
+    cardNumberReady: false,
+    cvcReady: false,
+    expiryReady: false,
+
+    cardNumberValid: false,
+    cvcValid: false,
+    expiryValid: false,
+
+    cardNumberErrorMsg: '',
+    cvcErrorMsg: '',
+    expiryErrorMsg: '',
+};
+// ============================================================
+// Airwallex SDK init
+// ============================================================
 let initPromise: Promise<InitResult> | null = null;
 
 
@@ -42,11 +81,100 @@ export function airwallexInit() {
   return initPromise;
 }
 
-
+// ============================================================
+// Manager
+// ============================================================
+// 创建的cardNumber,cvc,expiry输入框挂载到页面上的某个元素上才能渲染出来
+// 修改购物车数据后,需要重新获取支付方式列表,要想实现销毁-->重建卡支付输入框,必须要在销毁后把挂载的那个元素也删除,否则新创建的输入框上的绑定的事件会重发触发
+// 所以进行此次优化
 class AirwallexManager {
+    // --------------------------------------------------------
+    // Airwallex Elements
+    // --------------------------------------------------------
   private elements: AirwallexCardElements = {};
+
   private createPromise: Promise<AirwallexCardElements> | null = null;
 
+  // 引入使用者计数(现在是全局单例模式,这里的全局指的是以页面的生命周期为准,对于卡支付输入框,全局只有一个地方可以初始化,每次初始化后,全局只有一个实例)
+  // 所以这里引入的refCount是一个过度防御
+  // 当把airwallex的卡支付初始化与卡支付输入框的状态订阅拆分后,这个refCount已不那么需要(可以删除),但还是保留
+  private refCount = 0;
+
+  // --------------------------------------------------------
+  // React external store
+  // --------------------------------------------------------
+  private snapshot: AirwallexCardSnapshot = {
+      ...initialSnapshot,
+  };
+
+  private listeners = new Set<() => void>(); // 集合类型,用来保存监听器
+
+  // 参数listener是react内部的监听函数
+  // 当把subscribe传给useSyncExternalStore后,react在内部调用subscribe时把自己的监听函数传给subscribe
+  subscribe = (listener: () => void) => {
+      // 把react的监听函数保存到集合
+      this.listeners.add(listener);
+      
+      // 取消订阅时(比如组件卸载时),删除保存的监听函数
+      return () => {
+          this.listeners.delete(listener);
+      };
+  };
+
+  // react传给subscribe的监听函数里会调用getSnapshot重新获取数据快照
+  getSnapshot = () => {
+      return this.snapshot;
+  };
+  private emit() {
+
+      this.listeners.forEach((listener) => {
+          listener();
+      });
+  }
+  private updateSnapshot( patch: Partial<AirwallexCardSnapshot> ) {
+      this.snapshot = {
+          ...this.snapshot,
+          ...patch,
+      };
+      // 修改数据后,触发react的监听器,监听器里重现获取数据快照,数据变了,react重新渲染组件
+      this.emit();
+  }
+
+  private resetSnapshot() {
+      this.snapshot = {
+          ...initialSnapshot,
+      };
+
+      this.emit();
+  }
+  // 
+  setRefCount() {
+    this.refCount++;
+    console.log(
+        '[Airwallex] acquire:',
+        this.refCount
+    );
+  }
+  release() {
+    if (this.refCount === 0) {
+        return;
+    }
+
+    this.refCount--;
+
+    console.log(
+        '[Airwallex] release:',
+        this.refCount
+    );
+
+    if (this.refCount === 0) {
+      console.log('_____________________________AirwallexCard destory ___________________________________')
+        this.destroy();
+    }
+
+  }
+
+
   async createElements() {
 
     if (this.elements.cardNumber) {
@@ -58,9 +186,6 @@ class AirwallexManager {
 
       return this.createPromise;
     }
-
-
-
     
     this.createPromise = Promise.all([
       createElement("cardNumber"),
@@ -76,11 +201,15 @@ class AirwallexManager {
 
       return this.elements;
 
-    });
+    }).catch((error) => {
 
+        // 创建失败,允许下一次重新创建
+        this.createPromise = null;
 
-    return this.createPromise;
+        throw error;
+    });
 
+    return this.createPromise;
 
   }
 
@@ -94,18 +223,268 @@ class AirwallexManager {
     this.elements.expiry?.mount(containerIds.expiry);
   }
 
-  unmount() {
-    this.elements.cardNumber?.unmount();
-    this.elements.cvc?.unmount();
-    this.elements.expiry?.unmount();
+  on<EventCode extends Payment.SplitElementEventCode>(
+    el: ElementType, 
+    event: EventCode, 
+    handler: Payment.SplitElementEventHandler<EventCode>
+  ) {
+
+    el.on(event, handler);
   }
 
-  on<EventCode extends Payment.SplitElementEventCode>(el: ElementType, event: EventCode, handler: Payment.SplitElementEventHandler<EventCode>) {
+  elementsBindHandler() {
+    const { cardNumber, cvc, expiry } = this.elements;
+    if(!cardNumber || !cvc || !expiry) {
+      throw new Error('Airwallex elements have not been created!');
+    }
+    cardNumber.on("focus", (e) => {
+      this.handleFocus(e)
+    });
+    cardNumber.on("blur", (e) => {
+      this.handleBlur(e);
+    });
+    cardNumber.on("change", (e) => {
+      this.handleChange(e);
+    });
+    cardNumber.on("ready", (e) => {
+      this.handleReady(e);
+    });
+
+    cvc.on("focus", (e) => {
+      this.handleFocus(e)
+    });
+    cvc.on("blur", (e) => {
+      this.handleBlur(e);
+    });
+    cvc.on("change", (e) => {
+      this.handleChange(e);
+    });
+    cvc.on("ready", (e) => {
+      this.handleReady(e);
+    });
+
+    expiry.on("focus", (e) => {
+      this.handleFocus(e)
+    });
+    expiry.on("blur", (e) => {
+      this.handleBlur(e);
+    });
+    expiry.on("change", (e) => {
+      this.handleChange(e);
+    });
+    expiry.on("ready", (e) => {
+      this.handleReady(e);
+    });
 
-    el.on(event, handler);
   }
 
+  // ========================================================
+  // Airwallex event handlers
+  // ========================================================
+
+    handleReady(event: any) {
+      console.log('-----------------airwallex card ready-----------------');
+        const { type } = event.detail;
+
+        if (type === 'cardNumber') {
+            this.updateSnapshot({
+                cardNumberReady: true,
+            });
+            return;
+        }
+        if (type === 'cvc') {
+            this.updateSnapshot({
+                cvcReady: true,
+            });
+            return;
+        }
+        if (type === 'expiry') {
+            this.updateSnapshot({
+                expiryReady: true,
+            });
+            return;
+        }
+    }
+
+    handleChange(event: any) {
+
+        const {
+            type,
+            complete,
+            error,
+        } = event.detail;
 
+        const message = error?.message ?? '';
+
+        if (type === 'cardNumber') {
+
+            this.updateSnapshot({
+                cardNumberValid: complete,
+                cardNumberErrorMsg: message,
+            });
+
+            return;
+        }
+
+
+        if (type === 'cvc') {
+
+            this.updateSnapshot({
+                cvcValid: complete,
+                cvcErrorMsg: message,
+            });
+
+            return;
+        }
+
+
+        if (type === 'expiry') {
+
+            this.updateSnapshot({
+                expiryValid: complete,
+                expiryErrorMsg: message,
+            });
+
+            return;
+        }
+    }
+
+
+    handleFocus(event: any) {
+
+        const { type } = event.detail;
+
+        if (type === 'cardNumber') {
+
+            this.updateSnapshot({
+                cardNumberErrorMsg: '',
+            });
+
+            return;
+        }
+
+
+        if (type === 'cvc') {
+
+            this.updateSnapshot({
+                cvcErrorMsg: '',
+            });
+
+            return;
+        }
+
+
+        if (type === 'expiry') {
+
+            this.updateSnapshot({
+                expiryErrorMsg: '',
+            });
+
+            return;
+        }
+    }
+
+
+    handleBlur(event: any) {
+
+        const {
+            type,
+            error,
+        } = event.detail;
+
+        const message = error?.message ?? JSON.stringify(error);
+
+
+        if (type === 'cardNumber') {
+
+            this.updateSnapshot({
+                cardNumberErrorMsg: message,
+            });
+
+            return;
+        }
+
+
+        if (type === 'cvc') {
+
+            this.updateSnapshot({
+                cvcErrorMsg: message,
+            });
+
+            return;
+        }
+
+
+        if (type === 'expiry') {
+
+            this.updateSnapshot({
+                expiryErrorMsg: message,
+            });
+
+            return;
+        }
+    }
+
+
+    // ========================================================
+    // Validation
+    // ========================================================
+
+    validateCard(): {valid: boolean; msg: string} {
+
+        const state = this.snapshot;
+
+        if (!state.cardNumberReady) {
+            return {
+              valid: false,
+              msg: 'Card number input has not ready. Please wait a minute.'
+            };
+        }
+
+        if (!state.expiryReady) {
+            return {
+              valid: false,
+              msg: 'Card expiry date input has not ready. Please wait a minute.'
+            };
+        }
+
+        if (!state.cvcReady) {
+            return {
+              valid: false,
+              msg: 'Card cvc input has not ready. Please wait a minute.'
+            };
+        }
+
+
+        if (!state.cardNumberValid) {
+            return {
+              valid: false,
+              msg: state.cardNumberErrorMsg || 'Please input your card number.'
+            };
+        }
+
+        if (!state.expiryValid) {
+            return {
+                valid: false,
+                msg: state.expiryErrorMsg || 'Please input expiry date.'
+            };
+        }
+
+        if (!state.cvcValid) {
+            return {
+                valid: false,
+                msg: state.cvcErrorMsg || 'Please input cvc.'
+            };
+        }
+
+        return {
+          valid: true,
+          msg: ''
+        };
+    }
+
+
+    
 
   destroy() {
     const { cardNumber, cvc, expiry } = this.elements;
@@ -118,12 +497,17 @@ class AirwallexManager {
 
     this.elements = {};
     this.createPromise=null;
+    this.resetSnapshot();
 
   }
 
   getElements() {
     return this.elements;
   }
+
+  getCardNumberElement() {
+      return this.elements.cardNumber;
+  }
 }
 
 export const airwallexManager = new AirwallexManager();

+ 59 - 192
src/lib/Airwallex/useAirwallexCard.ts

@@ -1,28 +1,11 @@
-import { useEffect, useRef, useReducer, useEffectEvent } from "react";
+import { useSyncExternalStore, useEffect } from "react";
 import { 
     airwallexInit,
     airwallexManager,
-    AirwallexCartNumberElementType,
-    AirwallexCvcElementType,
-    AirwallexExpiryDateElementType
+    AirwallexCardSnapshot
 } from "@/lib/Airwallex/airwallexInit";
-import { AirwallexCardStateType } from "@/types/checkout/type";
 
-interface EventCallback {
-  focusCallback: (e: any) => void;
-  blurCallback: (e: any) => void;
-  changeCallback: (e: any) => void;
-  readyCallback: (e: any) => void;
-
-}
-
-function airwallexCardReducer(state: AirwallexCardStateType, action: Partial<AirwallexCardStateType>) { 
-    // 这里不需要action的type属性,因为这里只需要覆盖state
-    const newState = Object.assign({}, {...state}, {...action});
-    return {...newState};
-}
-
-const defaultAirwallexCardState: AirwallexCardStateType = {
+const serverSnapshot: AirwallexCardSnapshot = {
     cardNumberReady: false,
     cvcReady: false,
     expiryReady: false,
@@ -31,188 +14,72 @@ const defaultAirwallexCardState: AirwallexCardStateType = {
     cvcValid: false,
     expiryValid: false,
 
-    cardNumberErrorMsg: "",
-    cvcErrorMsg: "",
-    expiryErrorMsg: "",
+    cardNumberErrorMsg: '',
+    cvcErrorMsg: '',
+    expiryErrorMsg: '',
 };
 
-export function useAirwallexCard(
-  enabled: boolean, 
-  mountId: { cardNumber: string; cvc: string; expiry: string; },
-  // callback: EventCallback
-) {
-  
-    
-    const [airwallexCardState, airwallexCardDispatch] = useReducer(airwallexCardReducer, defaultAirwallexCardState);
-    const readyAirwallexElement = useEffectEvent((event: any): void => {
-        console.log('----------------------------onReady---------------------');
-        const { type } = event.detail;
-        if (type === 'cardNumber') {
-            airwallexCardDispatch({
-                cardNumberReady: true
-            });
-        }
-        if (type === 'cvc') {
-            airwallexCardDispatch({
-                cvcReady: true
-            });
-        }
-        if (type === 'expiry') {
-            airwallexCardDispatch({
-                expiryReady: true
-            });
-        }
-    });
-
-
-    const changeAirwallexElement = useEffectEvent((event: any) => {
-        console.log('----------------------------onChange---------------------');
-        const { type, complete, error } = event.detail;
-        const msg = error?.message ?? '';
-        if (type === 'cardNumber') {
-            airwallexCardDispatch({
-                cardNumberValid: complete,
-                cardNumberErrorMsg: msg
-            });
-        }
-        if (type === 'cvc') {
-            airwallexCardDispatch({
-                cvcValid: complete,
-                cvcErrorMsg: msg
-            });
-        }
-        if (type === 'expiry') {
-            airwallexCardDispatch({
-                expiryValid: complete,
-                expiryErrorMsg: msg
-            });
-        }
-    });
-
-    const focusAirwallexElement = useEffectEvent((event: any) => {
-        console.log('----------------------------onFocus---------------------');
-        const { type } = event.detail;
-        if (type === 'cardNumber') {
-            airwallexCardDispatch({
-                cardNumberErrorMsg: ''
-            });
-        }
-        if (type === 'cvc') {
-            airwallexCardDispatch({
-                cvcErrorMsg: ''
-            });
-        }
-        if (type === 'expiry') {
-            airwallexCardDispatch({
-                expiryErrorMsg: ''
-            });
-        }
-    });
-
-    const blurAirwallexElement = useEffectEvent((event: any) => {
-        console.log('----------------------------onBlur---------------------');
-        const { type, error } = event.detail;
-        const msg = error?.message ?? JSON.stringify(error);
-        if (type === 'cardNumber') {
-            airwallexCardDispatch({
-                cardNumberErrorMsg: msg,
-            });
-        }
-        if (type === 'cvc') {
-            airwallexCardDispatch({
-                cvcErrorMsg: msg,
-            });
-        }
-        if (type === 'expiry') {
-            airwallexCardDispatch({
-                expiryErrorMsg: msg,
-            });
-        }
-    });
+export {airwallexManager};
 
+export function useAirwallexCardState() {
+    // react 订阅 卡支付输入框状态
+    return useSyncExternalStore(
+        airwallexManager.subscribe,
+        airwallexManager.getSnapshot,
+        //第三个参数 用于 SSR 和 hydration,而且服务端和 hydration 的初始 snapshot 必须一致(与airwallexInit.ts中的initialSnapshot一致)
+        () => serverSnapshot
+    );
+}
 
+export function useAirwallexCard(mountId:{
+        cardNumber: string;
+        cvc: string;
+        expiry: string;
+}) {
+
+    // const state = useAirwallexCardState();
+
+
+    useEffect(() => {
+
+        airwallexManager.setRefCount(); // 引用计数
+        let canceled = false;
+        // init 函数里做了两次canceled为true然后return的判断
+        // 原因是这里有两个异步调用,异步调用的事件可能很快,也可能很慢
+        // 那么就会出现第一次effect触发后,异步调用还没有完成,然后因为用户修改购物车,导致第一次effect 的cleanup函数执行,接着触发第二次effect
+        // 那么就需要取消第一次effect中之后的逻辑,以第二次的effect为准。
+        async function init() {
+
+            await airwallexInit();
+            if (canceled) {
+                return;
+            }
+            await airwallexManager.createElements();
+            
+            if (canceled) {
+                return;
+            }
+
+            airwallexManager.mount(mountId);
+            airwallexManager.elementsBindHandler();
+        }
 
 
+        init();
 
 
-  const mountedRef = useRef(false);
-  const elementRef = useRef<{
-    cardNumber?: AirwallexCartNumberElementType;
-    cvc?: AirwallexCvcElementType;
-    expiry?: AirwallexExpiryDateElementType;
-  }>({});
-
-  useEffect(() => {
-    console.log('enabled ------- ',enabled);
-    if (!enabled) return;
-
-    let destroyed = false;
-
-    const init = async () => {
-        await airwallexInit();
-        //   await airwallexManager.init();
-
-        const els = await airwallexManager.createElements();
-        elementRef.current = els;
-            console.log('useAirwallexCard +++++++++++++++++++++++++++++',destroyed);
-        if (destroyed) return;
-        
-
-        airwallexManager.mount({
-            cardNumber: mountId.cardNumber,
-            cvc: mountId.cvc,
-            expiry: mountId.expiry
-        });
-
-        // register events ONLY ONCE
-        bindEvents(els,{
-            focusCallback: focusAirwallexElement,
-            blurCallback: blurAirwallexElement, 
-            changeCallback: changeAirwallexElement, 
-            readyCallback: readyAirwallexElement
-        });
-        mountedRef.current = true;
-
-      
-    };
-
-    init();
-
-    return () => {
-        console.log('useAirwallexCard ___________________________________',mountedRef.current)
-      destroyed = true;
-      if (mountedRef.current) {
-        airwallexManager.destroy();// 解除挂载
-        mountedRef.current = false;
-      }
-    };
-  }, [enabled,mountId.cardNumber,mountId.cvc,mountId.expiry]);
-
-
-
-  return {
-      getCardElements:() => elementRef.current,
-      airwallexCardState: airwallexCardState
-  };
-
-}
+        return () => {
+            
+            canceled = true;
 
-function bindEvents(els: any,callback:EventCallback) {
-  const { cardNumber, cvc, expiry } = els;
-  const {focusCallback,blurCallback, changeCallback, readyCallback} = callback;
+            airwallexManager.release();
+        };
 
-  airwallexManager.on(cardNumber,"focus", focusCallback);
-  airwallexManager.on(cardNumber,"blur", blurCallback);
-  airwallexManager.on(cardNumber,"change", changeCallback);
-  airwallexManager.on(cardNumber,"ready", readyCallback);
 
-  airwallexManager.on(cvc,"focus", focusCallback);
-  airwallexManager.on(cvc,"blur", blurCallback);
-  airwallexManager.on(cvc,"change", changeCallback);
-  airwallexManager.on(cvc,"ready", readyCallback);
+    }, [
+        mountId.cardNumber,
+        mountId.cvc,
+        mountId.expiry,
+    ]);
 
-  airwallexManager.on(expiry,"focus", focusCallback);
-  airwallexManager.on(expiry,"blur", blurCallback);
-  airwallexManager.on(expiry,"change", changeCallback);
-  airwallexManager.on(expiry,"ready", readyCallback);
 }

+ 0 - 13
src/types/checkout/type.ts

@@ -524,16 +524,3 @@ export type PlaceOrderFunction = <T = null>(params?: CreatePaymentInitiateVariab
 
 
 
-export interface AirwallexCardStateType {
-    cardNumberReady: boolean;
-    cvcReady: boolean;
-    expiryReady: boolean;
-
-    cardNumberValid: boolean;
-    cvcValid: boolean;
-    expiryValid: boolean;
-
-    cardNumberErrorMsg: string;
-    cvcErrorMsg: string;
-    expiryErrorMsg: string;
-}