CartModal.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. "use client";
  2. import clsx from "clsx";
  3. import { useDisclosure } from "@heroui/use-disclosure";
  4. import { AnimatePresence, motion } from "framer-motion";
  5. import { ShoppingCartIcon } from "@heroicons/react/24/outline";
  6. import { DEFAULT_OPTION } from "@/utils/constants";
  7. import OpenCart from "./OpenCart";
  8. import { Price } from "../theme/ui/Price";
  9. import CloseCart from "../common/icons/cart/CloseCart";
  10. import { DeleteItemButton } from "../common/icons/cart/DeleteItemButton";
  11. import { EditItemQuantityButton } from "../common/icons/cart/EditItemQuantityButton";
  12. import { useCartDetail } from "@utils/hooks/useCartDetail";
  13. import Image from "next/image";
  14. import { NOT_IMAGE } from "@utils/constants";
  15. import LoadingDots from "@components/common/icons/LoadingDots";
  16. import { useFormStatus } from "react-dom";
  17. import { redirectToCheckout } from "@/utils/actions";
  18. import Link from "next/link";
  19. import { createUrl, safeParse } from "@utils/helper";
  20. import { useBodyScrollLock } from "@utils/hooks/useBodyScrollLock";
  21. type MerchandiseSearchParams = {
  22. [key: string]: string;
  23. };
  24. export default function CartModal({
  25. children,
  26. className,
  27. onOpen,
  28. onClose,
  29. isOpen,
  30. }: {
  31. children?: React.ReactNode;
  32. className?: string;
  33. onOpen?: () => void;
  34. onClose?: () => void;
  35. isOpen?: boolean;
  36. }) {
  37. const {
  38. isOpen: internalIsOpen,
  39. onOpen: internalOnOpen,
  40. onClose: internalOnClose,
  41. } = useDisclosure();
  42. const isControlled = isOpen !== undefined;
  43. const finalIsOpen = isControlled ? isOpen : internalIsOpen;
  44. const finalOnOpen = isControlled ? onOpen : internalOnOpen;
  45. const finalOnClose = isControlled ? onClose : internalOnClose;
  46. const { isLoading, cartData: cartDetail } = useCartDetail();
  47. // const cartDetail = useAppSelector((state) => state.cartDetail);
  48. const cart = Array.isArray(cartDetail?.items?.edges)
  49. ? cartDetail?.items?.edges
  50. : [];
  51. useBodyScrollLock(finalIsOpen);
  52. // const handleOpenChange = (open: boolean) => {
  53. // if (!open) {
  54. // finalOnClose?.();
  55. // }
  56. // };
  57. return (
  58. <>
  59. <button
  60. type="button"
  61. aria-label="Open cart"
  62. className={clsx(
  63. className,
  64. isLoading ? "cursor-wait" : "cursor-pointer",
  65. )}
  66. disabled={isLoading}
  67. onClick={finalOnOpen}
  68. >
  69. {children ? (
  70. children
  71. ) : (
  72. <OpenCart quantity={cartDetail?.itemsQty} />
  73. )}
  74. </button>
  75. <AnimatePresence>
  76. {finalIsOpen && (
  77. <>
  78. <motion.div
  79. initial={{ opacity: 0 }}
  80. animate={{ opacity: 1 }}
  81. exit={{ opacity: 0 }}
  82. onClick={finalOnClose}
  83. className="fixed inset-0 z-40 bg-transparent lg:hidden transition-opacity"
  84. style={{ top: "68px", bottom: "64px" }}
  85. />
  86. <motion.div
  87. initial={{ x: "100%" }}
  88. animate={{ x: 0 }}
  89. exit={{ x: "100%" }}
  90. transition={{
  91. type: "spring",
  92. damping: 30,
  93. stiffness: 300,
  94. mass: 0.8,
  95. }}
  96. className="fixed right-0 z-50 flex flex-col bg-white dark:bg-black w-full max-w-[448px] border-l border-neutral-200 dark:border-neutral-800 lg:hidden"
  97. style={{
  98. top: "68px",
  99. bottom: "64px",
  100. width: "100%",
  101. maxWidth: "448px",
  102. height: "calc(var(--visual-viewport-height) - 132px)",
  103. }}
  104. >
  105. <div className="flex flex-col gap-1 p-4">
  106. <div className="flex items-center justify-between">
  107. <p
  108. className={clsx(
  109. "font-semibold",
  110. "text-xl",
  111. )}
  112. >
  113. My Cart
  114. </p>
  115. <button
  116. aria-label="Close cart"
  117. className="cursor-pointer"
  118. onClick={finalOnClose}
  119. >
  120. <CloseCart />
  121. </button>
  122. </div>
  123. </div>
  124. <div
  125. className={clsx(
  126. "flex-1 overflow-y-auto px-4 py-0 drawer-scrollbar-hidden",
  127. "!px-2",
  128. )}
  129. >
  130. {cart?.length === 0 ? (
  131. <div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
  132. <ShoppingCartIcon className="h-16" />
  133. <p className="mt-6 text-center text-2xl font-bold">
  134. Your cart is empty.
  135. </p>
  136. </div>
  137. ) : (
  138. <div className="flex h-full flex-col justify-between">
  139. <ul className="my-0 flex-grow overflow-auto py-0">
  140. {Array.isArray(cart) &&
  141. cart?.map((item: any, i: number) => {
  142. const merchandiseSearchParams =
  143. {} as MerchandiseSearchParams;
  144. const merchandiseUrl = createUrl(
  145. `/product/${item?.node.productUrlKey}`,
  146. new URLSearchParams(merchandiseSearchParams),
  147. );
  148. const baseImage: any = safeParse(
  149. item?.node?.baseImage,
  150. );
  151. return (
  152. <li key={i} className="flex w-full flex-col">
  153. <div
  154. className={clsx(
  155. "flex w-full flex-row justify-between py-4 px-1",
  156. "gap-1 xxs:gap-3",
  157. )}
  158. >
  159. <Link
  160. className="z-30 flex flex-row space-x-4"
  161. aria-label={`${item?.node?.name}`}
  162. href={merchandiseUrl}
  163. onClick={finalOnClose}
  164. >
  165. <div className="relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
  166. <Image
  167. alt={
  168. item?.node?.baseImage ||
  169. item?.product?.name
  170. }
  171. className="h-full w-full object-cover"
  172. height={64}
  173. src={baseImage?.small_image_url || ""}
  174. width={74}
  175. onError={(e) =>
  176. (e.currentTarget.src = NOT_IMAGE)
  177. }
  178. />
  179. </div>
  180. <div className="flex flex-1 flex-col text-base">
  181. <span className="line-clamp-1 font-outfit text-base font-medium">
  182. {item?.node?.name}
  183. </span>
  184. {item.name !== DEFAULT_OPTION && (
  185. <p className="text-sm lowercase line-clamp-1 text-black dark:text-neutral-400">
  186. {item?.node?.sku}
  187. </p>
  188. )}
  189. </div>
  190. </Link>
  191. <div className="flex h-16 flex-col justify-between">
  192. <Price
  193. amount={item?.node?.price}
  194. className="flex justify-end space-y-2 text-right font-outfit text-base font-medium"
  195. currencyCode={"USD"}
  196. />
  197. <div className="flex items-center gap-x-2">
  198. <DeleteItemButton item={item} />
  199. <div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
  200. <EditItemQuantityButton
  201. item={item}
  202. type="minus"
  203. />
  204. <p className="w-6 text-center">
  205. <span className="w-full text-sm">
  206. {item?.node?.quantity}
  207. </span>
  208. </p>
  209. <EditItemQuantityButton
  210. item={item}
  211. type="plus"
  212. />
  213. </div>
  214. </div>
  215. </div>
  216. </div>
  217. </li>
  218. );
  219. })}
  220. </ul>
  221. <div className="border-0 border-t border-solid border-neutral-200 dark:border-dark-grey py-4 text-sm text-neutral-500 dark:text-neutral-400">
  222. {(cartDetail as any)?.taxAmount > 0 && (
  223. <div className="mb-3 flex items-center justify-between">
  224. <p className="text-base font-normal text-black/[60%] dark:text-white">
  225. Taxes
  226. </p>
  227. <Price
  228. amount={(cartDetail as any)?.taxAmount}
  229. className="text-right text-base font-medium text-black dark:text-white"
  230. currencyCode={"USD"}
  231. />
  232. </div>
  233. )}
  234. <div className="mb-3 flex items-center justify-between pb-1">
  235. <p className="text-base font-normal text-black/[60%] dark:text-white">
  236. Total
  237. </p>
  238. <Price
  239. amount={(cartDetail as any)?.grandTotal}
  240. className="text-right text-base font-medium text-black dark:text-white"
  241. currencyCode={"USD"}
  242. />
  243. </div>
  244. <form action={redirectToCheckout}>
  245. <CheckoutButton />
  246. </form>
  247. </div>
  248. </div>
  249. )}
  250. </div>
  251. <div className="p-4" />
  252. </motion.div>
  253. </>
  254. )}
  255. </AnimatePresence>
  256. </>
  257. );
  258. }
  259. function CheckoutButton() {
  260. const { pending } = useFormStatus();
  261. return (
  262. <>
  263. <input
  264. name="url"
  265. type="hidden"
  266. value="/checkout"
  267. />
  268. <button
  269. className={clsx(
  270. "block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100",
  271. pending ? "cursor-wait" : "cursor-pointer",
  272. )}
  273. disabled={pending}
  274. type="submit"
  275. >
  276. {pending ? <LoadingDots className="bg-white" /> : "Proceed to Checkout"}
  277. </button>
  278. </>
  279. );
  280. }