Kaynağa Gözat

接入klarna支付 --- 调试

fogwind 1 gün önce
ebeveyn
işleme
836aa94c75

+ 1 - 0
README.md

@@ -335,6 +335,7 @@ useEffect 只会在客户端执行,具体是在浏览器绘制后执行,服
 42. 错误处理 https://nextjs.org/docs/app/getting-started/error-handling
 43. redux中的user删除
 44. 关于订单id,管理后台订单列表里显示的是increment_id(数据库里的字段名),前端页面上用的是id(数据库里的字段名)
+45. 结账页向server action 迁移
 
 > 39,40 参考 https://chatgpt.com/share/6a4f0637-ad28-83ea-ad44-423740795054
 

+ 214 - 6
src/app/(checkout)/checkout/_components/CheckoutPlaceOrder.tsx

@@ -1,13 +1,24 @@
 "use client";
 
 import { useRouter } from 'next/navigation'
+import { useRef } from 'react';
+import { Payment } from "@airwallex/components-sdk";
 import PaypalButton from "./PaymentButton/PaypalButton";
 import { overlayLoading } from "@/components/theme/ui/kernel/loading/api";
-import { PlaceOrderFunction } from "@/types/checkout/type";
+import { PlaceOrderFunction,KlarnaOtherData } from "@/types/checkout/type";
 import { useCustomToast } from "@/utils/hooks/useToast";
 import { usePlaceOrder } from "@/utils/hooks/usePlaceOrder";
 import { confirmDialog } from "@/components/theme/ui/kernel/confirm/api";
 import { useConfig } from '@utils/hooks/useConfig';
