BillingAddressCheckout.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. "use client";
  2. import { useMemo, useState, useEffect, useCallback } from "react";
  3. import { useQuery } from "@apollo/client/react";
  4. import clsx from "clsx";
  5. import {
  6. useFormContext,
  7. useWatch,
  8. Controller
  9. } from "react-hook-form";
  10. import {
  11. formatFinalPhoneValue,
  12. getPhoneCodeBycountryCode
  13. } from "@/components/theme/ui/PhoneNumberInput/phoneCodeMetaData";
  14. import { GET_COUNTRY_STATES } from "@/graphql";
  15. import {
  16. EMAIL_REGEX,
  17. IS_VALID_INPUT,
  18. IS_VALID_PHONECODE,
  19. IS_VALID_FULL_PHONE
  20. } from "@utils/constants";
  21. import { useConfig } from "@utils/hooks/useConfig";
  22. import type { CustomerAddressItem } from "@/types/customer/type";
  23. import InputText from "@/components/theme/ui/InputText";
  24. import Select from "@/components/theme/ui/Select";
  25. import PhoneNumberInput from "@/components/theme/ui/PhoneNumberInput/PhoneNumberInput";
  26. import { LoadingSpinner } from "@components/common/LoadingSpinner";
  27. import {LyDropDown} from "@/components/theme/ui/LyDropDown";
  28. export default function BillingAddressCheckout ({
  29. noAddress,
  30. isGuest,
  31. showFormField,
  32. onShowFormFieldChange,
  33. customerAddressList
  34. }: {
  35. noAddress: boolean;
  36. isGuest: boolean;
  37. showFormField: boolean;
  38. onShowFormFieldChange: (e: boolean) => void;
  39. customerAddressList: CustomerAddressItem[];
  40. }) {
  41. const {countries} = useConfig();
  42. const {
  43. getValues,
  44. setValue,
  45. register,
  46. clearErrors,
  47. formState: { errors },
  48. control
  49. } = useFormContext() // retrieve all hook methods
  50. const countriesOptions = useMemo(() => {
  51. return countries.map((country) => ({
  52. value: country.code,
  53. label: country.name,
  54. id: String(country._id),
  55. }))
  56. },[countries]);
  57. const [
  58. billingSameAsShipping,
  59. selectedCountryCode,
  60. firstName,
  61. lastName,
  62. streetAddress,
  63. stateProvince,
  64. postCode,
  65. city,
  66. phoneNumber
  67. ] = useWatch({
  68. control,
  69. name: [
  70. 'billingSameAsShipping',
  71. 'billingCountry',
  72. 'billingFirstName',
  73. 'billingLastName',
  74. 'billingAddress',
  75. 'billingState',
  76. 'billingPostcode',
  77. 'billingCity',
  78. 'billingPhoneNumber'
  79. ]
  80. });
  81. useEffect(() => {
  82. if (billingSameAsShipping) {
  83. clearErrors([
  84. "billingEmail",
  85. "billingFirstName",
  86. "billingLastName",
  87. "billingCompanyName",
  88. "billingAddress",
  89. "billingCountry",
  90. "billingState",
  91. "billingCity",
  92. "billingPostcode",
  93. "billingPhoneNumber",
  94. ]);
  95. }
  96. }, [billingSameAsShipping, clearErrors]);
  97. /*
  98. const shippingFields = useWatch({
  99. control,
  100. name: [
  101. "shippingEmail",
  102. "shippingFirstName",
  103. "shippingLastName",
  104. "shippingCompanyName",
  105. "shippingAddress",
  106. "shippingCountry",
  107. "shippingState",
  108. "shippingCity",
  109. "shippingPostcode",
  110. "shippingPhoneNumber",
  111. ],
  112. });
  113. useEffect(() => {
  114. if (!billingSameAsShipping) return;
  115. const [
  116. shippingEmail,
  117. shippingFirstName,
  118. shippingLastName,
  119. shippingCompanyName,
  120. shippingAddress,
  121. shippingCountry,
  122. shippingState,
  123. shippingCity,
  124. shippingPostcode,
  125. shippingPhoneNumber,
  126. ] = shippingFields;
  127. setValue("billingEmail", shippingEmail);
  128. setValue("billingFirstName", shippingFirstName);
  129. setValue("billingLastName", shippingLastName);
  130. setValue("billingCompanyName", shippingCompanyName);
  131. setValue("billingAddress", shippingAddress);
  132. setValue("billingCountry", shippingCountry);
  133. setValue("billingCity", shippingCity);
  134. setValue("billingPostcode", shippingPostcode);
  135. setValue("billingPhoneNumber", shippingPhoneNumber);
  136. setValue("billingState", shippingState,{shouldValidate: true}); // 必须触发校验,因为使用浏览器自动填充的时候,可能导致billingState校验不通过,导致无法下单
  137. }, [billingSameAsShipping, shippingFields, setValue]);
  138. */
  139. const selectedCountry = useMemo(() => {
  140. return countries.find( (country) => country.code === selectedCountryCode)
  141. }, [countries, selectedCountryCode]);
  142. const { data: statesData, loading: statesLoading } = useQuery(GET_COUNTRY_STATES, {
  143. variables: {
  144. countryId: selectedCountry?._id
  145. },
  146. skip: !selectedCountryCode,
  147. });
  148. let statesOptions: { value: string; label: string, id: string }[] = [];
  149. if(statesData) {
  150. statesOptions = statesData.countryStates.map((state) => ({
  151. value: state.code,
  152. label: state.defaultName,
  153. id: String(state._id),
  154. }));
  155. }
  156. const [showDropDown, setShowDropDown] = useState(false);
  157. // 是否用户手动选择过区号
  158. const [hasUserSelectedPhoneCode, setHasUserSelectedPhoneCode] = useState(false);
  159. // 选择国家时同步电话区号
  160. useEffect(() => {
  161. const preBillingPhoneNum = getValues('billingPhoneNumber');
  162. const regPhoneCode = IS_VALID_PHONECODE;
  163. if(preBillingPhoneNum && !hasUserSelectedPhoneCode) {
  164. const newPhoneCode = getPhoneCodeBycountryCode(selectedCountryCode);
  165. const codePart = regPhoneCode.exec(preBillingPhoneNum);
  166. let phone = preBillingPhoneNum;
  167. if(codePart) {
  168. phone = preBillingPhoneNum.replace(codePart[0], '');
  169. }
  170. if(codePart && newPhoneCode !== codePart[0].trim()) {
  171. return;
  172. }
  173. setValue('billingPhoneNumber',formatFinalPhoneValue(newPhoneCode, phone))
  174. }
  175. },[selectedCountryCode,hasUserSelectedPhoneCode,getValues,setValue]);
  176. const changeAddress = () => {
  177. if(isGuest) {
  178. if(!showFormField) onShowFormFieldChange(true);
  179. } else {
  180. showMyAddressList();
  181. }
  182. };
  183. const showMyAddressList = () => {
  184. setShowDropDown(true);
  185. };
  186. const closeMyaddressList = useCallback(() => {
  187. setShowDropDown(false);
  188. },[]);
  189. // 从地址列表选择地址
  190. const selectAddress = (param: CustomerAddressItem | 'edit') => {
  191. if(param === 'edit') {
  192. if(!showFormField) onShowFormFieldChange(true);
  193. } else {
  194. setValue('billingFirstName',param.firstName);
  195. setValue('billingLastName',param.lastName);
  196. setValue('billingAddress',param.address);
  197. setValue('billingCountry',param.country);
  198. setValue('billingState',param.state);
  199. setValue('billingPostcode',param.postcode);
  200. setValue('billingCity',param.city);
  201. setValue('billingPhoneNumber',param.phone);
  202. }
  203. closeMyaddressList();
  204. };
  205. return (<>
  206. <section className="w-full mt-4">
  207. <div className="box-border w-full p-4 bg-ly-lightgray">
  208. <div className="flex justify-between items-center">
  209. <span className="text-ly-20 flex-none">Billing Address</span>
  210. <label className="group relative w-9 h-5.5 flex-none">
  211. <input className="absolute w-full h-full top-0 left-0 opacity-0 z-10"
  212. type="checkbox"
  213. {...register("billingSameAsShipping")}
  214. />
  215. <span className="transition-all box-border block px-0.5 w-full h-full rounded-full flex items-center bg-[#c2c2c2] group-has-[:checked]:bg-[#363636]">
  216. <span className="transition-all block w-4.5 h-4.5 rounded-full bg-white group-has-[:checked]:ml-3.5"></span>
  217. </span>
  218. </label>
  219. </div>
  220. <p className="text-ly-14 mt-1.25">Same As Shipping Address</p>
  221. </div>
  222. <div className={clsx("w-full mt-4",{
  223. "hidden": billingSameAsShipping,
  224. })}>
  225. <h3 className="text-ly-20 font-medium mb-4">Billing Address</h3>
  226. {noAddress ?
  227. <div className="relative box-border w-full h-14 flex items-center px-4 bg-[url(/image/address-bg.webp)] bg-position-[0_-70%] bg-size-[100%_auto]">
  228. <svg className="flex-none w-6 h-6 mr-3" 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 d="M12 21.9999C12 21.9999 19.5 15.9999 19.5 9.49988C19.5 5.35773 16.1422 1.99988 12 1.99988C7.85785 1.99988 4.5 5.35773 4.5 9.49988C4.5 15.9999 12 21.9999 12 21.9999Z" stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" ></path><path d="M12 12.4999C13.6568 12.4999 15 11.1567 15 9.49988C15 7.84303 13.6568 6.49988 12 6.49988C10.3432 6.49988 9 7.84303 9 9.49988C9 11.1567 10.3432 12.4999 12 12.4999Z" stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" ></path></svg>
  229. <span className="text-ly-14">+New Shipping Address</span>
  230. {!isGuest &&
  231. <button onClick={changeAddress} className="absolute top-1/2 right-4 -translate-y-1/2" title="change address" type="button">
  232. <svg className="w-6 h-6" 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>
  233. </button>
  234. }
  235. </div>
  236. :
  237. <div className="w-full box-border px-4 bg-[url(/image/address-bg.webp)] bg-position-[0_-84%] bg-size-[100%_auto]">
  238. <div className="border-b-1 flex h-12.5 items-center justify-between">
  239. <p className="text-ly-14 font-medium">
  240. {firstName} {lastName}
  241. <span className="border-r h-3.5 mx-4"></span>
  242. {phoneNumber}
  243. </p>
  244. <button onClick={changeAddress} className="flex-none flex items-center text-ly-14 mr-1.5" type="button">
  245. Change
  246. <svg className="w-6 h-6" 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 9L12 15L18 9"></path></svg>
  247. </button>
  248. </div>
  249. <div className="flex items-center h-14">
  250. <svg className="w-6 h-6 mr-3" 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">
  251. <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>
  252. <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>
  253. <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>
  254. </svg>
  255. <span className="text-ly-14 leading-ly-20">
  256. {streetAddress},
  257. {city},
  258. {stateProvince},
  259. {postCode},
  260. {selectedCountryCode}
  261. </span>
  262. </div>
  263. </div>
  264. }
  265. <LyDropDown
  266. showDropDown={showDropDown}
  267. onClose={closeMyaddressList}
  268. >
  269. <div className={clsx("w-full p-3 text-ly-13 font-medium",noAddress && 'hidden')} key={'edit'} onClick={() => {selectAddress('edit')}}>
  270. Edit Address
  271. </div>
  272. {customerAddressList.map((item) => {
  273. return (
  274. <div className="w-full p-3" key={item.id} onClick={() => selectAddress(item)}>
  275. <p className="text-ly-13 leading-4">
  276. {item.firstName} {item.lastName},
  277. {item.address},
  278. {item.city},
  279. {item.state},
  280. {item.postcode},
  281. {item.country},
  282. {item.phone}
  283. </p>
  284. </div>
  285. );
  286. })}
  287. </LyDropDown>
  288. </div>
  289. <div className={clsx("box-border w-full mt-4",{
  290. "hidden": (!showFormField && !noAddress) || billingSameAsShipping
  291. })}
  292. >
  293. <div className={clsx("w-full",{
  294. "hidden": !isGuest
  295. })}>
  296. <label className="text-ly-16 block mb-1 font-semibold">
  297. Email *
  298. </label>
  299. <InputText
  300. type="email"
  301. placeholder="Enter your email address"
  302. {...register("billingEmail", {
  303. required: !billingSameAsShipping ? "Email is required" : false,
  304. pattern: {
  305. value: EMAIL_REGEX,
  306. message: "Please enter a valid email address",
  307. }
  308. })}
  309. error={errors.billingEmail && errors.billingEmail.message as string}
  310. />
  311. </div>
  312. <div className="w-full mt-4">
  313. <label className="text-ly-16 block mb-1 font-semibold">
  314. Name *
  315. </label>
  316. <div className="w-full flex justify-between">
  317. <div className="w-80.5 flex-none">
  318. <InputText
  319. type="text"
  320. placeholder="First name"
  321. {...register("billingFirstName", {
  322. required: !billingSameAsShipping ? "First name is required" : false,
  323. pattern: {
  324. value: IS_VALID_INPUT,
  325. message: "Invalid First Name",
  326. }
  327. })}
  328. error={errors.billingFirstName && errors.billingFirstName.message as string}
  329. />
  330. </div>
  331. <div className="w-80.5 flex-none">
  332. <InputText
  333. type="text"
  334. placeholder="Last name"
  335. {...register("billingLastName", {
  336. required: !billingSameAsShipping ? "Last name is required" : false,
  337. pattern: {
  338. value: IS_VALID_INPUT,
  339. message: "Invalid Last Name",
  340. }
  341. })}
  342. error={errors.billingLastName && errors.billingLastName.message as string}
  343. />
  344. </div>
  345. </div>
  346. </div>
  347. <div className="w-full mt-4">
  348. <label className="text-ly-16 block mb-1 font-semibold">Address *</label>
  349. <InputText
  350. type="text"
  351. placeholder="Please Input Your Detailed Address With Apt. No"
  352. {...register("billingAddress", {
  353. required: !billingSameAsShipping ? "Address is required" : false,
  354. })}
  355. error={errors.billingAddress && errors.billingAddress.message as string}
  356. />
  357. </div>
  358. <div className="w-full mt-4">
  359. <label className="text-ly-16 block mb-1 font-semibold">Country and State/Province *</label>
  360. <div className="w-full">
  361. <div className="w-full flex justify-between">
  362. <div className="w-80.5 flex-none">
  363. <Select placeholder="Country/Region"
  364. {...register("billingCountry", {
  365. required: !billingSameAsShipping ? "Country/Region field is required" : false,
  366. onChange: () => {
  367. setValue('billingState','',{
  368. shouldDirty: true,
  369. shouldValidate: true,
  370. });
  371. setValue('billingCity','',{
  372. shouldDirty: true,
  373. shouldValidate: true,
  374. });
  375. }
  376. })}
  377. options={countriesOptions}
  378. error={errors.billingCountry && errors.billingCountry.message as string}
  379. />
  380. </div>
  381. <div className="w-80.5 flex-none relative">
  382. {statesLoading && <LoadingSpinner className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />}
  383. {statesOptions.length > 0 ?
  384. (
  385. <Select placeholder="State/Province"
  386. {...register("billingState", {
  387. required: !billingSameAsShipping ? "State/Province field is required" : false
  388. })}
  389. options={statesOptions}
  390. error={errors.billingState && errors.billingState.message as string}
  391. />
  392. )
  393. : (
  394. <InputText
  395. type="text"
  396. placeholder="State/Province"
  397. {...register("billingState", {
  398. required: !billingSameAsShipping ? "State/Province field is required" : false
  399. })}
  400. error={errors.billingState && errors.billingState.message as string}
  401. />
  402. )}
  403. </div>
  404. </div>
  405. <div className="w-full flex justify-between mt-4">
  406. <div className="w-80.5 flex-none">
  407. <InputText
  408. type="text"
  409. placeholder="Zip/Postal Code"
  410. {...register("billingPostcode", {
  411. required: !billingSameAsShipping ? "Postcode field is required" : false,
  412. pattern: {
  413. value: IS_VALID_INPUT,
  414. message: "Invalid Postcode",
  415. },
  416. })}
  417. error={errors.billingPostcode&& errors.billingPostcode.message as string}
  418. />
  419. </div>
  420. <div className="w-80.5 flex-none">
  421. <InputText
  422. type="text"
  423. placeholder="City"
  424. {...register("billingCity", {
  425. required: !billingSameAsShipping ? "City field is required" : false,
  426. pattern: {
  427. value: IS_VALID_INPUT,
  428. message: "Invalid City",
  429. },
  430. })}
  431. error={errors.billingCity && errors.billingCity.message as string}
  432. />
  433. </div>
  434. </div>
  435. </div>
  436. </div>
  437. <div className="w-full mt-4">
  438. <label className="text-ly-16 block mb-1 font-semibold">Phone number *</label>
  439. <Controller
  440. name="billingPhoneNumber"
  441. control={control}
  442. rules={{
  443. required: !billingSameAsShipping ? "Phone Number is required" : false,
  444. pattern: {
  445. value: IS_VALID_FULL_PHONE,
  446. message: "Invalid Phone Number",
  447. }
  448. }}
  449. render={({ field, fieldState }) => {
  450. return (
  451. <PhoneNumberInput
  452. placeholder="Please Input Your Phone Number"
  453. value={field.value}
  454. onChange={field.onChange}
  455. onBlur={field.onBlur}
  456. name={field.name}
  457. ref={field.ref}
  458. error={fieldState.error?.message}
  459. countryCode={selectedCountryCode}
  460. hasUserSelectedPhoneCode={hasUserSelectedPhoneCode}
  461. onHasUserSelectedPhoneCode={() => setHasUserSelectedPhoneCode(true)}
  462. />
  463. );
  464. }}
  465. />
  466. </div>
  467. </div>
  468. </section>
  469. </>);
  470. }