BillingAddressCheckout.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  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 showMyAddressList = () => {
  177. setShowDropDown(true);
  178. };
  179. const closeMyaddressList = useCallback(() => {
  180. setShowDropDown(false);
  181. },[]);
  182. // 从地址列表选择地址
  183. const selectAddress = (param: CustomerAddressItem | 'edit') => {
  184. if(param === 'edit') {
  185. if(!showFormField) onShowFormFieldChange(true);
  186. } else {
  187. setValue('billingFirstName',param.firstName);
  188. setValue('billingLastName',param.lastName);
  189. setValue('billingAddress',param.address);
  190. setValue('billingCountry',param.country);
  191. setValue('billingState',param.state);
  192. setValue('billingPostcode',param.postcode);
  193. setValue('billingCity',param.city);
  194. setValue('billingPhoneNumber',param.phone);
  195. }
  196. closeMyaddressList();
  197. };
  198. return (<>
  199. <section className="w-full mt-4 pb-8">
  200. <div className="box-border w-full p-3 bg-ly-lightgray">
  201. <div className="flex justify-between items-center">
  202. <span className="text-ly-12 flex-none">Billing Address</span>
  203. <label className="group relative w-9 h-5.5 flex-none">
  204. <input className="absolute w-full h-full top-0 left-0 opacity-0 z-10"
  205. type="checkbox"
  206. {...register("billingSameAsShipping")}
  207. />
  208. <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]">
  209. <span className="transition-all block w-4.5 h-4.5 rounded-full bg-white group-has-[:checked]:ml-3.5"></span>
  210. </span>
  211. </label>
  212. </div>
  213. <p className="text-ly-12 mt-1.25">Same As Shipping Address</p>
  214. </div>
  215. <div className={clsx("w-full mt-4",{
  216. "hidden": billingSameAsShipping,
  217. })}>
  218. {noAddress ?
  219. <div onClick={showMyAddressList}
  220. 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]"
  221. >
  222. <span className="text-ly-12">+New Shipping Address</span>
  223. {!isGuest &&
  224. <button className="flex-none" title="change address" type="button">
  225. <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>
  226. </button>
  227. }
  228. </div>
  229. :
  230. <div onClick={showMyAddressList}
  231. className="w-full box-border px-3.75 bg-[url(/image/address-bg.webp)] bg-position-[0_-84%] bg-size-[100%_auto]"
  232. >
  233. <div className="border-b-1 flex h-11.25 items-center justify-between">
  234. <p className="text-ly-13 font-medium">
  235. {firstName} {lastName}
  236. <span className="border-r border-ly-gray h-3.5 mx-2"></span>
  237. {phoneNumber}
  238. </p>
  239. {!isGuest &&
  240. <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>
  241. }
  242. </div>
  243. <div className="flex items-center h-10">
  244. <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">
  245. <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>
  246. <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>
  247. <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>
  248. </svg>
  249. <span className="text-ly-12 ml-2.5">
  250. {streetAddress},
  251. {city},
  252. {stateProvince},
  253. {postCode},
  254. {selectedCountryCode}
  255. </span>
  256. </div>
  257. </div>
  258. }
  259. <LyDropDown
  260. showDropDown={showDropDown}
  261. onClose={closeMyaddressList}
  262. >
  263. <div className="w-full p-3 text-ly-13 font-medium" key={'edit'} onClick={() => {selectAddress('edit')}}>
  264. Edit Address
  265. </div>
  266. {customerAddressList.map((item) => {
  267. return (
  268. <div className="w-full p-3" key={item.id} onClick={() => selectAddress(item)}>
  269. <p className="text-ly-13 leading-4">
  270. {item.firstName} {item.lastName},
  271. {item.address},
  272. {item.city},
  273. {item.state},
  274. {item.postcode},
  275. {item.country},
  276. {item.phone}
  277. </p>
  278. </div>
  279. );
  280. })}
  281. </LyDropDown>
  282. </div>
  283. <div className={clsx("box-border w-full mt-4",{
  284. "hidden": (!showFormField && !noAddress) || billingSameAsShipping
  285. })}
  286. >
  287. <div className={clsx("w-full",{
  288. "hidden": !isGuest
  289. })}>
  290. <label className="text-ly-12 block mb-4 font-semibold">
  291. Email *
  292. </label>
  293. <InputText
  294. type="email"
  295. placeholder="Enter your email address"
  296. {...register("billingEmail", {
  297. required: !billingSameAsShipping ? "Email is required" : false,
  298. pattern: {
  299. value: EMAIL_REGEX,
  300. message: "Please enter a valid email address",
  301. }
  302. })}
  303. error={errors.billingEmail && errors.billingEmail.message as string}
  304. />
  305. </div>
  306. <div className="w-full mt-4">
  307. <label className="text-ly-12 block mb-4 font-semibold">
  308. Name *
  309. </label>
  310. <div className="w-full flex justify-between">
  311. <div className="w-41.25 flex-none">
  312. <InputText
  313. type="text"
  314. placeholder="First name"
  315. {...register("billingFirstName", {
  316. required: !billingSameAsShipping ? "First name is required" : false,
  317. pattern: {
  318. value: IS_VALID_INPUT,
  319. message: "Invalid First Name",
  320. }
  321. })}
  322. error={errors.billingFirstName && errors.billingFirstName.message as string}
  323. />
  324. </div>
  325. <div className="w-41.25 flex-none">
  326. <InputText
  327. type="text"
  328. placeholder="Last name"
  329. {...register("billingLastName", {
  330. required: !billingSameAsShipping ? "Last name is required" : false,
  331. pattern: {
  332. value: IS_VALID_INPUT,
  333. message: "Invalid Last Name",
  334. }
  335. })}
  336. error={errors.billingLastName && errors.billingLastName.message as string}
  337. />
  338. </div>
  339. </div>
  340. </div>
  341. <div className="w-full mt-4">
  342. <label className="text-ly-12 block mb-4 font-semibold">Address *</label>
  343. <InputText
  344. type="text"
  345. placeholder="Please Input Your Detailed Address With Apt. No"
  346. {...register("billingAddress", {
  347. required: !billingSameAsShipping ? "Address is required" : false,
  348. })}
  349. error={errors.billingAddress && errors.billingAddress.message as string}
  350. />
  351. </div>
  352. <div className="w-full mt-4">
  353. <label className="text-ly-12 block mb-4 font-semibold">Country and State/Province *</label>
  354. <div className="w-full">
  355. <div className="w-full flex justify-between">
  356. <div className="w-41.25 flex-none">
  357. <Select placeholder="Country/Region"
  358. {...register("billingCountry", {
  359. required: !billingSameAsShipping ? "Country/Region field is required" : false,
  360. onChange: () => {
  361. setValue('billingState','',{
  362. shouldDirty: true,
  363. shouldValidate: true,
  364. });
  365. setValue('billingCity','',{
  366. shouldDirty: true,
  367. shouldValidate: true,
  368. });
  369. }
  370. })}
  371. options={countriesOptions}
  372. error={errors.billingCountry && errors.billingCountry.message as string}
  373. />
  374. </div>
  375. <div className="w-41.25 flex-none relative">
  376. {statesLoading && <LoadingSpinner className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />}
  377. {statesOptions.length > 0 ?
  378. (
  379. <Select placeholder="State/Province"
  380. {...register("billingState", {
  381. required: !billingSameAsShipping ? "State/Province field is required" : false
  382. })}
  383. options={statesOptions}
  384. error={errors.billingState && errors.billingState.message as string}
  385. />
  386. )
  387. : (
  388. <InputText
  389. type="text"
  390. placeholder="State/Province"
  391. {...register("billingState", {
  392. required: !billingSameAsShipping ? "State/Province field is required" : false
  393. })}
  394. error={errors.billingState && errors.billingState.message as string}
  395. />
  396. )}
  397. </div>
  398. </div>
  399. <div className="w-full flex justify-between mt-4">
  400. <div className="w-41.25 flex-none">
  401. <InputText
  402. type="text"
  403. placeholder="Zip/Postal Code"
  404. {...register("billingPostcode", {
  405. required: !billingSameAsShipping ? "Postcode field is required" : false,
  406. pattern: {
  407. value: IS_VALID_INPUT,
  408. message: "Invalid Postcode",
  409. },
  410. })}
  411. error={errors.billingPostcode&& errors.billingPostcode.message as string}
  412. />
  413. </div>
  414. <div className="w-41.25 flex-none">
  415. <InputText
  416. type="text"
  417. placeholder="City"
  418. {...register("billingCity", {
  419. required: !billingSameAsShipping ? "City field is required" : false,
  420. pattern: {
  421. value: IS_VALID_INPUT,
  422. message: "Invalid City",
  423. },
  424. })}
  425. error={errors.billingCity && errors.billingCity.message as string}
  426. />
  427. </div>
  428. </div>
  429. </div>
  430. </div>
  431. <div className="w-full mt-4">
  432. <label className="text-ly-12 block mb-4 font-semibold">Phone number *</label>
  433. <Controller
  434. name="billingPhoneNumber"
  435. control={control}
  436. rules={{
  437. required: !billingSameAsShipping ? "Phone Number is required" : false,
  438. pattern: {
  439. value: IS_VALID_FULL_PHONE,
  440. message: "Invalid Phone Number",
  441. }
  442. }}
  443. render={({ field, fieldState }) => {
  444. return (
  445. <PhoneNumberInput
  446. placeholder="Please Input Your Phone Number"
  447. value={field.value}
  448. onChange={field.onChange}
  449. onBlur={field.onBlur}
  450. name={field.name}
  451. ref={field.ref}
  452. error={fieldState.error?.message}
  453. countryCode={selectedCountryCode}
  454. hasUserSelectedPhoneCode={hasUserSelectedPhoneCode}
  455. onHasUserSelectedPhoneCode={() => setHasUserSelectedPhoneCode(true)}
  456. />
  457. );
  458. }}
  459. />
  460. </div>
  461. </div>
  462. </section>
  463. </>);
  464. }