OrderFormModal.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. import { useForm, Controller } from "react-hook-form";
  2. import { zodResolver } from "@hookform/resolvers/zod";
  3. import * as z from "zod";
  4. import { useTranslation } from "react-i18next";
  5. import type { OrderRequestDTO } from "@/types/OrderType";
  6. import { useOrder } from "@/hooks/useOrder";
  7. import { useProfile } from "@/hooks/useProfile";
  8. import { getAllCountries } from "@/utils/countries";
  9. import Select from "react-select";
  10. import { toast } from "sonner";
  11. import { useNavigate } from "react-router-dom";
  12. import {
  13. Dialog,
  14. DialogContent,
  15. DialogHeader,
  16. DialogTitle,
  17. } from "@/components/ui/dialog";
  18. import { Input } from "@/components/ui/input";
  19. import { Button } from "@/components/ui/button";
  20. import { Separator } from "@/components/ui/separator";
  21. import { MapPin, Flag, PackageCheck, Loader2, User } from "lucide-react";
  22. interface OrderFormModalProps {
  23. isOpen: boolean;
  24. onClose: () => void;
  25. }
  26. const formatOptionLabel = ({ label, cca2 }: any) => (
  27. <div className="flex items-center gap-2">
  28. <img
  29. src={`https://flagcdn.com/w20/${cca2}.png`}
  30. srcSet={`https://flagcdn.com/w40/${cca2}.png 2x`}
  31. alt="flag"
  32. className="h-auto w-5 shadow-sm"
  33. />
  34. <span>{label}</span>
  35. </div>
  36. );
  37. export const OrderFormModal = ({ isOpen, onClose }: OrderFormModalProps) => {
  38. const { t } = useTranslation();
  39. const { createOrder } = useOrder();
  40. const navigate = useNavigate();
  41. const { user, loading: isUserLoading } = useProfile();
  42. const countries = getAllCountries();
  43. const locationSchema = z.object({
  44. streetAddress: z.string().min(3, t("validation.streetMin", "Ulica jest za krótka")),
  45. postalCode: z.string().regex(/^\d{2}-\d{3}$/, t("validation.postalCode", "Niepoprawny kod")),
  46. city: z.string().min(2, t("validation.cityMin", "Miasto za krótkie")),
  47. country: z.string().length(3, t("validation.countryMin", "Wybierz kraj")),
  48. });
  49. const orderSchema = z.object({
  50. weight: z
  51. .number({ message: t("validation.positiveWeight", "Waga musi być dodatnia") })
  52. .positive(t("validation.positiveWeight", "Waga musi być dodatnia")),
  53. volume: z
  54. .number({ message: t("validation.positiveVolume", "Objętość musi być dodatnia") })
  55. .positive(t("validation.positiveVolume", "Objętość musi być dodatnia")),
  56. recipientFirstName: z.string().min(2, t("validation.required", "Wymagane")),
  57. recipientLastName: z.string().min(2, t("validation.required", "Wymagane")),
  58. recipientEmail: z.string().email(t("validation.email", "Niepoprawny email")),
  59. recipientPhone: z.string().min(9, t("validation.phone", "Niepoprawny telefon")),
  60. pickupLocation: locationSchema,
  61. deliveryLocation: locationSchema,
  62. });
  63. type OrderFormValues = z.infer<typeof orderSchema>;
  64. const {
  65. register,
  66. handleSubmit,
  67. reset,
  68. control,
  69. formState: { errors, isSubmitting },
  70. } = useForm<OrderFormValues>({
  71. resolver: zodResolver(orderSchema),
  72. defaultValues: {
  73. pickupLocation: { country: "POL" },
  74. deliveryLocation: { country: "POL" },
  75. },
  76. });
  77. const onSubmit = async (data: OrderFormValues) => {
  78. if (!user || !user.id) {
  79. toast.error(
  80. t(
  81. "errors.userNotFound",
  82. "Nie można zidentyfikować użytkownika. Spróbuj zalogować się ponownie.",
  83. ),
  84. );
  85. return;
  86. }
  87. const payload: OrderRequestDTO = {
  88. ...data,
  89. customerId: user.id,
  90. };
  91. try {
  92. const createdOrder = await createOrder(payload);
  93. if (!createdOrder || !createdOrder.id) {
  94. throw new Error("Missing order ID in response");
  95. }
  96. reset();
  97. onClose();
  98. const calculatedAmount = data.weight * 5 + data.volume * 10;
  99. navigate(`/payment/${createdOrder.id}`, {
  100. state: {
  101. amount: calculatedAmount,
  102. customerEmail: user.email
  103. }
  104. });
  105. } catch (error) {
  106. console.error("There was an error creating the order:", error);
  107. toast.error("Błąd podczas tworzenia zamówienia.");
  108. }
  109. };
  110. const handleOpenChange = (open: boolean) => {
  111. if (!open) {
  112. reset();
  113. onClose();
  114. }
  115. };
  116. const fillTestData = () => {
  117. const testData: OrderFormValues = {
  118. recipientFirstName: "Jan",
  119. recipientLastName: "Testowy",
  120. recipientEmail: "boatdelivery0@gmail.com",
  121. recipientPhone: "573583371",
  122. weight: 2.5,
  123. volume: 5.0,
  124. pickupLocation: {
  125. streetAddress: "Piotrkowska 100",
  126. postalCode: "90-004",
  127. city: "Łódź",
  128. country: "POL"
  129. },
  130. deliveryLocation: {
  131. streetAddress: "Piotrkowska 10",
  132. postalCode: "90-270",
  133. city: "Łódź",
  134. country: "POL"
  135. },
  136. };
  137. reset(testData);
  138. };
  139. return (
  140. <Dialog open={isOpen} onOpenChange={handleOpenChange}>
  141. <DialogContent className="max-h-[90vh] w-full max-w-4xl overflow-y-auto sm:rounded-2xl">
  142. <DialogHeader className="mb-2">
  143. <DialogTitle className="flex items-center gap-2 text-2xl font-bold">
  144. <PackageCheck className="text-primary h-6 w-6" />
  145. {t("orders.newOrder")}
  146. </DialogTitle>
  147. </DialogHeader>
  148. {isUserLoading ? (
  149. <div className="text-muted-foreground flex h-32 flex-col items-center justify-center">
  150. <Loader2 className="mb-2 h-8 w-8 animate-spin" />
  151. <p>{t("orders.loadingProfile", "Pobieranie danych użytkownika...")}</p>
  152. </div>
  153. ) : (
  154. <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
  155. <div className="flex justify-end">
  156. <Button
  157. type="button"
  158. variant="outline"
  159. size="sm"
  160. onClick={fillTestData}
  161. className="text-xs"
  162. >
  163. {t("orders.fillTestData", "Wypełnij danymi testowymi")}
  164. </Button>
  165. </div>
  166. <div className="space-y-4">
  167. <h3 className="text-muted-foreground flex items-center gap-2 text-sm font-semibold">
  168. <User className="h-4 w-4 text-emerald-500" />
  169. {t("orders.recipientData", "Dane Odbiorcy")}
  170. </h3>
  171. <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
  172. <div className="space-y-2">
  173. <Input
  174. placeholder={t("orders.firstName", "Imię")}
  175. {...register("recipientFirstName")}
  176. className={errors.recipientFirstName ? "border-destructive focus-visible:ring-destructive" : ""}
  177. />
  178. {errors.recipientFirstName && (
  179. <p className="text-destructive text-sm font-medium">{errors.recipientFirstName.message}</p>
  180. )}
  181. </div>
  182. <div className="space-y-2">
  183. <Input
  184. placeholder={t("orders.lastName", "Nazwisko")}
  185. {...register("recipientLastName")}
  186. className={errors.recipientLastName ? "border-destructive focus-visible:ring-destructive" : ""}
  187. />
  188. {errors.recipientLastName && (
  189. <p className="text-destructive text-sm font-medium">{errors.recipientLastName.message}</p>
  190. )}
  191. </div>
  192. </div>
  193. <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
  194. <div className="space-y-2">
  195. <Input
  196. type="email"
  197. placeholder={t("orders.email", "Adres Email")}
  198. {...register("recipientEmail")}
  199. className={errors.recipientEmail ? "border-destructive focus-visible:ring-destructive" : ""}
  200. />
  201. {errors.recipientEmail && (
  202. <p className="text-destructive text-sm font-medium">{errors.recipientEmail.message}</p>
  203. )}
  204. </div>
  205. <div className="space-y-2">
  206. <Input
  207. type="tel"
  208. placeholder={t("orders.phone", "Numer Telefonu")}
  209. {...register("recipientPhone")}
  210. className={errors.recipientPhone ? "border-destructive focus-visible:ring-destructive" : ""}
  211. />
  212. {errors.recipientPhone && (
  213. <p className="text-destructive text-sm font-medium">{errors.recipientPhone.message}</p>
  214. )}
  215. </div>
  216. </div>
  217. </div>
  218. <Separator />
  219. <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
  220. <div className="space-y-2">
  221. <label className="text-sm leading-none font-medium">
  222. {t("orders.weight")}
  223. </label>
  224. <Input
  225. type="number"
  226. step="0.1"
  227. min="0.1"
  228. placeholder="0.0"
  229. {...register("weight", { valueAsNumber: true })}
  230. className={
  231. errors.weight
  232. ? "border-destructive focus-visible:ring-destructive"
  233. : ""
  234. }
  235. />
  236. {errors.weight && (
  237. <p className="text-destructive text-sm font-medium">
  238. {errors.weight.message}
  239. </p>
  240. )}
  241. </div>
  242. <div className="space-y-2">
  243. <label className="text-sm leading-none font-medium">
  244. {t("orders.volume")}
  245. </label>
  246. <Input
  247. type="number"
  248. step="0.1"
  249. min="0.1"
  250. placeholder="0.0"
  251. {...register("volume", { valueAsNumber: true })}
  252. className={
  253. errors.volume
  254. ? "border-destructive focus-visible:ring-destructive"
  255. : ""
  256. }
  257. />
  258. {errors.volume && (
  259. <p className="text-destructive text-sm font-medium">
  260. {errors.volume.message}
  261. </p>
  262. )}
  263. </div>
  264. </div>
  265. <Separator />
  266. <div className="space-y-4">
  267. <h3 className="text-muted-foreground flex items-center gap-2 text-sm font-semibold">
  268. <MapPin className="h-4 w-4 text-blue-500" />
  269. {t("orders.pickup")}
  270. </h3>
  271. <div className="space-y-2">
  272. <Input
  273. placeholder={t("orders.street")}
  274. {...register("pickupLocation.streetAddress")}
  275. className={
  276. errors.pickupLocation?.streetAddress
  277. ? "border-destructive focus-visible:ring-destructive"
  278. : ""
  279. }
  280. />
  281. {errors.pickupLocation?.streetAddress && (
  282. <p className="text-destructive text-sm font-medium">
  283. {errors.pickupLocation.streetAddress.message}
  284. </p>
  285. )}
  286. </div>
  287. <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
  288. <div className="space-y-2">
  289. <Input
  290. placeholder="00-000"
  291. maxLength={6}
  292. {...register("pickupLocation.postalCode", {
  293. onChange: (e) => {
  294. let val = e.target.value.replace(/\D/g, "");
  295. if (val.length > 2) {
  296. val = val.slice(0, 2) + "-" + val.slice(2, 5);
  297. }
  298. e.target.value = val;
  299. },
  300. })}
  301. className={
  302. errors.pickupLocation?.postalCode
  303. ? "border-destructive focus-visible:ring-destructive"
  304. : ""
  305. }
  306. />
  307. {errors.pickupLocation?.postalCode && (
  308. <p className="text-destructive text-sm font-medium">
  309. {errors.pickupLocation.postalCode.message}
  310. </p>
  311. )}
  312. </div>
  313. <div className="space-y-2">
  314. <Input
  315. placeholder={t("orders.city")}
  316. {...register("pickupLocation.city")}
  317. className={
  318. errors.pickupLocation?.city
  319. ? "border-destructive focus-visible:ring-destructive"
  320. : ""
  321. }
  322. />
  323. {errors.pickupLocation?.city && (
  324. <p className="text-destructive text-sm font-medium">
  325. {errors.pickupLocation.city.message}
  326. </p>
  327. )}
  328. </div>
  329. <div className="space-y-2">
  330. <Controller
  331. name="pickupLocation.country"
  332. control={control}
  333. render={({ field }) => (
  334. <Select
  335. {...field}
  336. options={countries}
  337. formatOptionLabel={formatOptionLabel}
  338. value={countries.find((c) => c.value === field.value)}
  339. onChange={(val) => field.onChange(val?.value)}
  340. unstyled
  341. classNames={{
  342. control: () =>
  343. `flex w-full items-center rounded-md border bg-background px-3 py-2 text-sm ring-offset-background focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 ${errors.pickupLocation?.country ? "border-destructive focus-within:ring-destructive" : "border-input"}`,
  344. menu: () =>
  345. "mt-1 rounded-md border bg-popover text-popover-foreground shadow-md z-50",
  346. option: ({ isFocused }) =>
  347. `p-2 text-sm cursor-pointer ${isFocused ? "bg-accent text-accent-foreground" : ""}`,
  348. singleValue: () => "text-foreground",
  349. }}
  350. />
  351. )}
  352. />
  353. {errors.pickupLocation?.country && (
  354. <p className="text-destructive text-sm font-medium">
  355. {errors.pickupLocation.country.message}
  356. </p>
  357. )}
  358. </div>
  359. </div>
  360. </div>
  361. <Separator />
  362. <div className="space-y-4">
  363. <h3 className="text-muted-foreground flex items-center gap-2 text-sm font-semibold">
  364. <Flag className="h-4 w-4 text-red-500" />
  365. {t("orders.delivery")}
  366. </h3>
  367. <div className="space-y-2">
  368. <Input
  369. placeholder={t("orders.street")}
  370. {...register("deliveryLocation.streetAddress")}
  371. className={
  372. errors.deliveryLocation?.streetAddress
  373. ? "border-destructive focus-visible:ring-destructive"
  374. : ""
  375. }
  376. />
  377. {errors.deliveryLocation?.streetAddress && (
  378. <p className="text-destructive text-sm font-medium">
  379. {errors.deliveryLocation.streetAddress.message}
  380. </p>
  381. )}
  382. </div>
  383. <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
  384. <div className="space-y-2">
  385. <Input
  386. placeholder="00-000"
  387. maxLength={6}
  388. {...register("deliveryLocation.postalCode", {
  389. onChange: (e) => {
  390. let val = e.target.value.replace(/\D/g, "");
  391. if (val.length > 2) {
  392. val = val.slice(0, 2) + "-" + val.slice(2, 5);
  393. }
  394. e.target.value = val;
  395. },
  396. })}
  397. className={
  398. errors.deliveryLocation?.postalCode
  399. ? "border-destructive focus-visible:ring-destructive"
  400. : ""
  401. }
  402. />
  403. {errors.deliveryLocation?.postalCode && (
  404. <p className="text-destructive text-sm font-medium">
  405. {errors.deliveryLocation.postalCode.message}
  406. </p>
  407. )}
  408. </div>
  409. <div className="space-y-2">
  410. <Input
  411. placeholder={t("orders.city")}
  412. {...register("deliveryLocation.city")}
  413. className={
  414. errors.deliveryLocation?.city
  415. ? "border-destructive focus-visible:ring-destructive"
  416. : ""
  417. }
  418. />
  419. {errors.deliveryLocation?.city && (
  420. <p className="text-destructive text-sm font-medium">
  421. {errors.deliveryLocation.city.message}
  422. </p>
  423. )}
  424. </div>
  425. <div className="space-y-2">
  426. <Controller
  427. name="deliveryLocation.country"
  428. control={control}
  429. render={({ field }) => (
  430. <Select
  431. {...field}
  432. options={countries}
  433. formatOptionLabel={formatOptionLabel}
  434. value={countries.find((c) => c.value === field.value)}
  435. onChange={(val) => field.onChange(val?.value)}
  436. unstyled
  437. classNames={{
  438. control: () =>
  439. `flex w-full items-center rounded-md border bg-background px-3 py-2 text-sm ring-offset-background focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 ${errors.deliveryLocation?.country ? "border-destructive focus-within:ring-destructive" : "border-input"}`,
  440. menu: () =>
  441. "mt-1 rounded-md border bg-popover text-popover-foreground shadow-md z-50",
  442. option: ({ isFocused }) =>
  443. `p-2 text-sm cursor-pointer ${isFocused ? "bg-accent text-accent-foreground" : ""}`,
  444. singleValue: () => "text-foreground",
  445. }}
  446. />
  447. )}
  448. />
  449. {errors.deliveryLocation?.country && (
  450. <p className="text-destructive text-sm font-medium">
  451. {errors.deliveryLocation.country.message}
  452. </p>
  453. )}
  454. </div>
  455. </div>
  456. </div>
  457. <div className="flex gap-3 pt-4">
  458. <Button
  459. type="button"
  460. variant="outline"
  461. className="w-1/3"
  462. onClick={onClose}
  463. >
  464. {t("cancel")}
  465. </Button>
  466. <Button type="submit" className="w-2/3" disabled={isSubmitting}>
  467. {isSubmitting ? t("orders.sending") : t("orders.submit")}
  468. </Button>
  469. </div>
  470. </form>
  471. )}
  472. </DialogContent>
  473. </Dialog>
  474. );
  475. };