ShippingAddressCheckout.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  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 { CustomerAddressItem } from "@/types/customer/type";
  17. import { useConfig } 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} = useConfig();
  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 showMyAddressList = () => {
  114. setShowDropDown(true);
  115. };
  116. const closeMyaddressList = useCallback(() => {
  117. setShowDropDown(false);
  118. },[]);
  119. // 从地址列表选择地址
  120. const selectAddress = (param: CustomerAddressItem | 'edit') => {
  121. if(param === 'edit') {
  122. if(!showFormField) onShowFormFieldChange(true);
  123. } else {
  124. setValue('shippingFirstName',param.firstName);
  125. setValue('shippingLastName',param.lastName);
  126. setValue('shippingAddress',param.address);
  127. setValue('shippingCountry',param.country);
  128. setValue('shippingState',param.state);
  129. setValue('shippingPostcode',param.postcode);
  130. setValue('shippingCity',param.city);
  131. setValue('shippingPhoneNumber',param.phone);
  132. }
  133. closeMyaddressList();
  134. };
  135. /**
  136. * 游客:
  137. * 1. 购物车没地址 -- 进来显示填写新地址,地址表单;填完地址,保存,关闭弹窗,购物车地址更新;再次进来显示填写的地址,隐藏表单;如果想修改,就点击修改,显示地址表单;
  138. * 2. 购物车有地址 -- 进来显示已有的地址,隐藏表单;如果想修改,就点击修改,显示地址表单;
  139. *
  140. * 登录用户:
  141. * 1. 购物车没地址 -- 进来显示填写新地址,地址表单;
  142. * 如果想选已有地址,点击箭头,弹出地址列表,选择地址,选完地址更新到表单,关闭地址列表(打上选中状态);如果地址列表没数据,显示没有地址供选择;
  143. * 如果不想选地址,就填写地址表单。
  144. * 保存地址,关闭弹窗;
  145. * 再次进来,显示购物车中的地址;
  146. *
  147. * 2. 购物车有地址 -- 进来显示购物车中的地址;
  148. * 如果想修改地址,点击箭头,出现地址列表
  149. *
  150. * 地址列表数据直接在 父组件获取
  151. */
  152. return (<>
  153. <div className="w-full">
  154. {noAddress ?
  155. <div onClick={showMyAddressList}
  156. 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]"
  157. >
  158. <span className="text-ly-12">+New Shipping Address</span>
  159. {!isGuest &&
  160. <button className="flex-none" title="change address" type="button">
  161. <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>
  162. </button>
  163. }
  164. </div>
  165. :
  166. <div onClick={showMyAddressList}
  167. className="w-full box-border px-3.75 bg-[url(/image/address-bg.webp)] bg-position-[0_-84%] bg-size-[100%_auto]"
  168. >
  169. <div className="border-b-1 flex h-11.25 items-center justify-between">
  170. <p className="text-ly-13 font-medium">
  171. {firstName} {lastName}
  172. <span className="border-r border-ly-gray h-3.5 mx-2"></span>
  173. {phoneNumber}
  174. </p>
  175. {!isGuest &&
  176. <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>
  177. }
  178. </div>
  179. <div className="flex items-center h-10">
  180. <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">
  181. <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>
  182. <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>
  183. <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>
  184. </svg>
  185. <span className="text-ly-12 ml-2.5">
  186. {streetAddress},
  187. {city},
  188. {stateProvince},
  189. {postCode},
  190. {selectedCountryCode}
  191. </span>
  192. </div>
  193. </div>
  194. }
  195. <LyDropDown
  196. showDropDown={showDropDown}
  197. onClose={closeMyaddressList}
  198. >
  199. <div className="w-full p-3 text-ly-13 font-medium" key={'edit'} onClick={() => {selectAddress('edit')}}>
  200. Edit Address
  201. </div>
  202. {customerAddressList.map((item) => {
  203. return (
  204. <div className="w-full p-3" key={item.id} onClick={() => selectAddress(item)}>
  205. <p className="text-ly-13 leading-4">
  206. {item.firstName} {item.lastName},
  207. {item.address},
  208. {item.city},
  209. {item.state},
  210. {item.postcode},
  211. {item.country},
  212. {item.phone}
  213. </p>
  214. </div>
  215. );
  216. })}
  217. </LyDropDown>
  218. </div>
  219. <p className="text-ly-12 leading-ly-20 mt-2 mb-2">
  220. Select a shipping address from your address book or enter a new address.
  221. </p>
  222. {/**
  223. * 登录用户: noAddres=false and showFormField = false 时 隐藏; noAddres=true 显示
  224. * 游客: noAddres=false and showFormField = false 时 隐藏; noAddres=true 显示
  225. */}
  226. <div className={clsx("box-border w-full", {
  227. "hidden": !showFormField && !noAddress
  228. })}>
  229. <div className={clsx("w-full",{
  230. "hidden": !isGuest
  231. })}>
  232. <label className="text-ly-12 block mb-4 font-semibold">
  233. Email *
  234. </label>
  235. <InputText
  236. type="email"
  237. placeholder="Enter your email address"
  238. {...register("shippingEmail", {
  239. required: "Email is required",
  240. pattern: {
  241. value: EMAIL_REGEX,
  242. message: "Please enter a valid email address",
  243. },
  244. onChange: (e) => {
  245. onEmailChange(e.target.value);
  246. // console.log(e.target.value);
  247. // if(isGuest) {
  248. // verifyEmailIsRegistered(e.target.value);
  249. // }
  250. }
  251. })}
  252. error={errors.shippingEmail && errors.shippingEmail.message as string}
  253. />
  254. </div>
  255. <div className="w-full mt-4">
  256. <label className="text-ly-12 block mb-4 font-semibold">
  257. Name *
  258. </label>
  259. <div className="w-full flex justify-between">
  260. <div className="w-41.25 flex-none">
  261. <InputText
  262. type="text"
  263. placeholder="First name"
  264. {...register("shippingFirstName", {
  265. required: "First name is required",
  266. pattern: {
  267. value: IS_VALID_INPUT,
  268. message: "Invalid First Name",
  269. }
  270. })}
  271. error={errors.shippingFirstName && errors.shippingFirstName.message as string}
  272. />
  273. </div>
  274. <div className="w-41.25 flex-none">
  275. <InputText
  276. type="text"
  277. placeholder="Last name"
  278. {...register("shippingLastName", {
  279. required: "Last name is required",
  280. pattern: {
  281. value: IS_VALID_INPUT,
  282. message: "Invalid Last Name",
  283. }
  284. })}
  285. error={errors.shippingLastName && errors.shippingLastName.message as string}
  286. />
  287. </div>
  288. </div>
  289. </div>
  290. <div className="w-full mt-4">
  291. <label className="text-ly-12 block mb-4 font-semibold">Address *</label>
  292. <InputText
  293. type="text"
  294. placeholder="Please Input Your Detailed Address With Apt. No"
  295. {...register("shippingAddress", {
  296. required: "Address is required",
  297. })}
  298. error={errors.shippingAddress && errors.shippingAddress.message as string}
  299. />
  300. </div>
  301. <div className="w-full mt-4">
  302. <label className="text-ly-12 block mb-4 font-semibold">Country and State/Province *</label>
  303. <div className="w-full">
  304. <div className="w-full flex justify-between">
  305. <div className="w-41.25 flex-none">
  306. <Select placeholder="Country/Region"
  307. {...register("shippingCountry", {
  308. required: "Country/Region field is required",
  309. onChange: () => {
  310. // 清空 state/city
  311. setValue('shippingState','',{
  312. shouldDirty: true,
  313. shouldValidate: true,
  314. });
  315. setValue('shippingCity','',{
  316. shouldDirty: true,
  317. shouldValidate: true,
  318. });
  319. }
  320. })}
  321. options={countriesOptions}
  322. error={errors.shippingCountry && errors.shippingCountry.message as string}
  323. />
  324. </div>
  325. <div className="w-41.25 flex-none relative">
  326. {statesLoading && <LoadingSpinner className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />}
  327. {statesOptions.length > 0 ?
  328. (
  329. <Select placeholder="State/Province"
  330. {...register("shippingState", {
  331. required: "State/Province field is required"
  332. })}
  333. options={statesOptions}
  334. error={errors.shippingState && errors.shippingState.message as string}
  335. />
  336. )
  337. : (
  338. <InputText
  339. type="text"
  340. placeholder="State/Province"
  341. {...register("shippingState", {
  342. required: "State/Province field is required"
  343. })}
  344. error={errors.shippingState && errors.shippingState.message as string}
  345. />
  346. )}
  347. </div>
  348. </div>
  349. <div className="w-full flex justify-between mt-4">
  350. <div className="w-41.25 flex-none">
  351. <InputText
  352. type="text"
  353. placeholder="Zip/Postal Code"
  354. {...register("shippingPostcode", {
  355. required: "Postcode field is required",
  356. pattern: {
  357. value: IS_VALID_INPUT,
  358. message: "Invalid Postcode",
  359. },
  360. })}
  361. error={errors.shippingPostcode&& errors.shippingPostcode.message as string}
  362. />
  363. </div>
  364. <div className="w-41.25 flex-none">
  365. <InputText
  366. type="text"
  367. placeholder="City"
  368. {...register("shippingCity", {
  369. required: "City field is required",
  370. pattern: {
  371. value: IS_VALID_INPUT,
  372. message: "Invalid City",
  373. },
  374. })}
  375. error={errors.shippingCity && errors.shippingCity.message as string}
  376. />
  377. </div>
  378. </div>
  379. </div>
  380. </div>
  381. <div className="w-full mt-4">
  382. <label className="text-ly-12 block mb-4 font-semibold">Phone number *</label>
  383. <Controller
  384. name="shippingPhoneNumber"
  385. control={control}
  386. rules={{
  387. required: "Phone Number is required",
  388. pattern: {
  389. value: IS_VALID_FULL_PHONE,
  390. message: "Invalid Phone Number",
  391. }
  392. }}
  393. render={({ field, fieldState }) => {
  394. return (
  395. <PhoneNumberInput
  396. placeholder="Please Input Your Phone Number"
  397. value={field.value}
  398. onChange={field.onChange}
  399. onBlur={field.onBlur}
  400. name={field.name}
  401. ref={field.ref}
  402. error={fieldState.error?.message}
  403. countryCode={selectedCountryCode}
  404. hasUserSelectedPhoneCode={hasUserSelectedPhoneCode}
  405. onHasUserSelectedPhoneCode={() => setHasUserSelectedPhoneCode(true)}
  406. />
  407. );
  408. }}
  409. />
  410. </div>
  411. </div>
  412. </>);
  413. };