ShippingAddressCheckout.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. "use client";
  2. import {useMemo,useEffect, useState, useCallback} from "react";
  3. import clsx from "clsx";
  4. import { useQuery } from "@apollo/client/react";
  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 { EMAIL_REGEX, IS_VALID_INPUT, IS_VALID_FULL_PHONE, IS_VALID_PHONECODE } from "@utils/constants";
  16. import type { CustomerAddressItem } from "@/types/customer/type";
  17. import { useCountries } from "@utils/hooks/useConfig";
  18. import InputText from "@/components/theme/ui/InputText";
  19. import Select from "@/components/theme/ui/Select";
  20. import PhoneNumberInput from "@/components/theme/ui/PhoneNumberInput/PhoneNumberInput";
  21. import { LoadingSpinner } from "@components/common/LoadingSpinner";
  22. import {LyDropDown} from "@/components/theme/ui/LyDropDown";
  23. export default function ShippingAddressCheckout({
  24. noAddress,
  25. isGuest,
  26. showFormField,
  27. onShowFormFieldChange,
  28. customerAddressList,
  29. onEmailChange
  30. }: {
  31. noAddress: boolean;
  32. isGuest: boolean;
  33. showFormField: boolean;
  34. onShowFormFieldChange: (e: boolean) => void;
  35. customerAddressList: CustomerAddressItem[];
  36. onEmailChange: (value: string) => void;
  37. }) {
  38. const countries = useCountries();
  39. const {
  40. getValues,
  41. setValue,
  42. register,
  43. formState: { errors },
  44. control,
  45. } = useFormContext() // retrieve all hook methods
  46. const countriesOptions = useMemo(() => {
  47. return countries.map((country) => ({
  48. value: country.code,
  49. label: country.name,
  50. id: String(country._id),
  51. }))
  52. },[countries]);
  53. const [
  54. selectedCountryCode,
  55. firstName,
  56. lastName,
  57. streetAddress,
  58. stateProvince,
  59. postCode,
  60. city,
  61. phoneNumber
  62. ] = useWatch({
  63. control,
  64. name: [
  65. 'shippingCountry',
  66. 'shippingFirstName',
  67. 'shippingLastName',
  68. 'shippingAddress',
  69. 'shippingState',
  70. 'shippingPostcode',
  71. 'shippingCity',
  72. 'shippingPhoneNumber'
  73. ]
  74. });
  75. const selectedCountry = useMemo(() => {
  76. return countries.find( (country) => country.code === selectedCountryCode)
  77. }, [countries, selectedCountryCode]);
  78. const { data: statesData, loading: statesLoading } = useQuery(GET_COUNTRY_STATES, {
  79. variables: {
  80. countryId: selectedCountry?._id
  81. },
  82. skip: !selectedCountryCode,
  83. });
  84. let statesOptions: { value: string; label: string, id: string }[] = [];
  85. if(statesData) {
  86. statesOptions = statesData.countryStates.map((state) => ({
  87. value: state.code,
  88. label: state.defaultName,
  89. id: String(state._id),
  90. }));
  91. }
  92. const [showDropDown, setShowDropDown] = useState(false);
  93. // 是否用户手动选择过区号
  94. const [hasUserSelectedPhoneCode, setHasUserSelectedPhoneCode] = useState(false);
  95. // 国家变更后 state字段的值需要清空
  96. // 选择国家时同步电话区号
  97. useEffect(() => {
  98. const preShippingPhoneNum = getValues('shippingPhoneNumber');
  99. const regPhoneCode = IS_VALID_PHONECODE;
  100. if(preShippingPhoneNum && !hasUserSelectedPhoneCode) {
  101. const newPhoneCode = getPhoneCodeBycountryCode(selectedCountryCode);
  102. const codePart = regPhoneCode.exec(preShippingPhoneNum);
  103. let phone = preShippingPhoneNum;
  104. if(codePart) {
  105. phone = preShippingPhoneNum.replace(codePart[0], '');
  106. }
  107. if(codePart && newPhoneCode !== codePart[0].trim()) {
  108. return;
  109. }
  110. setValue('shippingPhoneNumber',formatFinalPhoneValue(newPhoneCode, phone))
  111. }
  112. },[selectedCountryCode,hasUserSelectedPhoneCode,getValues,setValue]);
  113. const changeAddress = () => {
  114. if(isGuest) {
  115. if(!showFormField) onShowFormFieldChange(true);
  116. } else {
  117. showMyAddressList();
  118. }
  119. };
  120. const showMyAddressList = () => {
  121. setShowDropDown(true);
  122. };
  123. const closeMyaddressList = useCallback(() => {
  124. setShowDropDown(false);
  125. },[]);
  126. // 从地址列表选择地址
  127. const selectAddress = (param: CustomerAddressItem | 'edit') => {
  128. if(param === 'edit') {
  129. if(!showFormField) onShowFormFieldChange(true);
  130. } else {
  131. setValue('shippingFirstName',param.firstName);
  132. setValue('shippingLastName',param.lastName);
  133. setValue('shippingAddress',param.address);
  134. setValue('shippingCountry',param.country);
  135. setValue('shippingState',param.state);
  136. setValue('shippingPostcode',param.postcode);
  137. setValue('shippingCity',param.city);
  138. setValue('shippingPhoneNumber',param.phone);
  139. }
  140. closeMyaddressList();
  141. };
  142. /**
  143. * 游客:
  144. * 1. 购物车没地址 -- 进来显示填写新地址,地址表单;填完地址,保存,关闭弹窗,购物车地址更新;再次进来显示填写的地址,隐藏表单;如果想修改,就点击修改,显示地址表单;
  145. * 2. 购物车有地址 -- 进来显示已有的地址,隐藏表单;如果想修改,就点击修改,显示地址表单;
  146. *
  147. * 登录用户:
  148. * 1. 购物车没地址 -- 进来显示填写新地址,地址表单;
  149. * 如果想选已有地址,点击箭头,弹出地址列表,选择地址,选完地址更新到表单,关闭地址列表(打上选中状态);如果地址列表没数据,显示没有地址供选择;
  150. * 如果不想选地址,就填写地址表单。
  151. * 保存地址,关闭弹窗;
  152. * 再次进来,显示购物车中的地址;
  153. *
  154. * 2. 购物车有地址 -- 进来显示购物车中的地址;
  155. * 如果想修改地址,点击箭头,出现地址列表
  156. *
  157. * 地址列表数据直接在 父组件获取
  158. *
  159. *
  160. * 表单显示逻辑:
  161. * 游客 -- noAddress 为true 或者 change 时显示
  162. * 登录用户 -- noAddress 为true 或者 change 时显示
  163. */
  164. return (<>
  165. <h3 className="text-ly-20 font-medium mb-4">Shipping Address</h3>
  166. <div className="w-full">
  167. {noAddress ?
  168. <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]">
  169. <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>
  170. <span className="text-ly-14">+New Shipping Address</span>
  171. {!isGuest &&
  172. <button onClick={changeAddress} className="absolute top-1/2 right-4 -translate-y-1/2" title="change address" type="button">
  173. <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>
  174. </button>
  175. }
  176. </div>
  177. :
  178. <div className="w-full box-border px-4 bg-[url(/image/address-bg.webp)] bg-position-[0_-84%] bg-size-[100%_auto]">
  179. <div className="border-b-1 flex h-12.5 items-center justify-between">
  180. <p className="text-ly-14 font-medium">
  181. {firstName} {lastName}
  182. <span className="border-r h-3.5 mx-4"></span>
  183. {phoneNumber}
  184. </p>
  185. <button onClick={changeAddress} className="flex-none flex items-center text-ly-14 mr-1.5" type="button">
  186. Change
  187. <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>
  188. </button>
  189. </div>
  190. <div className="flex items-center h-14">
  191. <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"><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>
  192. <span className="text-ly-14 leading-ly-20">
  193. {streetAddress},
  194. {city},
  195. {stateProvince},
  196. {postCode},
  197. {selectedCountryCode}
  198. </span>
  199. </div>
  200. </div>
  201. }
  202. <LyDropDown
  203. showDropDown={showDropDown}
  204. onClose={closeMyaddressList}
  205. >
  206. <div className={clsx("w-full p-3 text-ly-13 font-medium", noAddress && 'hidden')} key={'edit'} onClick={() => {selectAddress('edit')}}>
  207. Edit Address
  208. </div>
  209. {customerAddressList.map((item) => {
  210. return (
  211. <div className="w-full p-3" key={item.id} onClick={() => selectAddress(item)}>
  212. <p className="text-ly-13 leading-4">
  213. {item.firstName} {item.lastName},
  214. {item.address},
  215. {item.city},
  216. {item.state},
  217. {item.postcode},
  218. {item.country},
  219. {item.phone}
  220. </p>
  221. </div>
  222. );
  223. })}
  224. </LyDropDown>
  225. </div>
  226. {/**
  227. * 登录用户: noAddres=false and showFormField = false 时 隐藏; noAddres=true 显示
  228. * 游客: noAddres=false and showFormField = false 时 隐藏; noAddres=true 显示
  229. */}
  230. <div className={clsx("box-border w-full mt-4", {
  231. "hidden": !showFormField && !noAddress
  232. })}>
  233. <div className="w-full mt-4">
  234. <label className="text-ly-16 block mb-1 font-semibold">
  235. Name *
  236. </label>
  237. <div className="w-full flex justify-between gap-3">
  238. <div className="flex-auto">
  239. <InputText
  240. type="text"
  241. placeholder="First name"
  242. {...register("shippingFirstName", {
  243. required: "First name is required",
  244. pattern: {
  245. value: IS_VALID_INPUT,
  246. message: "Invalid First Name",
  247. }
  248. })}
  249. error={errors.shippingFirstName && errors.shippingFirstName.message as string}
  250. />
  251. </div>
  252. <div className="flex-auto">
  253. <InputText
  254. type="text"
  255. placeholder="Last name"
  256. {...register("shippingLastName", {
  257. required: "Last name is required",
  258. pattern: {
  259. value: IS_VALID_INPUT,
  260. message: "Invalid Last Name",
  261. }
  262. })}
  263. error={errors.shippingLastName && errors.shippingLastName.message as string}
  264. />
  265. </div>
  266. </div>
  267. </div>
  268. <div className={clsx("w-full mt-4",{
  269. "hidden": !isGuest
  270. })}>
  271. <label className="text-ly-16 block mb-1 font-semibold">
  272. Email *
  273. </label>
  274. <InputText
  275. type="email"
  276. placeholder="Enter your email address"
  277. {...register("shippingEmail", {
  278. required: "Email is required",
  279. pattern: {
  280. value: EMAIL_REGEX,
  281. message: "Please enter a valid email address",
  282. },
  283. onChange: (e) => {
  284. onEmailChange(e.target.value);
  285. // console.log(e.target.value);
  286. // if(isGuest) {
  287. // verifyEmailIsRegistered(e.target.value);
  288. // }
  289. }
  290. })}
  291. error={errors.shippingEmail && errors.shippingEmail.message as string}
  292. />
  293. </div>
  294. <div className="w-full mt-4">
  295. <label className="text-ly-16 block mb-1 font-semibold">Address *</label>
  296. <InputText
  297. type="text"
  298. placeholder="Please Input Your Detailed Address With Apt. No"
  299. {...register("shippingAddress", {
  300. required: "Address is required",
  301. })}
  302. error={errors.shippingAddress && errors.shippingAddress.message as string}
  303. />
  304. </div>
  305. <div className="w-full mt-4">
  306. <label className="text-ly-16 block mb-1 font-semibold">Country and State/Province *</label>
  307. <div className="w-full">
  308. <div className="w-full flex justify-between gap-3">
  309. <div className="flex-auto w-1/2">
  310. <Select placeholder="Country/Region"
  311. {...register("shippingCountry", {
  312. required: "Country/Region field is required",
  313. onChange: () => {
  314. // 清空 state/city
  315. setValue('shippingState','',{
  316. shouldDirty: true,
  317. shouldValidate: true,
  318. });
  319. setValue('shippingCity','',{
  320. shouldDirty: true,
  321. shouldValidate: true,
  322. });
  323. }
  324. })}
  325. options={countriesOptions}
  326. error={errors.shippingCountry && errors.shippingCountry.message as string}
  327. />
  328. </div>
  329. <div className="flex-auto relative w-1/2">
  330. {statesLoading && <LoadingSpinner className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />}
  331. {statesOptions.length > 0 ?
  332. (
  333. <Select placeholder="State/Province"
  334. {...register("shippingState", {
  335. required: "State/Province field is required"
  336. })}
  337. options={statesOptions}
  338. error={errors.shippingState && errors.shippingState.message as string}
  339. />
  340. )
  341. : (
  342. <InputText
  343. type="text"
  344. placeholder="State/Province"
  345. {...register("shippingState", {
  346. required: "State/Province field is required"
  347. })}
  348. error={errors.shippingState && errors.shippingState.message as string}
  349. />
  350. )}
  351. </div>
  352. </div>
  353. <div className="w-full flex justify-between mt-4 gap-3">
  354. <div className="flex-auto">
  355. <InputText
  356. type="text"
  357. placeholder="Zip/Postal Code"
  358. {...register("shippingPostcode", {
  359. required: "Postcode field is required",
  360. pattern: {
  361. value: IS_VALID_INPUT,
  362. message: "Invalid Postcode",
  363. },
  364. })}
  365. error={errors.shippingPostcode&& errors.shippingPostcode.message as string}
  366. />
  367. </div>
  368. <div className="flex-auto">
  369. <InputText
  370. type="text"
  371. placeholder="City"
  372. {...register("shippingCity", {
  373. required: "City field is required",
  374. pattern: {
  375. value: IS_VALID_INPUT,
  376. message: "Invalid City",
  377. },
  378. })}
  379. error={errors.shippingCity && errors.shippingCity.message as string}
  380. />
  381. </div>
  382. </div>
  383. </div>
  384. </div>
  385. <div className="w-full mt-4">
  386. <label className="text-ly-16 block mb-1 font-semibold">Phone number *</label>
  387. <Controller
  388. name="shippingPhoneNumber"
  389. control={control}
  390. rules={{
  391. required: "Phone Number is required",
  392. pattern: {
  393. value: IS_VALID_FULL_PHONE,
  394. message: "Invalid Phone Number",
  395. }
  396. }}
  397. render={({ field, fieldState }) => {
  398. return (
  399. <PhoneNumberInput
  400. placeholder="Please Input Your Phone Number"
  401. value={field.value}
  402. onChange={field.onChange}
  403. onBlur={field.onBlur}
  404. name={field.name}
  405. ref={field.ref}
  406. error={fieldState.error?.message}
  407. countryCode={selectedCountryCode}
  408. hasUserSelectedPhoneCode={hasUserSelectedPhoneCode}
  409. onHasUserSelectedPhoneCode={() => setHasUserSelectedPhoneCode(true)}
  410. />
  411. );
  412. }}
  413. />
  414. </div>
  415. </div>
  416. </>);
  417. };