Quellcode durchsuchen

结账页登录用户选择地址功能;

fogwind vor 1 Woche
Ursprung
Commit
4c62c77815

src/app/(checkout)/checkout/_components/AddressResultDisplay.tsx → src/app/(checkout)/checkout/_components/CheckoutAddress/AddressResultDisplay.tsx


src/app/(checkout)/checkout/_components/AddressResultDisplayLoading.tsx → src/app/(checkout)/checkout/_components/CheckoutAddress/AddressResultDisplayLoading.tsx


+ 153 - 23
src/app/(checkout)/checkout/_components/BillingAddressCheckout.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { useMemo, useState, useEffect } from "react";
+import { useMemo, useState, useEffect, useCallback } from "react";
 import { useQuery } from "@apollo/client/react";
 import clsx from "clsx";
 import { 
@@ -19,12 +19,27 @@ import {
     IS_VALID_PHONECODE,
     IS_VALID_FULL_PHONE 
 } from "@utils/constants";
+import { useConfig } from "@utils/hooks/useConfig";
+import { CustomerAddressItem } from "@/types/customer/type";
 import InputText from "@/components/theme/ui/InputText";
 import Select from "@/components/theme/ui/Select";
 import PhoneNumberInput from "@/components/theme/ui/PhoneNumberInput/PhoneNumberInput";
 import { LoadingSpinner } from "@components/common/LoadingSpinner";
-import { useConfig } from "@utils/hooks/useConfig";
-export default function BillingAddressCheckout () {
+import {LyDropDown} from "@/components/theme/ui/LyDropDown";
+
+export default function BillingAddressCheckout ({
+    noAddress,
+    isGuest,
+    showFormField,
+    onShowFormFieldChange,
+    customerAddressList
+}: {
+    noAddress: boolean;
+    isGuest: boolean;
+    showFormField: boolean;
+    onShowFormFieldChange: (e: boolean) => void;
+    customerAddressList: CustomerAddressItem[];
+}) {
     const {countries} = useConfig();
     const { 
         getValues,
@@ -34,9 +49,37 @@ export default function BillingAddressCheckout () {
         control 
     } = useFormContext() // retrieve all hook methods
 
-    const billingSameAsShipping = useWatch({
+    const countriesOptions = useMemo(() => {
+        return countries.map((country) => ({
+            value: country.code,
+            label: country.name,
+            id: String(country._id),
+        }))
+    },[countries]);
+
+    const [
+        billingSameAsShipping,
+        selectedCountryCode,
+        firstName,
+        lastName,
+        streetAddress,
+        stateProvince,
+        postCode,
+        city,
+        phoneNumber
+    ] = useWatch({
         control,
-        name: 'billingSameAsShipping',
+        name: [
+            'billingSameAsShipping',
+            'billingCountry',
+            'billingFirstName',
+            'billingLastName',
+            'billingAddress',
+            'billingState',
+            'billingPostcode',
+            'billingCity',
+            'billingPhoneNumber'
+        ]
     });
 
     const shippingFields = useWatch({
@@ -82,18 +125,7 @@ export default function BillingAddressCheckout () {
         setValue("billingPhoneNumber", shippingPhoneNumber);
     }, [billingSameAsShipping, shippingFields, setValue]);
     
-    const countriesOptions = useMemo(() => {
-        return countries.map((country) => ({
-            value: country.code,
-            label: country.name,
-            id: String(country._id),
-        }))
-    },[countries]);
-
-    const selectedCountryCode = useWatch({
-        control,
-        name: 'billingCountry',
-    });
+    
 
     const selectedCountry = useMemo(() => {
         return countries.find( (country) => country.code === selectedCountryCode)
@@ -116,7 +148,7 @@ export default function BillingAddressCheckout () {
         }));
     }
 
-   
+    const [showDropDown, setShowDropDown] = useState(false);
     // 是否用户手动选择过区号
     const [hasUserSelectedPhoneCode, setHasUserSelectedPhoneCode] = useState(false);
 
@@ -141,10 +173,33 @@ export default function BillingAddressCheckout () {
     },[selectedCountryCode,hasUserSelectedPhoneCode,getValues,setValue]);
 
 
+    const showMyAddressList = () => {
+        setShowDropDown(true);
+    };
+    const closeMyaddressList = useCallback(() => {
+        setShowDropDown(false);
+    },[]);
 
+    // 从地址列表选择地址
+    const selectAddress = (param: CustomerAddressItem | 'edit') => {
+        if(param === 'edit') {
+            if(!showFormField) onShowFormFieldChange(true);
+        } else {
+            setValue('billingFirstName',param.firstName);
+            setValue('billingLastName',param.lastName);
+            setValue('billingAddress',param.address);
+            setValue('billingCountry',param.country);
+            setValue('billingState',param.state);
+            setValue('billingPostcode',param.postcode);
+            setValue('billingCity',param.city);
+            setValue('billingPhoneNumber',param.phone);
+        }
+        closeMyaddressList();
+    };
     
-    return (
-        <section className="w-full mt-4"> 
+    return (<>
+        <section className="w-full mt-4 pb-8"> 
+
             <div className="box-border w-full p-3 bg-ly-lightgray">
                 <div className="flex justify-between items-center">
                     <span className="text-ly-12 flex-none">Billing Address</span>
@@ -162,11 +217,86 @@ export default function BillingAddressCheckout () {
                 <p className="text-ly-12 mt-1.25">Same As Shipping Address</p>
             </div>
 
+            <div className={clsx("w-full mt-4",{
+                "hidden": billingSameAsShipping,
+            })}>
+                {noAddress ? 
+                    <div onClick={showMyAddressList}
+                        className="box-border w-full h-9 justify-between flex items-center px-4 relative bg-[url(/image/address-bg.webp)] bg-position-[0_-70%] bg-size-[100%_auto]"
+                    >
+                        <span className="text-ly-12">+New Shipping Address</span>
+                        {!isGuest && 
+                            <button className="flex-none" title="change address">
+                                <svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="16" y="0" width="16" height="16" transform="rotate(90 16 0)"   fill="#FFFFFF" fillOpacity="0"></rect><path    stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="square"  d="M11.7295 6.32028L7.62964 10.4201L3.52978 6.32028"></path></svg>
+                            </button>
+                        }
+                    </div>
+                : 
+                    <div onClick={showMyAddressList}
+                        className="w-full box-border px-3.75 bg-[url(/image/address-bg.webp)] bg-position-[0_-84%] bg-size-[100%_auto]"
+                    >
+                        <div className="border-b-1 flex h-11.25 items-center justify-between">
+                            <p className="text-ly-13 font-medium">
+                                {firstName} {lastName}
+                                <span className="border-r border-ly-gray h-3.5 mx-2"></span> 
+                                {phoneNumber}
+                            </p>
+                            {!isGuest && 
+                                <svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="16" y="0" width="16" height="16" transform="rotate(90 16 0)"   fill="#FFFFFF" fillOpacity="0"></rect><path    stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="square"  d="M11.7295 6.32028L7.62964 10.4201L3.52978 6.32028"></path></svg>
+                            }
+                        </div>
+                        <div className="flex items-center h-10">
+                            <svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24" fill="none">
+                                <path stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"  d="M6.01652 15.917C4.48522 16.3764 3.53809 17.0111 3.53809 17.712C3.53809 19.1141 7.32661 20.2507 12 20.2507C16.6734 20.2507 20.4619 19.1141 20.4619 17.712C20.4619 17.0111 19.5148 16.3764 17.9835 15.917"></path>
+                                <path d="M12.0002 16.8657C12.0002 16.8657 17.5005 13.271 17.5005 9.11522C17.5005 6.15182 15.0379 3.74951 12.0002 3.74951C8.96254 3.74951 6.5 6.15182 6.5 9.11522C6.5 13.271 12.0002 16.8657 12.0002 16.8657Z" stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round"  ></path>
+                                <path d="M11.9983 11.3653C13.1666 11.3653 14.1138 10.4181 14.1138 9.24979C14.1138 8.08144 13.1666 7.13428 11.9983 7.13428C10.83 7.13428 9.88281 8.08144 9.88281 9.24979C9.88281 10.4181 10.83 11.3653 11.9983 11.3653Z" stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round"  ></path>
+                            </svg>
+                            <span className="text-ly-12 ml-2.5">
+                                {streetAddress}, 
+                                {city}, 
+                                {stateProvince}, 
+                                {postCode}, 
+                                {selectedCountryCode}
+                            </span>
+                        </div>
+                    </div>  
+                }
+                <LyDropDown
+                    showDropDown={showDropDown}
+                    onClose={closeMyaddressList}
+                >
+                    <div className="w-full p-3 text-ly-13 font-medium" key={'edit'} onClick={() => {selectAddress('edit')}}>
+                        Edit Address
+                    </div>
+                    {customerAddressList.map((item) => {
+                        return (
+                            <div className="w-full p-3" key={item.id} onClick={() => selectAddress(item)}>
+                        
+                                <p className="text-ly-13 leading-4">
+                                    {item.firstName} {item.lastName}, 
+                                    {item.address}, 
+                                    {item.city}, 
+                                    {item.state}, 
+                                    {item.postcode}, 
+                                    {item.country},
+                                    {item.phone}
+                                </p>
+                            </div>
+                        );
+                    })}
+                    
+                </LyDropDown>
+                
+            </div> 
+
+
             <div className={clsx("box-border w-full mt-4",{
-                    "hidden": billingSameAsShipping,
+                    "hidden": (!showFormField && !noAddress) || billingSameAsShipping
                 })}
             >
-                <div className="w-full">
+                <div className={clsx("w-full",{
+                    "hidden": !isGuest
+                })}>
                     <label className="text-ly-12 block mb-4 font-semibold">
                         Email *
                     </label>
@@ -352,5 +482,5 @@ export default function BillingAddressCheckout () {
 
 
         </section>
-    );
+    </>);
 }

+ 448 - 0
src/app/(checkout)/checkout/_components/CheckoutAddress/CheckoutAssress.tsx

@@ -0,0 +1,448 @@
+"use client";
+
+import {Ref, useState, useEffect, useCallback, useImperativeHandle } from "react";
+
+import { useForm, FormProvider } from "react-hook-form";
+import { useCustomToast } from "@/utils/hooks/useToast";
+import {useCheckoutAddress} from "@/utils/hooks/useCheckoutAddress"
+
+import { useGetCustomerAddress } from "@utils/hooks/useGetCustomerAddress";
+import { 
+    ShipAddressFormData,
+    FullAddressFormData,
+    CreateCheckoutAddressVariables,
+} from "@/types/checkout/type";
+import { CustomerAddressItem } from "@/types/customer/type";
+import { CartDetail,CartAddress } from "@/types/cart/type";
+
+import { overlayLoading } from "@/components/theme/ui/kernel/loading/api";
+
+import {normalizePhoneForForm} from "@/utils/phoneNumberTools";
+
+import AddressResultDisplay from "./AddressResultDisplay";
+import ShippingAddressCheckout from "./ShippingAddressCheckout";
+import BillingAddressCheckout from "./BillingAddressCheckout";
+import CommonModal from "@/components/theme/ui/CommonModal";
+
+
+
+export interface RefCheckoutAddressHandle {
+  getAddressFormDate: () => void;
+  validateAddressForm: () => Promise<boolean>;
+}
+
+/***
+ * 不用useForShipping字段了
+ * 以shipping address 为准,根据shippingaddress 设置billing address
+ * 保存完地址之后要重新获取运输方式和支付方式
+ */
+
+/**
+ * 地址表单默认值
+ */
+function getBillingAddressFormData(billingAddress:CartAddress) {
+    const billingCountry = billingAddress.country || 'US';
+    const billingPhoneNumber =  normalizePhoneForForm(billingAddress.phone, billingCountry) || "";
+    const res = {
+        "billingAddressId": billingAddress.id,
+        "billingEmail": billingAddress.email,
+        "billingFirstName": billingAddress.firstName,
+        "billingLastName": billingAddress.lastName,
+        "billingCompanyName": "",
+        "billingAddress": billingAddress.address,
+        "billingCountry": billingCountry,
+        "billingState": billingAddress.state,
+        "billingCity": billingAddress.city,
+        "billingPostcode": billingAddress.postcode,
+        "billingPhoneNumber": billingPhoneNumber,
+    };
+
+    return res;
+}
+function getShippingAddressFormData(shippingAddress:CartAddress) {
+    const shippingCountry = shippingAddress.country || 'US';
+    const shippingPhoneNumber =  normalizePhoneForForm(shippingAddress.phone, shippingCountry) || "";
+    const res = {
+        "shippingAddressId": shippingAddress.id,
+        "shippingEmail": shippingAddress.email,
+        "shippingFirstName": shippingAddress.firstName,
+        "shippingLastName": shippingAddress.lastName,
+        "shippingCompanyName": "",
+        "shippingAddress": shippingAddress.address,
+        "shippingCountry": shippingCountry,
+        "shippingState": shippingAddress.state,
+        "shippingCity": shippingAddress.city,
+        "shippingPostcode": shippingAddress.postcode,
+        "shippingPhoneNumber": shippingPhoneNumber,
+    };
+
+    return res;
+}
+function getAddressFormDataFromCart(cartDetail:CartDetail, loginEmail?: string) {
+    const shippingAddress = cartDetail.shippingAddress;
+    const billingAddress = cartDetail.billingAddress;
+    const defaultValues = {
+        "shippingAddressId": "",
+        "shippingEmail": loginEmail ?? '',
+        "shippingFirstName": "",
+        "shippingLastName": "",
+        "shippingCompanyName": "",
+        "shippingAddress": "",
+        "shippingCountry": "US",
+        "shippingState": "",
+        "shippingCity": "",
+        "shippingPostcode": "",
+        "shippingPhoneNumber": "",
+        "billingAddressId": "",
+        "billingEmail": loginEmail ?? '',
+        "billingFirstName": "",
+        "billingLastName": "",
+        "billingCompanyName": "",
+        "billingAddress": "",
+        "billingCountry": "US",
+        "billingState": "",
+        "billingCity": "",
+        "billingPostcode": "",
+        "billingPhoneNumber": "",
+        "billingSameAsShipping": true
+    }
+    if(billingAddress) {
+        const bs = getBillingAddressFormData(billingAddress);
+        defaultValues.billingAddressId = bs.billingAddressId;
+        defaultValues.billingEmail = bs.billingEmail;
+        defaultValues.billingFirstName = bs.billingFirstName;
+        defaultValues.billingLastName = bs.billingLastName;
+        defaultValues.billingCompanyName = bs.billingCompanyName;
+        defaultValues.billingAddress = bs.billingAddress;
+        defaultValues.billingCountry = bs.billingCountry;
+        defaultValues.billingState = bs.billingState;
+        defaultValues.billingCity = bs.billingCity;
+        defaultValues.billingPostcode = bs.billingPostcode;
+        defaultValues.billingPhoneNumber = bs.billingPhoneNumber;
+        // defaultValues.billingSameAsShipping = true;
+    }
+    if(shippingAddress) {
+        const ss = getShippingAddressFormData(shippingAddress);
+        defaultValues.shippingAddressId = ss.shippingAddressId;
+        defaultValues.shippingEmail = ss.shippingEmail;
+        defaultValues.shippingFirstName = ss.shippingFirstName;
+        defaultValues.shippingLastName = ss.shippingLastName;
+        defaultValues.shippingCompanyName = ss.shippingCompanyName;
+        defaultValues.shippingAddress = ss.shippingAddress;
+        defaultValues.shippingCountry = ss.shippingCountry;
+        defaultValues.shippingState = ss.shippingState;
+        defaultValues.shippingCity = ss.shippingCity;
+        defaultValues.shippingPostcode = ss.shippingPostcode;
+        defaultValues.shippingPhoneNumber = ss.shippingPhoneNumber;
+    }
+    if( defaultValues.billingEmail === defaultValues.shippingEmail &&
+        defaultValues.billingFirstName === defaultValues.shippingFirstName &&
+        defaultValues.billingLastName === defaultValues.shippingLastName &&
+        defaultValues.billingCompanyName === defaultValues.shippingCompanyName &&
+        defaultValues.billingAddress === defaultValues.shippingAddress &&
+        defaultValues.billingCountry === defaultValues.shippingCountry &&
+        defaultValues.billingState === defaultValues.shippingState &&
+        defaultValues.billingCity === defaultValues.shippingCity &&
+        defaultValues.billingPostcode === defaultValues.shippingPostcode &&
+        defaultValues.billingPhoneNumber === defaultValues.shippingPhoneNumber
+    ) {
+        defaultValues.billingSameAsShipping = true;
+    }
+    return defaultValues;
+}
+/**
+ * 生成createCheckoutAddress接口的参数
+ */
+function generateSaveCheckoutAddressParam(formData: FullAddressFormData):CreateCheckoutAddressVariables {
+    const formDataShippingAddress:ShipAddressFormData = {
+        shippingAddressId: formData.shippingAddressId,
+        shippingEmail: formData.shippingEmail,
+        shippingFirstName: formData.shippingFirstName,
+        shippingLastName: formData.shippingLastName,
+        shippingCompanyName: formData.shippingCompanyName,
+        shippingAddress: formData.shippingAddress,
+        shippingCountry: formData.shippingCountry,
+        shippingState: formData.shippingState,
+        shippingCity: formData.shippingCity,
+        shippingPostcode: formData.shippingPostcode,
+        shippingPhoneNumber: formData.shippingPhoneNumber,
+    };
+    let formDataBillingAddress = {
+        billingAddressId: formData.billingAddressId,
+        billingEmail: formData.billingEmail,
+        billingFirstName: formData.billingFirstName,
+        billingLastName: formData.billingLastName,
+        billingCompanyName : formData.billingCompanyName,
+        billingAddress: formData.billingAddress,
+        billingCountry: formData.billingCountry,
+        billingState: formData.billingState,
+        billingCity: formData.billingCity,
+        billingPostcode: formData.billingPostcode,
+        billingPhoneNumber: formData.billingPhoneNumber,
+
+        useForShipping: false,
+    };
+    if(formData.billingSameAsShipping) {
+        formDataBillingAddress = {
+            billingAddressId: formData.billingAddressId,
+            billingEmail: formDataShippingAddress.shippingEmail,
+            billingFirstName: formDataShippingAddress.shippingFirstName,
+            billingLastName: formDataShippingAddress.shippingLastName,
+            billingCompanyName : formDataShippingAddress.shippingCompanyName,
+            billingAddress: formDataShippingAddress.shippingAddress,
+            billingCountry: formDataShippingAddress.shippingCountry,
+            billingState: formDataShippingAddress.shippingState,
+            billingCity: formDataShippingAddress.shippingCity,
+            billingPostcode: formDataShippingAddress.shippingPostcode,
+            billingPhoneNumber: formDataShippingAddress.shippingPhoneNumber,
+            useForShipping: true,
+        }
+    }
+    return {
+       ...formDataShippingAddress,
+       ...formDataBillingAddress
+    };
+    
+}
+const myAddressPerPage = 50; // 每页的个数
+export function CheckoutAddress({
+    ref,
+    loginEmail,
+    cartData,
+    onSaveAddress,
+}: {
+    ref: Ref<RefCheckoutAddressHandle>;
+    loginEmail: string;
+    cartData: CartDetail;
+    onSaveAddress: (saveRes: {billingAddress: CartAddress; shippingAddress: CartAddress;}) => void;
+}) {
+
+    const addressFormDefaultValues = getAddressFormDataFromCart(cartData,loginEmail);
+
+    const {getCustomerAddress} = useGetCustomerAddress();
+    const { showToast } = useCustomToast();
+    const { saveCheckoutAddress } = useCheckoutAddress(loginEmail);
+    
+    // useForm() 只会在组件初始化时读取一次 defaultValues
+    const addressForm = useForm<FullAddressFormData>({
+        mode: "onChange",          // 或 "onChange"
+        reValidateMode: "onChange",
+        defaultValues: addressFormDefaultValues 
+    });
+    const [myAddressList, setMyAddressList] = useState<CustomerAddressItem[]>([]);
+    const [showShipFormField, setShowShipFormField] = useState(false);
+    const [showBillFormField, setShowBillFormField] = useState(false);
+    const [addressFormModalOpen, setAddressFormModalOpen] = useState(false);
+
+
+    useImperativeHandle(ref, () => {
+        return {
+            // 获取用户填写的地址
+            getAddressFormDate: () => {
+                return addressForm.getValues();
+            },
+         
+            // 触发校验
+            validateAddressForm: () => {
+                return addressForm.trigger();
+            },
+        }
+        
+    },[addressForm]);
+
+
+    const showShipFormFieldChange = useCallback((e: boolean) => {
+        setShowShipFormField(e);
+    },[]);
+    const showBillFormFieldChange = useCallback((e: boolean) => {
+        setShowBillFormField(e);
+    },[]);
+
+
+    const openAddressFormModal = () => {
+        setAddressFormModalOpen(true);
+        setShowShipFormField(false);
+        setShowBillFormField(false);
+        if(cartData.billingAddress && cartData.shippingAddress) {
+            addressForm.reset({
+                ...getAddressFormDataFromCart(cartData)
+            });
+        }
+
+        
+    };
+    const closeAddressFormModal = () => {
+        setAddressFormModalOpen(false);
+        setShowShipFormField(false);
+        setShowBillFormField(false);
+        // 重置表单
+        addressForm.reset();
+    };
+    const addressFormOnSubmit = async (formData: FullAddressFormData) => { 
+        // console.log('addressFormOnSubmit ---- ',formData); return;
+        const saveAddressParam = generateSaveCheckoutAddressParam(formData);
+
+        overlayLoading.start();
+        try {
+
+            const saveRes = await saveCheckoutAddress(saveAddressParam);
+            console.log('CREATE_CHECKOUT_ADDRESS res ====== ',saveRes);
+            if(!saveRes.error) {
+                // 地址保存成功后重新获取运输方式和支付方式 
+                // 保存完地址之后,把保存后的地址id同步到表单里;
+                const createAddressData = saveRes.data;
+                if(!createAddressData) {
+                    // 提示用户出错了,刷新页面
+                    showToast('Something wrong. Please refresh the page.', 'danger');
+                    return;
+                }
+
+                addressForm.resetField('shippingAddressId',{
+                    defaultValue: createAddressData.shippingAddressId
+                });
+                addressForm.resetField('billingAddressId',{
+                    defaultValue: createAddressData.billingAddressId
+                });
+
+        
+                // 同步地址到购物车详情
+                const newBillingAddress =  {
+                    id: String(createAddressData.billingAddressId),
+                    firstName: createAddressData.billingFirstName,
+                    lastName: createAddressData.billingLastName,
+                    email: createAddressData.billingEmail,
+                    address: createAddressData.billingAddress,
+                    city: createAddressData.billingCity,
+                    state: createAddressData.billingState,
+                    country: createAddressData.billingCountry,
+                    postcode: createAddressData.billingPostcode,
+                    phone: createAddressData.billingPhoneNumber
+                };
+                const newShippingAddress = {
+                    id: String(createAddressData.shippingAddressId),
+                    firstName: createAddressData.shippingFirstName,
+                    lastName: createAddressData.shippingLastName,
+                    email: createAddressData.shippingEmail,
+                    address: createAddressData.shippingAddress,
+                    city: createAddressData.shippingCity,
+                    state: createAddressData.shippingState,
+                    country: createAddressData.shippingCountry,
+                    postcode: createAddressData.shippingPostcode,
+                    phone: createAddressData.shippingPhoneNumber,
+                };
+   
+                onSaveAddress({
+                    billingAddress: newBillingAddress,
+                    shippingAddress: newShippingAddress
+                });
+               
+            } else {
+                showToast(saveRes.msg, 'danger');
+            }
+            setAddressFormModalOpen(false);
+        } catch(err) {
+            // 错误处理
+            console.error("save address error", err);
+           
+            showToast('Save address failed. Please try again.', 'danger');
+        } finally {
+            overlayLoading.stop();
+        }
+        
+    };
+
+    const noShipAddress = cartData.shippingAddress === null;
+    const noBillAddress = cartData.billingAddress === null;
+
+    
+    useEffect(() => {
+        if(cartData.isGuest) return;
+
+        async function loadAddress() {
+
+            overlayLoading.start();
+            const res = await getCustomerAddress({
+                first: myAddressPerPage
+            });
+            overlayLoading.stop();
+            if(!res.error && res.data !== null) {
+                setMyAddressList(res.data.list);
+            } else {
+                showToast(res.msg,"danger");
+            }  
+        }
+        loadAddress();
+    },[showToast,cartData.isGuest]);
+    
+    // function tt() {
+    //     const arr = [
+    //         ['US','3803800217'], 
+    //         ['CH','+4186043315'], 
+    //         ['US','334-208-6177'], 
+    //         ['AU','+61 404103617'], 
+    //         ['US','1-9166705105'], 
+    //         ['CA','819 384 3221'],
+    //         ['US','(409) 239-9482'],
+    //         ['US','1-(651) 421-2762'],
+    //         ['CH', '1-792930242'],
+    //         ['US', '+1 4570438892'],
+    //         ['US', '1-+1 (404) 276-1068'],
+    //         ['CH', '+49 015164340021'], // 国家与phonecode不一致情况
+    //     ];
+    //     arr.forEach((item) => {
+    //         normalizePhoneForForm(item[1], item[0]);
+    //     });
+    // }
+    return (<>
+        <AddressResultDisplay shippingAddress={cartData.shippingAddress || null} 
+            onAddressModalOpenClick={openAddressFormModal}
+        />
+        <CommonModal
+            isOpen={addressFormModalOpen}
+            onClose={closeAddressFormModal}
+            header={
+                <div className="w-full box-border p-3.75 h-14.5 flex justify-between items-center">
+                    <span>Shipping Address</span>
+                    <button className="w-6 h-6" onClick={closeAddressFormModal}>
+                        <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>
+                    </button>
+                </div>
+            }
+            body={
+                <div className="box-border w-full h-full p-3.75">
+                    <div>
+                        <FormProvider {...addressForm}>
+                            <form>
+                                <ShippingAddressCheckout 
+                                    noAddress={noShipAddress}
+                                    isGuest={cartData.isGuest}
+                                    customerAddressList={myAddressList}
+                                    showFormField={showShipFormField}
+                                    onShowFormFieldChange={showShipFormFieldChange}
+                                />
+                                <BillingAddressCheckout 
+                                    noAddress={noBillAddress}
+                                    isGuest={cartData.isGuest}
+                                    customerAddressList={myAddressList}
+                                    showFormField={showBillFormField}
+                                    onShowFormFieldChange={showBillFormFieldChange}
+                                />
+                            </form>
+                        </FormProvider>
+                    </div>
+                </div>
+            }
+            footer={
+                <div className="box-border w-full p-3.75">
+                <button className="block flex justify-center items-center w-full h-12 bg-ly-green rounded-3xl text-white text-ly-16 font-bold"
+                    onClick={addressForm.handleSubmit(addressFormOnSubmit)}
+                >
+                    Save
+                </button>
+                </div>
+            }
+        />
+            
+
+    </>);
+};

+ 173 - 11
src/app/(checkout)/checkout/_components/ShippingAddressCheckout.tsx

@@ -1,6 +1,7 @@
 "use client";
 
-import {useMemo,useEffect, useState} from "react";
+import {useMemo,useEffect, useState, useCallback} from "react";
+import clsx from "clsx";
 import { useQuery } from "@apollo/client/react";
 import { 
     useFormContext, 
@@ -13,22 +14,40 @@ import {
 } from "@/components/theme/ui/PhoneNumberInput/phoneCodeMetaData";
 import { GET_COUNTRY_STATES } from "@/graphql";
 import { EMAIL_REGEX, IS_VALID_INPUT, IS_VALID_FULL_PHONE, IS_VALID_PHONECODE } from "@utils/constants";
+import { CustomerAddressItem } from "@/types/customer/type";
+import { useConfig } from "@utils/hooks/useConfig";
 import InputText from "@/components/theme/ui/InputText";
 import Select from "@/components/theme/ui/Select";
 import PhoneNumberInput from "@/components/theme/ui/PhoneNumberInput/PhoneNumberInput";
 import { LoadingSpinner } from "@components/common/LoadingSpinner";
-import { useConfig } from "@utils/hooks/useConfig";
+import {LyDropDown} from "@/components/theme/ui/LyDropDown";
+
+
+
+export default function ShippingAddressCheckout({
+    noAddress,
+    isGuest,
+    showFormField,
+    onShowFormFieldChange,
+    customerAddressList,
+}: {
+    noAddress: boolean;
+    isGuest: boolean;
+    showFormField: boolean;
+    onShowFormFieldChange: (e: boolean) => void;
+    customerAddressList: CustomerAddressItem[];
+}) {
 
-export default function ShippingAddressCheckout () {
     const {countries} = useConfig();
     const { 
         getValues,
         setValue,
         register,
         formState: { errors },
-        control 
+        control,
     } = useFormContext() // retrieve all hook methods
-
+    console.log('errors ----- ', errors);
+    
     const countriesOptions = useMemo(() => {
         return countries.map((country) => ({
             value: country.code,
@@ -37,9 +56,27 @@ export default function ShippingAddressCheckout () {
         }))
     },[countries]);
 
-    const selectedCountryCode = useWatch({
+    const [ 
+        selectedCountryCode,
+        firstName,
+        lastName,
+        streetAddress,
+        stateProvince,
+        postCode,
+        city,
+        phoneNumber
+    ] = useWatch({
         control,
-        name: 'shippingCountry',
+        name: [
+            'shippingCountry',
+            'shippingFirstName',
+            'shippingLastName',
+            'shippingAddress',
+            'shippingState',
+            'shippingPostcode',
+            'shippingCity',
+            'shippingPhoneNumber'
+        ]
     });
     const selectedCountry = useMemo(() => {
         return countries.find( (country) => country.code === selectedCountryCode)
@@ -62,9 +99,12 @@ export default function ShippingAddressCheckout () {
         }));
     }
 
+    const [showDropDown, setShowDropDown] = useState(false);
+
     // 是否用户手动选择过区号
     const [hasUserSelectedPhoneCode, setHasUserSelectedPhoneCode] = useState(false);
 
+
     // 国家变更后 state字段的值需要清空
     // 选择国家时同步电话区号
     useEffect(() => {
@@ -86,10 +126,132 @@ export default function ShippingAddressCheckout () {
         
     },[selectedCountryCode,hasUserSelectedPhoneCode,getValues,setValue]);
 
+    const showMyAddressList = () => {
+        setShowDropDown(true);
+    };
+    const closeMyaddressList = useCallback(() => {
+        setShowDropDown(false);
+    },[]);
+    // 从地址列表选择地址
+    const selectAddress = (param: CustomerAddressItem | 'edit') => {
+        if(param === 'edit') {
+            if(!showFormField) onShowFormFieldChange(true);
+        } else {
+            setValue('shippingFirstName',param.firstName);
+            setValue('shippingLastName',param.lastName);
+            setValue('shippingAddress',param.address);
+            setValue('shippingCountry',param.country);
+            setValue('shippingState',param.state);
+            setValue('shippingPostcode',param.postcode);
+            setValue('shippingCity',param.city);
+            setValue('shippingPhoneNumber',param.phone);
+        }
+        closeMyaddressList();
+    };
 
-    return (
-        <div className="box-border w-full">
-            <div className="w-full">
+    /**
+     * 游客:
+     * 1. 购物车没地址 -- 进来显示填写新地址,地址表单;填完地址,保存,关闭弹窗,购物车地址更新;再次进来显示填写的地址,隐藏表单;如果想修改,就点击修改,显示地址表单;
+     * 2. 购物车有地址 -- 进来显示已有的地址,隐藏表单;如果想修改,就点击修改,显示地址表单;
+     * 
+     * 登录用户:
+     * 1. 购物车没地址 -- 进来显示填写新地址,地址表单;
+     *    如果想选已有地址,点击箭头,弹出地址列表,选择地址,选完地址更新到表单,关闭地址列表(打上选中状态);如果地址列表没数据,显示没有地址供选择;
+     *    如果不想选地址,就填写地址表单。
+     *    保存地址,关闭弹窗;
+     *    再次进来,显示购物车中的地址;
+     * 
+     * 2. 购物车有地址 -- 进来显示购物车中的地址;
+     *    如果想修改地址,点击箭头,出现地址列表
+     *    
+     * 地址列表数据直接在 父组件获取
+     */
+    return (<>
+        
+        <div className="w-full">
+            {noAddress ? 
+                <div onClick={showMyAddressList}
+                    className="box-border w-full h-9 justify-between flex items-center px-4 relative bg-[url(/image/address-bg.webp)] bg-position-[0_-70%] bg-size-[100%_auto]"
+                >
+                    <span className="text-ly-12">+New Shipping Address</span>
+                    {!isGuest && 
+                        <button className="flex-none" title="change address">
+                            <svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="16" y="0" width="16" height="16" transform="rotate(90 16 0)"   fill="#FFFFFF" fillOpacity="0"></rect><path    stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="square"  d="M11.7295 6.32028L7.62964 10.4201L3.52978 6.32028"></path></svg>
+                        </button>
+                    }
+                </div>
+            : 
+                <div onClick={showMyAddressList}
+                    className="w-full box-border px-3.75 bg-[url(/image/address-bg.webp)] bg-position-[0_-84%] bg-size-[100%_auto]"
+                >
+                    <div className="border-b-1 flex h-11.25 items-center justify-between">
+                        <p className="text-ly-13 font-medium">
+                            {firstName} {lastName}
+                            <span className="border-r border-ly-gray h-3.5 mx-2"></span> 
+                            {phoneNumber}
+                        </p>
+                        {!isGuest && 
+                            <svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="16" y="0" width="16" height="16" transform="rotate(90 16 0)"   fill="#FFFFFF" fillOpacity="0"></rect><path    stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="square"  d="M11.7295 6.32028L7.62964 10.4201L3.52978 6.32028"></path></svg>
+                        }
+                    </div>
+                    <div className="flex items-center h-10">
+                        <svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24" fill="none">
+                            <path stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"  d="M6.01652 15.917C4.48522 16.3764 3.53809 17.0111 3.53809 17.712C3.53809 19.1141 7.32661 20.2507 12 20.2507C16.6734 20.2507 20.4619 19.1141 20.4619 17.712C20.4619 17.0111 19.5148 16.3764 17.9835 15.917"></path>
+                            <path d="M12.0002 16.8657C12.0002 16.8657 17.5005 13.271 17.5005 9.11522C17.5005 6.15182 15.0379 3.74951 12.0002 3.74951C8.96254 3.74951 6.5 6.15182 6.5 9.11522C6.5 13.271 12.0002 16.8657 12.0002 16.8657Z" stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round"  ></path>
+                            <path d="M11.9983 11.3653C13.1666 11.3653 14.1138 10.4181 14.1138 9.24979C14.1138 8.08144 13.1666 7.13428 11.9983 7.13428C10.83 7.13428 9.88281 8.08144 9.88281 9.24979C9.88281 10.4181 10.83 11.3653 11.9983 11.3653Z" stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round"  ></path>
+                        </svg>
+                        <span className="text-ly-12 ml-2.5">
+                            {streetAddress}, 
+                            {city}, 
+                            {stateProvince}, 
+                            {postCode}, 
+                            {selectedCountryCode}
+                        </span>
+                    </div>
+                </div>  
+            }
+            <LyDropDown
+                showDropDown={showDropDown}
+                onClose={closeMyaddressList}
+            >
+                <div className="w-full p-3 text-ly-13 font-medium" key={'edit'} onClick={() => {selectAddress('edit')}}>
+                    Edit Address
+                </div>
+                {customerAddressList.map((item) => {
+                    return (
+                        <div className="w-full p-3" key={item.id} onClick={() => selectAddress(item)}>
+                    
+                            <p className="text-ly-13 leading-4">
+                                {item.firstName} {item.lastName}, 
+                                {item.address}, 
+                                {item.city}, 
+                                {item.state}, 
+                                {item.postcode}, 
+                                {item.country},
+                                {item.phone}
+                            </p>
+                        </div>
+                    );
+                })}
+                
+            </LyDropDown>
+            
+        </div>     
+            
+        
+        <p className="text-ly-12 leading-ly-20 mt-2 mb-2">
+            Select a shipping address from your address book or enter a new address.
+        </p>
+        {/**
+         * 登录用户: noAddres=false and showFormField = false 时 隐藏;  noAddres=true 显示
+         * 游客: noAddres=false and showFormField = false 时 隐藏; noAddres=true 显示
+         */}
+        <div className={clsx("box-border w-full", {
+            "hidden": !showFormField && !noAddress
+        })}>
+            <div className={clsx("w-full",{
+                "hidden": !isGuest
+            })}>
                 <label className="text-ly-12 block mb-4 font-semibold">
                     Email *
                 </label>
@@ -273,5 +435,5 @@ export default function ShippingAddressCheckout () {
             </div>
 
         </div>
-    );
+    </>);
 };

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

@@ -1,7 +1,7 @@
 "use client";
 
 import clsx from 'clsx';
-import { useRouter } from 'next/navigation'
+import { useRouter } from 'next/navigation';
 import {AirwallexCartNumberElementType} from "@/lib/Airwallex/airwallexInit";
 import PaypalButton from "./PaymentButton/PaypalButton";
 import { overlayLoading } from "@/components/theme/ui/kernel/loading/api";

+ 1 - 0
src/app/(checkout)/checkout/_components/VipPlus/CheckoutVipPlus.tsx

@@ -193,6 +193,7 @@ export default function CheckoutVipPlus({
         }
 
         <CommonModal 
+            destroyOnClose={true}
             isOpen={isShow}
             onClose={toggleModal}
             contentClassName={"left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-80 rounded-xl bg-white px-3"}

+ 1 - 1
src/app/(checkout)/checkout/page.tsx

@@ -33,7 +33,7 @@ export default async function CheckoutPage() {
     const cartDetails = cartDetailsRes.createReadCart.readCart;
     return (
         <>
-            <CheckoutWrapper key={cartDetails.id}
+            <CheckoutWrapper key={Date.now()}
                 cartDetailData={cartDetails}
                 loginEmail={session?.user?.email || ""}
             />

+ 10 - 1
src/components/common/LoginModal/LoginModal.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import {useState} from "react";
+import {useState, useLayoutEffect} from "react";
 import { useRouter } from 'next/navigation';
 import { signIn } from "next-auth/react";
 import clsx from "clsx";
@@ -81,6 +81,15 @@ export default function LoginModal({
         }
     });
     const [activeTab, setActiveTab] = useState<TabFlag>('login');
+
+
+    useLayoutEffect(() => {
+        if(isShow) {
+            loginForm.reset();
+            registerForm.reset();
+        }
+    },[isShow]);
+
     const toggleLoginRegister = (tab: TabFlag) => {
         setActiveTab(tab);
     };

+ 1 - 1
src/components/theme/ui/CommonModal.tsx

@@ -42,7 +42,7 @@ export default function CommonModal({
     const [rootPddingTop, setRootPddingTop] = useState(0);
 
     const baseClassNname = "absolute box-border transition-transform duration-300 ease-out";
-    let applyClassName = "bottom-0 left-0 h-17/20 w-full bg-white";
+    let applyClassName = "bottom-0 left-0 h-17/20 w-full bg-white rounded-t-xl";
     if(contentClassName) {
         applyClassName = contentClassName;
     }

+ 102 - 0
src/components/theme/ui/LyDropDown.tsx

@@ -0,0 +1,102 @@
+"use client";
+
+import { useState, useRef, useLayoutEffect, ReactNode, useEffect } from "react";
+import clsx from "clsx";
+
+
+export function LyDropDown({
+    showDropDown,
+    children,
+    onClose
+}: {
+    showDropDown: boolean;
+    children: ReactNode;
+    onClose: () => void;
+}) {
+    const listRef = useRef<HTMLDivElement>(null);
+    const rootRef = useRef<HTMLDivElement>(null);
+    const [listPosition, setListPosition] = useState({
+        top: '0',
+        transform: 'translate3d(0, 0%, 0)', 
+    });
+
+    useLayoutEffect(() => {
+        if (listRef.current && rootRef.current && showDropDown) {
+            const { height } = listRef.current.getBoundingClientRect();
+            // setListHeight(height);
+            console.log('Measured tooltip height: ' + height);
+
+            const triggerRect = rootRef.current.getBoundingClientRect();
+            if(triggerRect.bottom + height > window.innerHeight) { // 显示在上方
+                setListPosition({
+                    top: '0',
+                    transform: 'translate3d(0, -100%, 0)',
+                });
+            } else { // 显示在下方
+                setListPosition({
+                    top: `${triggerRect.height}px`,
+                    transform: `translate3d(0, 0%, 0)`
+                });
+            }
+            
+        }
+    }, [showDropDown]);
+
+
+    // 点击外部关闭
+    useEffect(() => {
+
+        if (!showDropDown) return;
+
+
+        function handleClickOutside(event: MouseEvent) {
+
+            const target = event.target as Node;
+
+            if (
+                rootRef.current &&
+                !rootRef.current.contains(target)
+            ) {
+                onClose();
+            }
+        }
+
+
+        // 放到 document 捕获阶段
+        document.addEventListener(
+            "pointerdown",
+            handleClickOutside,
+            true
+        );
+
+
+        return () => {
+            document.removeEventListener(
+                "pointerdown",
+                handleClickOutside,
+                true
+            );
+        };
+
+    }, [showDropDown, onClose]);
+
+
+
+    return (
+        <>
+            <div className="w-full relative" ref={rootRef}>
+
+                <div ref={listRef} className={clsx(
+                    "absolute w-full max-h-48 overflow-auto bg-white ly-thin-shadow border border-ly-gray rounded-md z-1",
+                    showDropDown  ? "visible pointer-events-auto" : "invisible pointer-events-none"
+                )}
+                    onClick={(e) => { e.stopPropagation(); }}
+                    style={listPosition}
+                >
+                    {children}
+                    
+                </div>
+            </div>
+        </>
+    );
+}

+ 44 - 0
src/graphql/customer/query/GetCustomerAddress.ts

@@ -0,0 +1,44 @@
+import { gql,TypedDocumentNode } from "@apollo/client";
+import { GetCustomerAddressResponse, GetCustomerAddressVariables } from "@/types/customer/type";
+
+export const GET_CUSTOMER_ADDRESS: TypedDocumentNode<GetCustomerAddressResponse,GetCustomerAddressVariables> = gql`
+query getCustomerAddresses($first: Int, $after: String, $last:Int, $before: String) {
+  getCustomerAddresses(first: $first, after: $after, last: $last, before: $before) {
+    edges {
+      cursor
+      node {
+        id
+        _id
+        parentAddressId
+        addressType
+        companyName
+        name
+        firstName
+        lastName
+        email
+        address
+        city
+        state
+        country
+        postcode
+        phone
+        vatId
+        cartId
+        orderId
+        gender
+        defaultAddress
+        useForShipping
+        additional
+        createdAt
+        updatedAt
+      }
+    }
+    pageInfo {
+      hasNextPage
+      hasPreviousPage
+      startCursor
+      endCursor
+    }
+    totalCount
+  }
+}`;

+ 2 - 1
src/graphql/customer/query/index.ts

@@ -1,3 +1,4 @@
 export {
     GET_ORDER_DETAILS
-} from "./GetOrderDetails";
+} from "./GetOrderDetails";
+export {GET_CUSTOMER_ADDRESS} from "./GetCustomerAddress";

+ 48 - 0
src/types/customer/type.ts

@@ -22,4 +22,52 @@ export interface CustomerLoginResponse{
         customerLogin: CustomerLoginData
     }
     
+}
+
+export interface CustomerAddressItem {
+    id: string;
+    _id: number;
+    parentAddressId: string | null;
+    addressType: string;
+    companyName: string | null;
+    name: string;
+    firstName: string;
+    lastName: string;
+    email: string;
+    address: string;
+    city: string;
+    state: string;
+    country: string;
+    postcode: string;
+    phone: string;
+    vatId: string | null;
+    cartId: string | null;
+    orderId: string | null;
+    gender: string | null;
+    defaultAddress: boolean;
+    useForShipping: boolean;
+    additional: string | null;
+    createdAt: string;
+    updatedAt: string;
+}
+export interface GetCustomerAddressVariables {
+    first?: number;
+    after?: string;
+    last?: number;
+    before?: string;
+}
+export interface GetCustomerAddressResponse {
+    getCustomerAddresses: {
+        edges: Array<{
+            cursor: string;
+            node: CustomerAddressItem
+        }>
+    };
+    pageInfo: {
+        hasNextPage: boolean;
+        hasPreviousPage: boolean;
+        startCursor: string;
+        endCursor: string;
+    };
+    totalCount: number;
 }

+ 63 - 0
src/utils/hooks/useGetCustomerAddress.ts

@@ -0,0 +1,63 @@
+"use client";
+
+import { useCallback } from "react";
+import { GET_CUSTOMER_ADDRESS } from "@/graphql";
+import {useApolloClient} from "@apollo/client/react";
+import { GetCustomerAddressVariables } from "@/types/customer/type";
+import {normalizePhoneForForm} from "@/utils/phoneNumberTools";
+
+
+export function useGetCustomerAddress() {
+    const apolloClient = useApolloClient();
+
+
+    const getCustomerAddress = useCallback((param:GetCustomerAddressVariables) => {
+        return apolloClient.query({
+            query: GET_CUSTOMER_ADDRESS,
+            variables: param,
+            fetchPolicy: "no-cache",
+            context: {
+                fetchOptions: {
+                    cache: 'no-store',
+                },
+        
+            }
+        }).then((res) => {
+            const addressList =  res.data?.getCustomerAddresses?.edges?.map((edge) => {
+                    const item = {...edge.node};
+                    item.phone = normalizePhoneForForm(edge.node.phone, edge.node.country);
+                    return item;
+                    // return {
+                    //     cursor: edge.cursor,
+                    //     node: item
+                    // };
+                }) || [];
+            
+            return {
+                data: {
+                    list: addressList,
+                    pageInfo: res.data?.pageInfo,
+                    totalCount: res.data?.totalCount
+                },
+                error: false,
+                msg: ""
+            };
+        
+        }).catch((err) => {
+            return {
+                data: null,
+                error: true,
+                msg: err.message,
+            }
+        });
+    },[apolloClient]);
+
+   
+
+  return {
+    getCustomerAddress
+  };
+}
+
+
+