+import KlarnaButton from "./PaymentButton/KlarnaButton";
+import { CartAddress } from "@/types/cart/type";
+
+interface TestKlarnaData {
+    billingAddress: CartAddress | undefined;
+    shippingAddress: CartAddress | undefined;
+    webOrderId: string;
+    verifyToken: string;
+}
 export default function CheckoutPlaceOrder({
     paymentMethod,
     clickPlaceOrder, 
@@ -25,7 +36,15 @@ export default function CheckoutPlaceOrder({
     const paypalCreateOrder = async () => {
         overlayLoading.start();
         const valid = await validateCheckout();
-        if(!valid) {overlayLoading.stop();}
+        if(!valid) {
+            overlayLoading.stop();
+            return {
+                error: true,
+                msg: '',
+                orderId: '',
+                webOrderId: ''
+            };
+        }
         // 校验通过
         const placeOrderRes = await clickPlaceOrder();
         if(placeOrderRes.data !== null) {
@@ -49,7 +68,10 @@ export default function CheckoutPlaceOrder({
     const airwallexPlaceOrder = async () => {
         overlayLoading.start();
         const valid = await validateCheckout();
-        if(!valid) {overlayLoading.stop();}
+        if(!valid) {
+            overlayLoading.stop();
+            return;
+        }
 
         const placeOrderRes = await clickPlaceOrder({
             paymentSuccessUrl: window.location.origin + '/paymentresult/result'
@@ -63,9 +85,12 @@ export default function CheckoutPlaceOrder({
     const cardAirwallexPlaceOrder = async () => {
         overlayLoading.start();
         const valid = await validateCheckout();
-        if(!valid) {overlayLoading.stop();}
+        if(!valid) {
+            overlayLoading.stop();
+            return;
+        }
 
-        const placeOrderRes = await clickPlaceOrder();
+        const placeOrderRes = await clickPlaceOrder<Payment.CardNumberElementType>();
         if(placeOrderRes.data !== null && placeOrderRes.otherData !== null) {
             const webOrderId = placeOrderRes.data.orderId;
             const cardNumberElement = placeOrderRes.otherData;
@@ -94,7 +119,10 @@ export default function CheckoutPlaceOrder({
         if(window.AfterPay) {
             overlayLoading.start();
             const valid = await validateCheckout();
-            if(!valid) {overlayLoading.stop();} 
+            if(!valid) {
+                overlayLoading.stop();
+                return;
+            } 
 
             const placeOrderRes = await clickPlaceOrder({
                 paymentSuccessUrl: window.location.origin + '/paymentresult/result',
@@ -166,6 +194,178 @@ export default function CheckoutPlaceOrder({
         }
     };
 
+    const klarnaTestRef= useRef<TestKlarnaData| null>(null);
+    const klarnaPlaceOrder = async () => { 
+        overlayLoading.start();
+        const valid = await validateCheckout();
+        if(!valid) {
+            overlayLoading.stop();
+            return;
+        }
+
+        const placeOrderRes = await clickPlaceOrder<KlarnaOtherData>({
+            paymentSuccessUrl: window.location.origin + '/paymentresult/result',
+            paymentCancelUrl: window.location.origin + '/paymentresult/result'
+        });
+        
+        if(placeOrderRes.data !== null && placeOrderRes.otherData) {
+            const klarnaToken = placeOrderRes.data.gatewayOrderId;
+            const webOrderId = placeOrderRes.data.orderId;
+            const {billingAddress, shippingAddress} = placeOrderRes.otherData;
+
+            const klarnaClientToken = klarnaToken.split("|")[0];
+            const klarnaVerifyToken = klarnaToken.split("|")[1];
+
+            klarnaTestRef.current = {
+                billingAddress: billingAddress,
+                shippingAddress: shippingAddress,
+                webOrderId: webOrderId,
+                verifyToken: klarnaVerifyToken
+            };
+            console.log('klarna otherData ---- ', placeOrderRes.otherData);
+            window.Klarna.Payments.init({
+                client_token: klarnaClientToken,
+            });
+            //klarna-widget-container
+            window.Klarna.Payments.load({
+                container: '#klarna-widget-container',
+            }, (response) => {
+                console.log('Widget loaded:', response.show_form ? 'Success' : 'Warning');
+                if(!response.show_form) { // klarna不可用
+                    // 跳转结果页
+                    router.replace('/paymentresult/result?orderId=' + webOrderId + '&return_result=failure');
+                } else {
+
+                    
+                    window.Klarna.Payments.authorize(
+                        {},
+                        {
+                            // billing_address: {
+                            //     given_name: billingAddress.firstName,
+                            //     family_name: billingAddress.lastName,
+                            //     email: billingAddress.email,
+                            //     phone: billingAddress.phone,
+                            //     street_address: billingAddress.address,
+                            //     postal_code: billingAddress.postcode,
+                            //     city: billingAddress.city,
+                            //     country: billingAddress.country
+                            // },
+                            shipping_address: {
+                                given_name: shippingAddress.firstName,
+                                family_name: shippingAddress.lastName,
+                                email: shippingAddress.email,
+                                street_address: shippingAddress.address,
+                                postal_code: shippingAddress.postcode,
+                                city: shippingAddress.city,
+                                phone: shippingAddress.phone,
+                                region: shippingAddress.state,
+                                country: shippingAddress.country
+                            },
+                            // customer: {
+                            //     date_of_birth: "1941-03-21",
+                            // },
+                        },
+                        async function(res) {
+                            console.debug(res);
+                            if(res.approved && res.authorization_token) { // 可以支付
+
+                                const resCallback = await createPaymentCallback({
+                                    orderId: Number(webOrderId),
+                                    gatewayOrderId: res.authorization_token,
+                                    verifyToken: klarnaVerifyToken,
+                                    status: "success",
+                                });
+                                // 跳转结果页
+                                overlayLoading.stop();
+                                if(!resCallback.error) {
+                                    router.replace('/paymentresult/result?orderId=' + webOrderId + '&return_result=success');
+                                } else {
+                                    // callback 失败,提示错误
+                                    confirmDialog({
+                                        title: "Somthing Wrong",
+                                        content: resCallback.msg + " Please contact customer service." + " OrderId: "+ webOrderId,
+                                        noCancel: true,
+                                    }).then(() => { 
+                                        router.replace('/');
+                                    });
+                                    
+                                }
+
+                            } else {
+                                // if(res.show_form) { // klarna可用,但是有报错
+                                // } else {
+                                //     // klarna 不可用 跳转结果页 
+                                // }
+                                overlayLoading.stop();
+                                router.replace('/paymentresult/result?orderId=' + webOrderId + '&return_result=failure');
+                            }
+                        }
+                    );
+                    
+
+                }
+            });
+            
+        }
+        
+    };
+
+    const testKlarna = () => {
+        if(klarnaTestRef.current === null) return;  
+        const billingAddress = klarnaTestRef.current.billingAddress;
+        const shippingAddress = klarnaTestRef.current.shippingAddress;
+        const webOrderId = klarnaTestRef.current.webOrderId;
+        const verifyToken = klarnaTestRef.current.verifyToken;
+        window.Klarna.Payments.authorize(
+            {},
+            {
+                billing_address: {
+                    given_name: billingAddress?.firstName,
+                    family_name: billingAddress?.lastName,
+                    email: billingAddress?.email,
+                    phone: billingAddress?.phone,
+                    street_address: billingAddress?.address,
+                    postal_code: billingAddress?.postcode,
+                    city: billingAddress?.city,
+                    country: billingAddress?.country
+                },
+                shipping_address: {
+                    given_name: shippingAddress?.firstName,
+                    family_name: shippingAddress?.lastName,
+                    email: shippingAddress?.email,
+                    street_address: shippingAddress?.address,
+                    postal_code: shippingAddress?.postcode,
+                    city: shippingAddress?.city,
+                    phone: shippingAddress?.phone,
+                    country: shippingAddress?.country
+                },
+                // customer: {
+                //     date_of_birth: "1941-03-21",
+                // },
+            },
+            async function(res) {
+                console.debug(res);
+                if(res.approved && res.authorization_token) { // 可以支付
+
+                    const resCallback = await createPaymentCallback({
+                        orderId: Number(webOrderId),
+                        gatewayOrderId: res.authorization_token,
+                        verifyToken: verifyToken,
+                        status: "success",
+                    });
+                    // @todu 跳转结果页
+
+                } else {
+                    // if(res.show_form) { // klarna可用,但是有报错
+                    // } else {
+                    //     // klarna 不可用 跳转结果页 
+                    // }
+                    router.replace('/paymentresult/result?orderId=' + webOrderId + '&return_result=failure');
+                }
+            }
+        );
+    }
+
     return (
         <>
             <div className="relative w-full">
@@ -195,6 +395,14 @@ export default function CheckoutPlaceOrder({
                         Place Order
                     </button>
                 }
+                {paymentMethod === 'klarna' &&
+                <>
+                    <button className="flex items-center justify-center w-25 h-12 bg-ly-green text-white text-ly-12 font-bold"
+                        onClick={testKlarna}
+                    >Test</button>
+                    <KlarnaButton clickHandler={klarnaPlaceOrder} />
+                    </>
+                }
                 
 
             </div>

+ 41 - 19
src/app/(checkout)/checkout/_components/CheckoutWrapper.tsx

@@ -9,9 +9,9 @@ import {
     ShipAddressFormData,
     FullAddressFormData,
     CreateCheckoutAddressVariables,
-    ExtraMapPlaceOrderResult,
     PlaceOrderResult,
-    PlaceOrderFunction
+    PlaceOrderFunction,
+    CreatePaymentInitiateVariables
 } from "@/types/checkout/type";
 import AddressResultDisplay from "./AddressResultDisplay";
 import ShippingAddressCheckout from "./ShippingAddressCheckout";
@@ -38,6 +38,7 @@ import { usePlaceOrder } from "@/utils/hooks/usePlaceOrder";
 import { clearCart } from "@/store/slices/cart-slice";
 import {loadAfterpayScript} from "@/lib/Afterpay/afterpay";
 import PaypalApplepayButton from "./PaymentButton/PaypalApplepayButton";
+import {usePaymentSDKContext} from "@/providers/PaymentSDKProvider";
 /***
  * 不用useForShipping字段了
  * 以shipping address 为准,根据shippingaddress 设置billing address
@@ -221,6 +222,9 @@ export default function CheckoutWrapper({
     loginEmail: string;
     cartDetailData: CartDetail;
 }) {
+
+    const {loadSdk} = usePaymentSDKContext();
+
     const { createOrder } = usePlaceOrder(); 
     const addressFormDefaultValues = getAddressFormDataFromCart(cartDetailData,loginEmail);
     const dispatch = useAppDispatch();
@@ -266,8 +270,19 @@ export default function CheckoutWrapper({
 
     const [addressSaving, setAddressSaving] = useState(false); // 保存地址时的loading状态
 
+    const changePaymentMethodToloadSdk = (method:string) => {
+        if(method === 'klarna' || 
+            method === 'afterpay' || 
+            method === 'clearpay' || 
+            method === 'applepay'
+        ) {
+            loadSdk(method);
+        }
+    };
 
-
+    useEffect(() => {
+        changePaymentMethodToloadSdk(selectedPaymentMethod);
+    }, [selectedPaymentMethod]);
 
     const openAddressModal = () => {
         setAddressModalOpen(true);
@@ -338,6 +353,7 @@ export default function CheckoutWrapper({
 
 
     const handlePaymentMethodChange =  async (method: string) => { 
+        changePaymentMethodToloadSdk(method);
         let shippingMethod = '-1';
         if(methodShiippingRef.current) {
             shippingMethod = methodShiippingRef.current.getSelectShipMethod();
@@ -461,7 +477,7 @@ export default function CheckoutWrapper({
         } 
     };
     // 下单
-    const handlePlaceOrder: PlaceOrderFunction = async (params) => { 
+    const handlePlaceOrder: PlaceOrderFunction = async <T,>(params?: CreatePaymentInitiateVariables): Promise<PlaceOrderResult<T>>  => { 
         // 校验地址
         // overlayLoading.start();
         // const validRes = await validateCheckout();
@@ -479,29 +495,35 @@ export default function CheckoutWrapper({
                 dispatch(clearCart());  
             }
             const pmethod = methodPaymentRef.current?.getSelectPaymentMethod();
-            const resultData:PlaceOrderResult<keyof ExtraMapPlaceOrderResult> = {
-                // valid: true, 
-                data: res.data,
-                otherData: null
-            }
+            let resultData: PlaceOrderResult<T>;
             if(pmethod === 'airwallex' && methodPaymentRef.current) {
                 const airwallexCardElements = methodPaymentRef.current.getAirwallexCardElements();
-                resultData.otherData = airwallexCardElements.cardNumber as Payment.CardNumberElementType;
+                const airCardNumber = airwallexCardElements.cardNumber as T;
+                resultData = {
+                    data: res.data, 
+                    otherData: airCardNumber
+                };
+            } else if(pmethod === 'klarna') {
+                const klaData = {
+                    billingAddress: cartData.billingAddress,
+                    shippingAddress: cartData.shippingAddress
+                } as T;
+                resultData = {
+                    data: res.data, 
+                    otherData: klaData
+                };
+            } else {
+                resultData = {
+                    data: res.data, 
+                    otherData: null
+                };
             }
             return resultData;
         // } else {
         //     overlayLoading.stop();
         //     return {valid: false, data: null, otherData: null};
         // }
-    };                    
-    
-    useEffect(() => {
-        const hasAfterpay = paymentMethodDatas.some(x=>x.method==="afterpay");
-        const hasClearpay = paymentMethodDatas.some(x=>x.method==="clearpay");
-        if(hasAfterpay || hasClearpay){
-            loadAfterpayScript();
-        }
-    }, [paymentMethodDatas]);
+    }
 
 
     // function tt() {

Dosya farkı çok büyük olduğundan ihmal edildi
+ 47 - 0
src/app/(checkout)/checkout/_components/PaymentButton/KlarnaButton.tsx


+ 8 - 5
src/app/(checkout)/layout.tsx

@@ -1,5 +1,6 @@
 import TitleHeader from "@components/layout/navbar/TitleHeader";
 import { ReactNode } from "react";
+import { PaymentSDKProvider } from "@/providers/PaymentSDKProvider";
 export default async function RootLayout({
   children,
 }: {
@@ -7,12 +8,14 @@ export default async function RootLayout({
 }) {
   return (
     <>
+      <PaymentSDKProvider>
+        <TitleHeader title="Checkout" />
 
-      <TitleHeader title="Checkout" />
-
-      <div className="mx-auto w-full">
-        {children}
-      </div>
+        <div className="mx-auto w-full">
+          {children}
+        </div>
+      </PaymentSDKProvider>
+      
     </>
   );
 }

+ 39 - 0
src/app/api/shop/klarna/createOrder/route.ts

@@ -0,0 +1,39 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+    createOrder
+} from "../tools";
+
+export async function POST(req: NextRequest) {
+
+
+   try {
+      const params = await req.json();
+      const result = await createOrder(params);
+      if(result.error) {
+          return NextResponse.json(
+            {
+              message: result.message,
+              error: result.error,
+            },
+            { status: 500 }
+          );
+      }
+      return NextResponse.json({
+            status: 200,
+            data: result,
+        });
+    } catch (error: unknown) {
+      const safeError =
+          error instanceof Error
+            ? { name: error.name, message: error.message }
+            : { message: String(error) };
+
+        return NextResponse.json(
+          {
+            message: safeError.message ?? "Network error",
+            error: safeError,
+          },
+          { status: 500 }
+        );
+    }
+}

+ 39 - 0
src/app/api/shop/klarna/getClientToken/route.ts

@@ -0,0 +1,39 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+    createSession
+} from "../tools";
+
+export async function POST(req: NextRequest) {
+
+      try {
+        const params = await req.json();
+        const result = await createSession(params);
+        if (result.error) {
+            return NextResponse.json(
+                {
+                    message: result.message,
+                    error: result.error,
+                },
+                { status: 400 }
+            );
+        }
+        return NextResponse.json({
+            status: 200,
+            data: result,
+        });
+      } catch (error:unknown) {
+
+        const safeError =
+          error instanceof Error
+            ? { name: error.name, message: error.message }
+            : { message: String(error) };
+
+        return NextResponse.json(
+          {
+            message: safeError.message ?? "Network error",
+            error: safeError,
+          },
+          { status: 500 }
+        );
+      }
+}

+ 184 - 0
src/app/api/shop/klarna/tools.ts

@@ -0,0 +1,184 @@
+
+export const KLARNA_API_URL = 'https://api-na.playground.klarna.com';
+export const KLARNA_USERNAME = '3ba92f9d-a5d1-4644-a77e-070f07991636';
+export const KLARNA_PASSWORD = 'klarna_test_api_N3pPI3kwbilKKHIxekJEPzMhelokY1MlNkhqVlN1cGQsM2JhOTJmOWQtYTVkMS00NjQ0LWE3N2UtMDcwZjA3OTkxNjM2LDEsS1pEMEh1THRodVNPOGxKTUtPeWhISElnVW56bDlXMnE4bURjVWRmSlV5bz0';
+
+
+
+
+// --- Helpers ---
+
+export function getBasicAuthHeader() {
+  const token = Buffer.from(`${KLARNA_USERNAME}:${KLARNA_PASSWORD}`).toString('base64');
+  return `Basic ${token}`;
+}
+
+export async function fetchWithTimeout(url: string, opts = {}, timeoutMs = 120000) {
+  const controller = new AbortController();
+  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+  try {
+    return await fetch(url, { signal: controller.signal, ...opts });
+  } finally {
+    clearTimeout(timeoutId);
+  }
+}
+
+export function validateCredentials() {
+  if (!KLARNA_USERNAME || !KLARNA_PASSWORD) {
+    throw new Error('Klarna credentials not configured — set KLARNA_API_USERNAME and KLARNA_API_PASSWORD in .env');
+  }
+}
+
+export async function safeParseJSON(response: Response) {
+  const text = await response.text();
+  try {
+    return JSON.parse(text);
+  } catch {
+    return { raw_response: text };
+  }
+}
+
+export async function makeKlarnaRequest(
+    method: "GET" | "POST", 
+    endpoint: string, 
+    payload: any = null, 
+    retries:number = 2
+) {
+  validateCredentials();
+  const url = `${KLARNA_API_URL}${endpoint}`;
+
+  for (let attempt = 0; attempt <= retries; attempt++) {
+    try {
+      const response = await fetchWithTimeout(url, {
+        method,
+        headers: {
+          'Content-Type': 'application/json',
+          Authorization: getBasicAuthHeader()
+        },
+        body: payload ? JSON.stringify(payload) : undefined
+      });
+
+      if (!response.ok) {
+        const data = await safeParseJSON(response);
+        throw new Error(data.error_message || data.raw_response || JSON.stringify(data));
+      }
+
+      if (response.status === 204 || (response.status === 201 && !response.headers.get('content-type')?.includes('json'))) {
+        if (endpoint.includes('/cancel')) {
+          return { success: true, message: 'Order cancelled successfully' };
+        }
+        if (endpoint.includes('/captures')) {
+          return { success: true, message: 'Amount captured successfully', captureId: response.headers.get('capture-id') };
+        }
+        if (endpoint.includes('/refunds')) {
+          return { success: true, message: 'Refund processed successfully', refundId: response.headers.get('refund-id') };
+        }
+        return { success: true, message: 'Operation completed successfully' };
+      }
+
+      return await safeParseJSON(response);
+    } catch (err:any) {
+      const isRetryable = err.name === 'AbortError' || err.message === 'fetch failed';
+      if (isRetryable && attempt < retries) {
+        const delay = Math.pow(2, attempt) * 1000;
+        console.warn(`Klarna request failed (attempt ${attempt + 1}/${retries + 1}), retrying in ${delay}ms:`, err.message);
+        await new Promise(resolve => setTimeout(resolve, delay));
+        continue;
+      }
+      if (err.cause) {
+        console.error('Klarna request error:', err.message, '| cause:', err.cause.message || err.cause);
+      }
+      throw err;
+    }
+  }
+}
+
+// --- Validation ---
+
+const ALLOWED_COUNTRIES = ['US', 'SE', 'DE', 'GB', 'AU', 'AT', 'BE', 'DK', 'FI', 'NL', 'NO'];
+const ALLOWED_CURRENCIES = ['USD', 'SEK', 'EUR', 'GBP', 'AUD', 'DKK', 'NOK'];
+
+export function validateSessionPayload(body: any) {
+  const errors = [];
+  if (!body) return ['Request body is required'];
+
+  const { purchase_country, purchase_currency, order_amount, order_lines } = body;
+
+  if (purchase_country && !ALLOWED_COUNTRIES.includes(purchase_country)) {
+    errors.push(`Invalid purchase_country: ${purchase_country}. Allowed: ${ALLOWED_COUNTRIES.join(', ')}`);
+  }
+  if (purchase_currency && !ALLOWED_CURRENCIES.includes(purchase_currency)) {
+    errors.push(`Invalid purchase_currency: ${purchase_currency}. Allowed: ${ALLOWED_CURRENCIES.join(', ')}`);
+  }
+  if (order_amount !== undefined && (!Number.isInteger(order_amount) || order_amount <= 0)) {
+    errors.push('order_amount must be a positive integer (in minor units, e.g. 10000 = $100.00)');
+  }
+  if (order_lines !== undefined && !Array.isArray(order_lines)) {
+    errors.push('order_lines must be an array');
+  }
+  return errors;
+}
+
+// export function validateOrderId(orderId) {
+//   return /^[a-f0-9-]{36}$/i.test(orderId);
+// }
+
+// --- Routes ---
+export async function createSession(params:any) {
+    const errors = validateSessionPayload(params);
+    if (errors.length > 0) {
+        return {
+            message: 'Validation failed',
+            error: errors,
+        }
+    }
+      
+    const {
+      purchase_country,
+      purchase_currency,
+      locale,
+      order_amount,
+      order_lines,
+      shipping_address,
+      billing_address
+    } = params;
+      
+    const payload = {
+      purchase_country,
+      purchase_currency,
+      locale,
+      order_amount,
+      order_tax_amount: 0,
+      order_lines: order_lines ,
+      shipping_address,
+      billing_address
+    };
+
+    const data = await makeKlarnaRequest('POST', '/payments/v1/sessions', payload);
+    return data;
+} 
+
+export async function createOrder(params:any) {
+        const { authorization_token, payload } = params;
+    
+        if (!authorization_token) {
+          return {
+              message: 'authorization_token is required',
+              error: new Error('authorization_token is required'),
+          }
+              
+        }
+        if (typeof authorization_token !== 'string' || authorization_token.length < 10) {
+          return {
+              message: 'Invalid authorization_token format',
+              error: new Error('Invalid authorization_token format'),
+          }
+        }
+    
+        const data = await makeKlarnaRequest(
+          'POST',
+          `/payments/v1/authorizations/${encodeURIComponent(authorization_token)}/order`,
+          payload
+        );
+        return data;
+}

+ 1 - 1
src/app/layout.tsx

@@ -121,7 +121,7 @@ export default async function RootLayout({
         outfit.variable
       )}>
         {/* <Script src="https://applepay.cdn-apple.com/jsapi/v1/apple-pay-sdk.js" /> */}
-        <Script src="https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js" strategy="beforeInteractive" />
+        {/* <Script src="https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js" strategy="beforeInteractive" /> */}
         
         <main>
           <ErrorBoundary>

+ 178 - 1
src/components/home/HomeImageBanner.tsx

@@ -3,7 +3,7 @@
 import { Swiper, SwiperSlide } from 'swiper/react';
 import 'swiper/css';
 import {CurrencySwitch} from "@/components/common/CurrencySwitch/CurrencySwitch";
-
+import {PaymentSDKProvider,usePaymentSDKContext} from "@/providers/PaymentSDKProvider";
 // interface ImageCarouselProps {
 //     options: {
 //         images: {
@@ -28,6 +28,183 @@ export default function HomeImageBanner() {
       <SwiperSlide>Slide 4</SwiperSlide>
     </Swiper>
     <CurrencySwitch/>
+    <PaymentSDKProvider>
+
+      <Ttest/>
+    </PaymentSDKProvider>
+    <KlarnaTest/>
     </>
   )
+}
+
+
+function Ttest() {
+    const {loadSdk,getSDKState} = usePaymentSDKContext();
+  return (
+    <>
+        <button onClick={() => loadSdk("klarna")}>Klarna</button>
+        <button onClick={() => loadSdk("applepay")}>Applepay</button>
+        <button onClick={() => loadSdk("afterpay")}>Afterpay</button>
+        <p className='text-red-500'>Klarna: {getSDKState('klarna')}</p>
+        <p className='text-red-500'>Applepay: {getSDKState('applepay')}</p>
+        <p className='text-red-500'>Afterpay: {getSDKState('afterpay')}</p>
+    </>
+    
+  );
+}
+
+const billingAddress = {
+  firstName:"Taylor",
+  lastName:"Parker",
+  email:"taylor.parker69@hotmail.com",
+  address:"16840 MCKEEVER STREET",
+  city:"Granada Hills",
+  state:"CA",
+  country:"US",
+  postcode:"91344",
+  phone:"+1 5309964115",
+};
+const shippingAddress = {
+  firstName:"Taylor",
+  lastName:"Parker",
+  email:"taylor.parker69@hotmail.com",
+  address:"16840 MCKEEVER STREET",
+  city:"Granada Hills",
+  state:"CA",
+  country:"US",
+  postcode:"91344",
+  phone:"+1 5309964115",
+};
+
+const orderData = {
+  "purchase_country": "US",
+  "purchase_currency": "USD",
+  "locale": "en-US",
+  "order_amount": 10000,
+  "order_lines": [
+        {
+            "reference": "19-402",
+            "name": "Battery Power Pack",
+            "quantity": 1,
+            "unit_price": 10000,
+            "total_amount": 10000
+        }
+  ],
+  "billing_address": {
+    "city": billingAddress.city,
+    "country": billingAddress.country,
+    "email": billingAddress.email,
+    "family_name": billingAddress.lastName,
+    "given_name": billingAddress.firstName,
+    "phone": billingAddress.phone,
+    "postal_code": billingAddress.postcode,
+    "street_address": billingAddress.address,
+  },
+  "shipping_address": {
+    "city": shippingAddress.city,
+    "country": shippingAddress.country,
+    "email": shippingAddress.email,
+    "family_name": shippingAddress.lastName,
+    "given_name": shippingAddress.firstName,
+    "phone": shippingAddress.phone,
+    "postal_code": shippingAddress.postcode,
+    "street_address": shippingAddress.address,
+  },
+};
+function KlarnaTest() {
+
+  const getClientToken = async () => {
+    const response = await fetch('/api/shop/klarna/getClientToken', {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+      },
+      body: JSON.stringify(orderData),
+    });
+
+    const {data} = await response.json();
+    // return data.clientToken;
+    console.log('getClientToken data --- ', data);
+
+    window.Klarna.Payments.init({
+        client_token: data.client_token,
+    });
+    //klarna-widget-container
+    window.Klarna.Payments.load({
+        container: '#klarna-widget-test',
+    }, (response) => {
+        console.log('Widget loaded:', response.show_form);
+        authorize();
+    });
+  };
+
+  const authorize = () => {
+    const payload = orderData;
+    window.Klarna.Payments.authorize({},{
+       billing_address: {
+          given_name: billingAddress.firstName,
+          family_name: billingAddress.lastName,
+          email: billingAddress.email,
+          phone: billingAddress.phone,
+          street_address: billingAddress.address,
+          postal_code: billingAddress.postcode,
+          city: billingAddress.city,
+          country: billingAddress.country
+      },
+      shipping_address: {
+          given_name: shippingAddress.firstName,
+          family_name: shippingAddress.lastName,
+          email: shippingAddress.email,
+          street_address: shippingAddress.address,
+          postal_code: shippingAddress.postcode,
+          city: shippingAddress.city,
+          phone: shippingAddress.phone,
+          country: shippingAddress.country
+      },
+    }, async (response) => {
+      try {
+        console.log('authorize response ---- ',response);
+
+        if (!response?.authorization_token) {
+          throw new Error('No authorization token received');
+        }
+
+        const orderData = await fetch('/api/shop/klarna/createOrder', {
+          method: 'POST',
+          headers: {
+            'Content-Type': 'application/json',
+          },
+          body: JSON.stringify({ ...response, payload }),
+        });
+        console.log('create_order response ---- ',orderData);
+      } catch (err) {
+         console.log('authorize err ---- ',err);
+      }
+    });
+  }
+
+  return (
+    <div>
+      <div id="klarna-widget-test"></div>
+      <button onClick={() => getClientToken()}>get client token</button>
+
+      <div>
+        <pre id="response" style={{
+          backgroundColor: "#fff",
+          border: "1px solid #e0e0e",
+          borderRadius: "8px",
+          padding: "16px",
+          margin: "24px 0",
+          maxHeight: "500px",
+          overflowY: "auto",
+          fontFamily: "'Monaco', 'Menlo', 'Ubuntu Mono', monospace",
+          fontSize: "12px",
+          color: "#333",
+          lineHeight: "1.5",
+          whiteSpace: "pre-wrap",
+          wordWrap: "break-word"
+        }}></pre>
+      </div>
+    </div>
+  );
 }

+ 2 - 0
src/graphql/checkout/mutations/CreatePaymentCallback.ts

@@ -7,6 +7,7 @@ export const CREATE_PAYMENT_CALLBACK: TypedDocumentNode<CreatePaymentCallbackDat
       $gatewayOrderId: String
       $status: String
       $paymentSignature: String
+      $verifyToken: String
       
       $shippingFirstName: String
       $shippingLastName: String
@@ -34,6 +35,7 @@ export const CREATE_PAYMENT_CALLBACK: TypedDocumentNode<CreatePaymentCallbackDat
           gatewayOrderId: $gatewayOrderId
           status: $status
           paymentSignature: $paymentSignature
+          verifyToken: $verifyToken
           
           shippingFirstName: $shippingFirstName
           shippingLastName: $shippingLastName

+ 151 - 0
src/providers/PaymentSDKProvider.tsx

@@ -0,0 +1,151 @@
+"use client";
+
+import Script from "next/script";
+import {
+  createContext,
+  useContext,
+  useState
+} from "react";
+
+type SDKLoadState = "none" | "loading" | "error" | "success";
+type SDKForPaymentMethod = "klarna" | "afterpay" | "clearpay" | "applepay";
+type SDKName = "klarna" | "afterpay" | "applepay";
+interface PaymentContextProps {
+  loadSdk: (method:string) => void;
+  getSDKState: (method:SDKForPaymentMethod) => SDKLoadState | undefined;
+}
+interface PaymentSDKState {
+  klarna: SDKLoadState;
+  afterpay: SDKLoadState;
+  applepay: SDKLoadState;
+}
+
+const klarnaSDKURL = "https://x.klarnacdn.net/kp/lib/v1/api.js";
+const afterpaySDKURL = process.env.NODE_ENV === "production" ? "https://portal.afterpay.com/afterpay.js" : "https://portal.sandbox.afterpay.com/afterpay.js";
+const applepaySDKURL = "https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js";
+
+const PaymentSDKContext = createContext<PaymentContextProps|null>(null);
+
+const normalizeSDKName = (method:string):SDKName| undefined =>{
+
+    switch(method){
+
+        case "clearpay":
+        case "afterpay":
+            return "afterpay";
+
+        case "klarna":
+            return "klarna";
+
+        case "applepay":
+            return "applepay";
+        default: 
+            return undefined;
+    }
+
+}
+export function PaymentSDKProvider({
+    children
+}: {
+    children:React.ReactNode;
+}){
+
+    const [loadSDKState, setLoadSDKState] = useState<PaymentSDKState>({
+        klarna: 'none', // none loading error success
+        afterpay: 'none', // none loading error success
+        applepay: 'none' // none loading error success
+    });
+
+    const loadSdk = (method:string) => {
+        const m = normalizeSDKName(method);
+        if(m) {
+            setLoadSDKState((prev) => {
+
+                if(prev[m] !== "none") {
+                    return prev;
+                }
+
+                return {
+                    ...prev,
+                    [m]:"loading"
+                };
+            });
+        }
+        
+    }
+    const onSdkLoad = (m:SDKName) => {
+        setLoadSDKState((prev) => {
+            return {
+                ...prev,
+                [m]: 'success'
+            }
+        });
+    };
+    const onSdkError = (m:SDKName) => {
+        setLoadSDKState((prev) => {
+            return {
+                ...prev,
+                [m]: 'error'
+            }
+        });
+    };
+    const getSDKState = (method:SDKForPaymentMethod) => {
+        const m = normalizeSDKName(method);
+        if(m) {
+            return loadSDKState[m];
+        } else {
+            return undefined;
+        }
+        
+    }
+
+    return (
+        <PaymentSDKContext.Provider
+            value={{
+                loadSdk,
+                getSDKState
+            }}
+        >
+            {loadSDKState.klarna !== "none" &&
+                <Script id="klarna-sdk" strategy="afterInteractive"
+                    src={klarnaSDKURL}
+                    onLoad={() => onSdkLoad('klarna')}
+                    onError={() => {
+                        onSdkError('klarna');
+                    }}
+                />
+            }
+            { loadSDKState.afterpay !== "none" &&
+                <Script id="afterpay-sdk" strategy="afterInteractive"
+                    src={afterpaySDKURL}
+                    onLoad={() => onSdkLoad('afterpay')}
+                    onError={() => {
+                        onSdkError('afterpay');
+                    }}
+                />
+            }
+            { loadSDKState.applepay !== "none" &&
+                <Script id="applepay-sdk" strategy="afterInteractive"
+                    src={applepaySDKURL}
+                    onLoad={() => onSdkLoad('applepay')}
+                    onError={() => {
+                        onSdkError('applepay');
+                    }}
+                />
+            }
+
+            {children}
+
+        </PaymentSDKContext.Provider>
+    )
+
+}
+
+export const usePaymentSDKContext = () => {
+    const context = useContext(PaymentSDKContext);
+    if (!context) {
+        throw new Error("usePaymentSDKContext must be used within a PaymentSDKProvider");
+    }
+
+    return context;
+};

+ 10 - 9
src/types/checkout/type.ts

@@ -1,4 +1,4 @@
-import {CartDetail} from "@/types/cart/type";
+import {CartDetail,CartAddress} from "@/types/cart/type";
 import { Payment } from '@airwallex/components-sdk';
 
 export type AddressType = "cart_billing" | "cart_shipping";
@@ -266,6 +266,7 @@ export interface CreatePaymentCallbackVariables {
   gatewayOrderId: string;
   status: "success" | "cancel" | "failure";
   paymentSignature?: string;
+  verifyToken?: string;
   
   // 快捷支付需要
   shippingFirstName?: string;
@@ -472,19 +473,19 @@ export interface CreatePaymentReplayVariables {
 
 
 // 结账页额外用到的类型
-export type ExtraMapPlaceOrderResult = {
-  AirwallexCad: Payment.CardNumberElementType ;
-  
-};
+export type AirwallexCadOtherData = Payment.CardNumberElementType;
 
-export type ExtraDataPlaceOrderResult<Code extends keyof ExtraMapPlaceOrderResult> = ExtraMapPlaceOrderResult[Code];
-export interface PlaceOrderResult<Code extends keyof ExtraMapPlaceOrderResult> {
+export type KlarnaOtherData = {
+  billingAddress: CartAddress;
+  shippingAddress: CartAddress;
+}
+export interface PlaceOrderResult<T> {
     // valid: boolean; 
     data: CreatePaymentInitiate | null;
-    otherData: ExtraDataPlaceOrderResult<Code> | null;
+    otherData: T | null;
 }
 
-export type PlaceOrderFunction = (params?: CreatePaymentInitiateVariables) => Promise<PlaceOrderResult<keyof ExtraMapPlaceOrderResult>>
+export type PlaceOrderFunction = <T = null>(params?: CreatePaymentInitiateVariables) => Promise<PlaceOrderResult<T>>
 
 export interface AirwallexCardElements{
   cardNumber?: Payment.CardNumberElementType;

+ 42 - 0
src/types/klarna.d.ts

@@ -0,0 +1,42 @@
+
+interface KlarnaLoadResponse {
+    show_form: boolean;
+    error?: {
+      invalid_fields?: string[];
+    };
+}
+
+interface KlarnaAuthorizeResponse {
+    approved: boolean;
+    show_form: boolean;
+    authorization_token?: string;
+    error?: {
+      invalid_fields?: string[];
+    };
+}
+
+
+interface KlarnaPayments {
+    init: (options: { client_token: string }) => void;
+    load: (
+      options: { container: string; payment_method_category?: string },
+      callback?: (res: KlarnaLoadResponse) => void
+    ) => void;
+    authorize: (
+      options?: { payment_method_category?: string },
+      data?: Record<string, unknown>,
+      callback?: (res: KlarnaAuthorizeResponse) => void
+    ) => void;
+}
+
+declare global {
+
+ interface Window {
+    Klarna: {
+      Payments: KlarnaPayments;
+    };
+ }
+
+}
+// 这里的export {} 起到一个标记的作用,告诉Typescript,这个文件是一个模块,而不是一个全局变量。
+export {};