Advertisement
Guest User

Untitled

a guest
Feb 7th, 2025
413
0
49 days
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
  1. "use client";
  2. import { useState } from "react";
  3. import { Button, Checkbox, Input, ListItem, Text, Title } from "@/components";
  4. import { useKlubStore } from "@/providers/klub-store-provider";
  5. import { getClientReqConfig, kAxios, notifyError } from "@/utils/axios";
  6. import { getCheckout } from "@/utils/endpoints/checkout";
  7. import { validateEmail, validatePhone } from "@/utils/form-fields-validators";
  8. import { CalendarX, CircleNotch } from "@phosphor-icons/react";
  9. import { useMutation, useQuery } from "@tanstack/react-query";
  10. import { useParams, useRouter } from "next/navigation";
  11. import { useEffect, useTransition } from "react";
  12. import { FormProvider, useFieldArray, useForm } from "react-hook-form";
  13. import { twMerge } from "tailwind-merge";
  14. import { useMe } from "@/components/hooks/use-me";
  15. import type { HotelDetail } from "@/utils/types/hotel";
  16. import { format, parseISO } from "date-fns";
  17. import { useLoyaltyPoints } from "@/components/hooks/use-loyalty-points";
  18. import { getNightsAmount } from "@/utils/dates";
  19. import { formatMoney } from "@/utils/text";
  20. import type { CheckoutSession } from "@/utils/types/checkout";
  21. import { useMixpanel } from "@/components/hooks/use-mixpanel";
  22. import { Voucher } from "@/components/molecules/voucher/voucher";
  23.  
  24. type FormGuest = {
  25.   firstName: string;
  26.   lastName: string;
  27.   email: string;
  28.   phone: string;
  29. };
  30. type FormProps = {
  31.   guestsRequests: CheckoutSession["guestsRequests"];
  32.   guests: FormGuest[];
  33.   phone: string;
  34.   useKlubPoints: boolean;
  35. };
  36.  
  37. type Tax = {
  38.   included: boolean;
  39.   currency: string;
  40.   amount: number;
  41.   description: string;
  42. };
  43.  
  44. export const CheckoutForm = () => {
  45.   const [isTransitioning, startTransition] = useTransition();
  46.   const { data: me } = useMe();
  47.   const { checkoutId } = useParams<{ checkoutId: string }>();
  48.   const { adults, children } = useKlubStore((state) => state.search);
  49.   const { isPartner, token } = useKlubStore((state) => state.auth);
  50.   const { data: loyalty } = useLoyaltyPoints();
  51.   const [discountPercentage, setCouponDiscountPercentage] = useState(0);
  52.  
  53.   const guests = adults + children;
  54.   const router = useRouter();
  55.   const methods = useForm<FormProps>({
  56.     defaultValues: {
  57.       useKlubPoints: false,
  58.       guestsRequests: {
  59.         airportDrop: false,
  60.         additionalBed: false,
  61.         earlyCheckIn: false,
  62.         lateCheckout: false,
  63.         remarks: "",
  64.       },
  65.       guests: [],
  66.       phone: "",
  67.     },
  68.     mode: "onSubmit",
  69.     reValidateMode: "onChange",
  70.   });
  71.  
  72.   const { track } = useMixpanel();
  73.  
  74.   const { data: checkoutData, isFetching } = useQuery({
  75.     queryKey: ["checkout", checkoutId],
  76.     queryFn: () => getCheckout(checkoutId),
  77.   });
  78.  
  79.   const useLoyaltyPointsWatch = methods.watch("useKlubPoints");
  80.   const useLoyaltyPointsTouched =
  81.     !!methods.formState.touchedFields.useKlubPoints;
  82.  
  83.   const hotelId = checkoutData?.prebookResponsePayload.hotelId;
  84.  
  85.   const { data: hotelData } = useQuery({
  86.     enabled: !!hotelId,
  87.     queryKey: ["hotel", hotelId],
  88.     queryFn: async () =>
  89.       (await kAxios.get<HotelDetail>(`/hotels/${hotelId}`)).data,
  90.   });
  91.  
  92.   const isKlubPointSelected = methods.watch("useKlubPoints");
  93.   const paymentSessionUrl = checkoutData?.paymentSessionUrl;
  94.  
  95.   useEffect(() => {
  96.     if (useLoyaltyPointsTouched) {
  97.       track("hotel_checkout.use_loyalty.clicked", {
  98.         useKlubPoints: useLoyaltyPointsWatch ? "yes" : "no",
  99.       });
  100.     }
  101.   }, [useLoyaltyPointsWatch, useLoyaltyPointsTouched, track]);
  102.  
  103.   useEffect(() => {
  104.     if (!me) return;
  105.  
  106.     const [firstName, lastName] = me.name.split(" ");
  107.     methods.setValue(
  108.       "guests",
  109.       Array.from<number>({ length: 1 }).map((_: number, index: number) =>
  110.         index === 0
  111.           ? {
  112.               firstName,
  113.               lastName,
  114.               email: me.email,
  115.               phone: "",
  116.             }
  117.           : {
  118.               firstName: "",
  119.               lastName: "",
  120.               email: "",
  121.               phone: "",
  122.             },
  123.       ),
  124.     );
  125.   }, [guests, methods, me]);
  126.  
  127.   useEffect(() => {
  128.     if (paymentSessionUrl) {
  129.       router.push(paymentSessionUrl);
  130.     }
  131.   }, [paymentSessionUrl, router]);
  132.  
  133.   const mutation = useMutation({
  134.     mutationFn: (payload: {
  135.       guestsDetails: CheckoutSession["guestsDetails"];
  136.       holderDetails: CheckoutSession["holderDetails"];
  137.       hotelDetails: CheckoutSession["hotelDetails"];
  138.       guestsRequests: CheckoutSession["guestsRequests"];
  139.       klubPointsAmount: CheckoutSession["klubPointsAmount"];
  140.       discountCoupon: number;
  141.     }) => {
  142.       return kAxios.post<{ url: string }>(
  143.         `/payments/session/${checkoutId}`,
  144.         payload,
  145.         getClientReqConfig({ withAuth: true }),
  146.       );
  147.     },
  148.     onSuccess: (data) => {
  149.       startTransition(() => {
  150.         router.push(data.data.url);
  151.       });
  152.     },
  153.     onError: notifyError,
  154.   });
  155.  
  156.   const errors = methods.formState.errors;
  157.   const onSubmit = async (data: FormProps) => {
  158.     const [firstName, lastName] = me
  159.       ? me.name.split(" ")
  160.       : [data.guests[0].firstName, data.guests[0].lastName];
  161.     track("hotel_checkout.go_to_payment.clicked", {
  162.       guests: data.guests.length,
  163.       hotelName: hotelData?.name || "",
  164.       useKlubPoints: data.useKlubPoints ? "yes" : "no",
  165.     });
  166.     mutation.mutate({
  167.       guestsDetails: (data.guests || []).map((guest) => ({
  168.         firstName: guest.firstName,
  169.         lastName: guest.lastName,
  170.         email: guest.email,
  171.         occupancyNumber: 1,
  172.       })),
  173.       hotelDetails: {
  174.         name: hotelData?.name || "",
  175.         address: hotelData?.address || "",
  176.         city: hotelData?.city || "",
  177.         image: hotelData?.main_photo || "",
  178.       },
  179.       holderDetails: {
  180.         firstName: firstName,
  181.         lastName: lastName,
  182.         email: me ? me.email : data.guests[0].email,
  183.         phone: data.phone,
  184.       },
  185.       guestsRequests: data.guestsRequests,
  186.       klubPointsAmount: data.useKlubPoints ? loyalty?.klubPoints : 0,
  187.       discountCoupon: couponDiscount,
  188.     });
  189.   };
  190.  
  191.   if (isFetching) {
  192.     return (
  193.       <div className="flex justify-center items-center min-h-[600px]">
  194.         <div className="animate-spin text-primary">
  195.           <CircleNotch size={48} />
  196.         </div>
  197.       </div>
  198.     );
  199.   }
  200.  
  201.   if (!checkoutData || !hotelData) {
  202.     return null;
  203.   }
  204.  
  205.   const nrOfNights = getNightsAmount(
  206.     checkoutData.checkin,
  207.     checkoutData.checkout,
  208.   );
  209.   const prebook = checkoutData.prebookResponsePayload;
  210.   const couponDiscount = parseFloat(
  211.     (prebook.price * discountPercentage).toFixed(2),
  212.   );
  213.   const totalAmount = Math.max(
  214.     0,
  215.     isKlubPointSelected && loyalty?.equivalentAmount
  216.       ? prebook.price - loyalty.equivalentAmount - couponDiscount
  217.       : prebook.price - couponDiscount,
  218.   );
  219.   const rate = prebook?.roomTypes?.[0]?.rates?.[0];
  220.   const isRefundable = rate?.cancellationPolicies?.refundableTag === "RFN";
  221.   const cancelTime =
  222.     rate?.cancellationPolicies?.cancelPolicyInfos?.[0]?.cancelTime;
  223.  
  224.   const includedTaxesAndFees = rate?.retailRate?.taxesAndFees?.filter(
  225.     (tax: Tax) => !!tax.included,
  226.   ) as Tax[];
  227.   const exludedTaxesAndFees = rate?.retailRate?.taxesAndFees?.filter(
  228.     (tax: Tax) => !tax.included,
  229.   ) as Tax[];
  230.  
  231.   return (
  232.     <FormProvider {...methods}>
  233.       <form onSubmit={methods.handleSubmit(onSubmit)}>
  234.         <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
  235.           <div className="col-span-1 lg:col-span-2 grid gap-10 pt-6 relative">
  236.             <section>
  237.               <div>
  238.                 <div>{hotelData.name}</div>
  239.                 <div className="text-12-regular text-neutral-50">
  240.                   {hotelData.address}, {hotelData.city}
  241.                 </div>
  242.               </div>
  243.               <div className="rounded-t flex flex-col md:flex-row border border-neutral-20 mt-3">
  244.                 <div className="flex-1 flex flex-col md:flex-row gap-4 p-4">
  245.                   <div
  246.                     className="w-full md:w-[140px] h-[140px] md:min-w-[140px] rounded bg-center bg-cover"
  247.                     style={{
  248.                       backgroundImage: `url(${hotelData.main_photo})`,
  249.                     }}
  250.                   />
  251.                   <div>
  252.                     <div className="grid grid-cols-2 gap-6">
  253.                       <div>
  254.                         <div className="text-12-semibold">Check in</div>
  255.                         <div className="text-16-semibold mt-2">
  256.                           {format(parseISO(checkoutData.checkin), "dd MMM")}
  257.                         </div>
  258.                         <div className="text-12-semibold text-neutral-50">
  259.                           {hotelData.checkinCheckoutTimes.checkin}
  260.                         </div>
  261.                       </div>
  262.                       <div>
  263.                         <div className="text-12-semibold">Check out</div>
  264.                         <div className="text-16-semibold mt-2">
  265.                           {format(parseISO(checkoutData.checkout), "dd MMM")}
  266.                         </div>
  267.                         <div className="text-12-semibold text-neutral-90">
  268.                           {hotelData.checkinCheckoutTimes.checkout}
  269.                         </div>
  270.                       </div>
  271.                     </div>
  272.                     <div className="text-12-semibold mt-4 mb-2">Room</div>
  273.                     <div className="text-16-semibold">
  274.                       {adults + children} Guests
  275.                     </div>
  276.                     <div className="text-12-semibold text-neutral-50">
  277.                       {rate?.name}
  278.                     </div>
  279.                   </div>
  280.                 </div>
  281.                 <div className="flex-1 md:max-w-[275px] p-4 border-t md:border-t-0 md:border-l border-neutral-20">
  282.                   <CalendarX size={24} className="text-red-400" />
  283.                   <div className="text-16-semibold">Cancellation policy</div>
  284.                   <div className="text-2-semibold mt-5">
  285.                     {isRefundable ? "Free cancellation" : "Non-refundable"}
  286.                   </div>
  287.                   {isRefundable && (
  288.                     <div className="text-12-regular text-neutral-50 mt-2">
  289.                       Cancel before:{" "}
  290.                       {format(cancelTime, "dd MMM yyyy 'at' HH:mm aaaa")}
  291.                     </div>
  292.                   )}
  293.                 </div>
  294.               </div>
  295.               {!isPartner ||
  296.                 (token && (
  297.                   <div className="rounded-b border-x border-b border-neutral-20 bg-input p-4 ">
  298.                     <Checkbox
  299.                       disabled={loyalty?.klubPoints === 0}
  300.                       className="bg-white text-neutral-100"
  301.                       label={
  302.                         loyalty?.klubPoints ? (
  303.                           <>
  304.                             Use Klub Points:{" "}
  305.                             <span className="text-primary">
  306.                               {loyalty.klubPoints}
  307.                             </span>
  308.                           </>
  309.                         ) : (
  310.                           <>You have 0 Klub Points</>
  311.                         )
  312.                       }
  313.                       {...methods.register("useKlubPoints")}
  314.                     />
  315.                   </div>
  316.                 ))}
  317.             </section>
  318.             <section id="guests">
  319.               <Title tag="h3" className="mb-6 text-24-semibold">
  320.                 Guests Details
  321.               </Title>
  322.               <div className="flex flex-col gap-6">
  323.                 <div
  324.                   className={twMerge(
  325.                     "grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4",
  326.                     "border border-neutral-20 rounded p-4",
  327.                   )}
  328.                 >
  329.                   <Input
  330.                     {...methods.register(`guests.${0}.firstName`, {
  331.                       required: "This field is required",
  332.                     })}
  333.                     variant="secondary"
  334.                     placeholder="First name"
  335.                     error={errors.guests?.[0]?.firstName?.message}
  336.                   />
  337.                   <Input
  338.                     {...methods.register(`guests.${0}.lastName`, {
  339.                       required: "This field is required",
  340.                     })}
  341.                     placeholder="Last name"
  342.                     variant="secondary"
  343.                     error={errors.guests?.[0]?.lastName?.message}
  344.                   />
  345.                   <div className="col-span-1 md:col-span-2">
  346.                     <Text tag="span" className="text-xs text-neutral-50">
  347.                       Booking details will be shared over this phone number and
  348.                       email address
  349.                     </Text>
  350.                   </div>
  351.                   <Input
  352.                     {...methods.register(`guests.${0}.email`, {
  353.                       required: "This field is required",
  354.                       validate: validateEmail,
  355.                     })}
  356.                     variant="secondary"
  357.                     placeholder="Email"
  358.                     error={errors.guests?.[0]?.email?.message}
  359.                   />
  360.                   <Input
  361.                     {...methods.register(`phone`, {
  362.                       required: "This field is required",
  363.                       validate: validatePhone,
  364.                     })}
  365.                     variant="secondary"
  366.                     error={errors.guests?.[0]?.phone?.message}
  367.                     placeholder="Mobile number"
  368.                   />
  369.                 </div>
  370.               </div>
  371.             </section>
  372.  
  373.             <section id="additional-requests">
  374.               <Title tag="h3" className="mb-6 text-24-semibold">
  375.                 Additional Requests
  376.               </Title>
  377.               <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  378.                 <div>
  379.                   <Checkbox
  380.                     label="Airport drop"
  381.                     {...methods.register("guestsRequests.airportDrop")}
  382.                   />
  383.                 </div>
  384.                 <div>
  385.                   <Checkbox
  386.                     label="Additional bed"
  387.                     {...methods.register("guestsRequests.additionalBed")}
  388.                   />
  389.                 </div>
  390.                 <div>
  391.                   <Checkbox
  392.                     label="Early checkin"
  393.                     {...methods.register("guestsRequests.earlyCheckIn")}
  394.                   />
  395.                 </div>
  396.                 <div>
  397.                   <Checkbox
  398.                     label="Late checkout"
  399.                     {...methods.register("guestsRequests.lateCheckout")}
  400.                   />
  401.                 </div>
  402.               </div>
  403.               <label className="block mt-6">
  404.                 <Text tag="span" className="block mb-3">
  405.                   Anything else you would like us to inform your hotel?
  406.                 </Text>
  407.                 <textarea
  408.                   {...methods.register("guestsRequests.remarks")}
  409.                   placeholder="Add your note..."
  410.                   className="w-full h-32 rounded p-4 outline-none border border-neutral-20 bg-input focus:border-primary"
  411.                 />
  412.               </label>
  413.             </section>
  414.           </div>
  415.           <aside className="col-span-1">
  416.             <div className="border border-neutral-20 shadow-xl rounded-sm mt-7 md:sticky top-0">
  417.               <div className="border-b-1 border-neutral-20">
  418.                 <div className="bg-white md:px-4">
  419.                   <Title tag="h4" className="py-3 text-16-semibold">
  420.                     Your Exclusive Deals
  421.                   </Title>
  422.                 </div>
  423.               </div>
  424.               <Voucher
  425.                 couponDiscount={couponDiscount}
  426.                 onDiscountChange={setCouponDiscountPercentage}
  427.               />
  428.             </div>
  429.             <div className="border border-neutral-20 shadow-xl rounded-sm mt-7 md:sticky top-0">
  430.               <div className="bg-white p-2 md:p-4">
  431.                 <Title tag="h4" className="mb-4 text-24-semibold">
  432.                   Price Breakdown
  433.                 </Title>
  434.                 <ul className="space-y-4">
  435.                   <ListItem
  436.                     container="li"
  437.                     className="items-start"
  438.                     textContainerClasses="gap-0"
  439.                     title={`1 room X ${nrOfNights.formatted}`}
  440.                     subtitle={
  441.                       <>
  442.                         <Text tag="span" className="text-neutral-50 text-xs">
  443.                           {formatMoney({
  444.                             currency: prebook.currency,
  445.                             amount: prebook.price / nrOfNights.amount,
  446.                           })}{" "}
  447.                           per night
  448.                         </Text>
  449.                         {includedTaxesAndFees?.length && (
  450.                           <Text tag="p" className="text-neutral-50 text-xs">
  451.                             Inclusive of{" "}
  452.                             {includedTaxesAndFees
  453.                               .map((tax) => tax.description)
  454.                               .join(", ")}
  455.                           </Text>
  456.                         )}
  457.                       </>
  458.                     }
  459.                     endItem={formatMoney({
  460.                       currency: prebook.currency,
  461.                       amount: prebook.price,
  462.                     })}
  463.                   />
  464.                 </ul>
  465.                 {isKlubPointSelected && loyalty?.equivalentAmount && (
  466.                   <div className="border-t border-netrual-70 text-green-dark pt-4 mt-4 font-semibold">
  467.                     <ListItem
  468.                       title={
  469.                         <div className="text-16-semibold">
  470.                           Klub Points Discount
  471.                         </div>
  472.                       }
  473.                       endItem={
  474.                         <>
  475.                           -{" "}
  476.                           {formatMoney({
  477.                             currency: prebook.currency,
  478.                             amount: loyalty.equivalentAmount,
  479.                           })}
  480.                         </>
  481.                       }
  482.                       container="div"
  483.                     />
  484.                   </div>
  485.                 )}
  486.                 {!!discountPercentage && (
  487.                   <div className="border-y border-dotted border-netrual-70 py-4 my-4">
  488.                     <ListItem
  489.                       endTextClasses="text-red-500"
  490.                       title="Promo Code Applied"
  491.                       endItem={
  492.                         <>
  493.                           -{" "}
  494.                           {formatMoney({
  495.                             currency: prebook.currency,
  496.                             amount: couponDiscount,
  497.                           })}
  498.                         </>
  499.                       }
  500.                     />
  501.                   </div>
  502.                 )}
  503.                 <div className="border-y border-dotted border-netrual-70 py-4 my-4">
  504.                   <ListItem
  505.                     title={<div className="text-16-semibold">Total amount</div>}
  506.                     endItem={formatMoney({
  507.                       currency: prebook.currency,
  508.                       amount: totalAmount,
  509.                     })}
  510.                     container="div"
  511.                   />
  512.                 </div>
  513.                 <Button
  514.                   type="submit"
  515.                   size="medium"
  516.                   className="mt-4"
  517.                   isLoading={
  518.                     mutation.isPending || isTransitioning || paymentSessionUrl
  519.                   }
  520.                 >
  521.                   Go to Payment
  522.                 </Button>
  523.               </div>
  524.               {!!exludedTaxesAndFees?.length && (
  525.                 <div className="p-4 mt-4 bg-neutral-20 rounded-sm">
  526.                   <div className="text-16-regular mb-1">Additional Charges</div>
  527.                   <div className="text-12-regular text-neutral-50 mb-2">
  528.                     This includes taxes to be paid at the hotel desk during
  529.                     check in.
  530.                   </div>
  531.                   <ul className="space-y-1">
  532.                     {exludedTaxesAndFees.map((tax, index: number) => (
  533.                       <ListItem
  534.                         key={`tax-${index}`}
  535.                         title={tax.description}
  536.                         endItem={formatMoney({
  537.                           currency: tax.currency,
  538.                           amount: tax.amount,
  539.                         })}
  540.                         container="li"
  541.                       />
  542.                     ))}
  543.                   </ul>
  544.                 </div>
  545.               )}
  546.             </div>
  547.           </aside>
  548.         </div>
  549.       </form>
  550.     </FormProvider>
  551.   );
  552. };
  553.  
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement