255 lines
9.2 KiB
TypeScript
255 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import Image from "next/image";
|
|
import { IoCloseOutline, IoAddOutline, IoRemoveOutline, IoTrashOutline } from "react-icons/io5";
|
|
import { IconButton } from "@/components/IconButton";
|
|
import { useCart } from "./CartContext";
|
|
import { FORMAT_GROUPS } from "@/lib/variants";
|
|
import type { MedusaLineItem } from "@/lib/medusa";
|
|
|
|
/** Build a suffix → friendly-name map from the variant taxonomy. */
|
|
const suffixToLabel: Record<string, string> = {};
|
|
for (const group of FORMAT_GROUPS) {
|
|
for (const v of group.variants ?? []) suffixToLabel[v.suffix] = v.title;
|
|
for (const sg of group.subgroups ?? []) {
|
|
for (const v of sg.variants) suffixToLabel[v.suffix] = v.title;
|
|
}
|
|
}
|
|
|
|
function variantLabel(item: MedusaLineItem): string {
|
|
const sku = item.variant?.sku;
|
|
if (sku) {
|
|
const suffix = sku.split("_").pop()?.toUpperCase();
|
|
if (suffix && suffixToLabel[suffix]) return suffixToLabel[suffix];
|
|
}
|
|
return item.variant?.title ?? item.title;
|
|
}
|
|
|
|
function formatPrice(amount: number, currencyCode: string) {
|
|
return new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: currencyCode,
|
|
minimumFractionDigits: 2,
|
|
}).format(amount);
|
|
}
|
|
|
|
const overlayVariants = {
|
|
open: {
|
|
opacity: 1,
|
|
pointerEvents: "auto" as const,
|
|
transition: { type: "tween" as const, ease: "easeInOut" as const, duration: 0.2 },
|
|
},
|
|
closed: {
|
|
opacity: 0,
|
|
transition: { type: "tween" as const, ease: "easeInOut" as const, duration: 0.2, delay: 0.15 },
|
|
transitionEnd: { pointerEvents: "none" as const },
|
|
},
|
|
};
|
|
|
|
const panelVariants = {
|
|
open: {
|
|
opacity: 1,
|
|
transition: { type: "tween" as const, ease: "easeInOut" as const, duration: 0.4 },
|
|
},
|
|
closed: {
|
|
opacity: 0,
|
|
},
|
|
exit: {
|
|
opacity: 0,
|
|
transition: { type: "tween" as const, ease: "easeInOut" as const, duration: 0.3, delay: 0.1 },
|
|
},
|
|
};
|
|
|
|
function CartItemContent({ item, currencyCode }: { item: MedusaLineItem; currencyCode: string }) {
|
|
return (
|
|
<>
|
|
{/* Thumbnail */}
|
|
<div className="relative aspect-square w-21 flex-shrink-0 overflow-hidden rounded-xl bg-lightline dark:bg-darkline">
|
|
{item.thumbnail ? (
|
|
<Image
|
|
src={item.thumbnail}
|
|
alt={item.product_title ?? item.title}
|
|
fill
|
|
sizes="64px"
|
|
className="object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center text-xs text-lightsec dark:text-darksec">
|
|
No img
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="min-w-0 flex-1 py-3">
|
|
<p className="truncate font-silkasb text-sm">
|
|
{item.product_title ?? item.title}
|
|
</p>
|
|
<p className="truncate text-xs text-lightsec dark:text-darksec">
|
|
{variantLabel(item)}
|
|
</p>
|
|
<p className="mt-1 font-silkasb text-sm">
|
|
{formatPrice(item.unit_price, currencyCode)}{" "}
|
|
<span className="ml-1 font-silka text-lightsec dark:text-darksec">
|
|
x {item.quantity}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function CartDrawer() {
|
|
const { cart, drawerOpen, setDrawerOpen, removeItem, updateItem } = useCart();
|
|
const router = useRouter();
|
|
|
|
useEffect(() => {
|
|
if (!drawerOpen) return;
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setDrawerOpen(false);
|
|
};
|
|
window.addEventListener("keydown", onKeyDown);
|
|
return () => window.removeEventListener("keydown", onKeyDown);
|
|
}, [drawerOpen, setDrawerOpen]);
|
|
|
|
useEffect(() => {
|
|
if (drawerOpen) {
|
|
document.documentElement.style.overflow = "hidden";
|
|
document.body.style.overflow = "hidden";
|
|
} else {
|
|
document.documentElement.style.overflow = "";
|
|
document.body.style.overflow = "";
|
|
}
|
|
return () => {
|
|
document.documentElement.style.overflow = "";
|
|
document.body.style.overflow = "";
|
|
};
|
|
}, [drawerOpen]);
|
|
|
|
const items = cart?.items ?? [];
|
|
const currencyCode = cart?.currency_code ?? "eur";
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{drawerOpen && (
|
|
<motion.div
|
|
className="fixed inset-0 z-50"
|
|
variants={overlayVariants}
|
|
initial="closed"
|
|
animate="open"
|
|
exit="closed"
|
|
onClick={() => setDrawerOpen(false)}
|
|
>
|
|
{/* Backdrop */}
|
|
<div className="absolute inset-0 bg-lightbg/85 backdrop-blur-md dark:bg-darkbg/85" />
|
|
|
|
{/* Panel */}
|
|
<motion.aside
|
|
className="absolute top-0 right-0 flex h-full w-full max-w-lg flex-col bg-lightbg shadow-lg dark:bg-darkbg"
|
|
variants={panelVariants}
|
|
initial="closed"
|
|
animate="open"
|
|
exit="exit"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-6 md:p-8">
|
|
<h2 className="font-argesta text-2xl">Cart</h2>
|
|
<IconButton onClick={() => setDrawerOpen(false)} aria-label="Close cart">
|
|
<IoCloseOutline />
|
|
</IconButton>
|
|
</div>
|
|
|
|
{/* Items */}
|
|
<div
|
|
className="flex-1 overflow-y-auto p-6 md:p-8"
|
|
style={{
|
|
maskImage:
|
|
"linear-gradient(to bottom, transparent, black 24px, black calc(100% - 24px), transparent)",
|
|
WebkitMaskImage:
|
|
"linear-gradient(to bottom, transparent, black 24px, black calc(100% - 24px), transparent)",
|
|
}}
|
|
>
|
|
{items.length === 0 ? (
|
|
<p className="text-center text-lightsec dark:text-darksec">Your cart is empty.</p>
|
|
) : (
|
|
<ul className="flex flex-col gap-4">
|
|
{items.map((item) => (
|
|
<li
|
|
key={item.id}
|
|
className="relative z-10 overflow-hidden rounded-xl bg-lightbg shadow-lg ring-1 ring-lightline transition-all duration-200 ease-in-out hover:ring-lightline-hover dark:bg-darkbg dark:ring-darkline dark:hover:ring-darkline-hover"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{/* Thumbnail + Info (wrapped in link when product_handle exists) */}
|
|
{item.product_handle ? (
|
|
<Link
|
|
href={`/release/${item.product_handle}`}
|
|
onClick={() => setDrawerOpen(false)}
|
|
className="flex min-w-0 flex-1 items-center gap-3"
|
|
>
|
|
<CartItemContent item={item} currencyCode={currencyCode} />
|
|
</Link>
|
|
) : (
|
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
<CartItemContent item={item} currencyCode={currencyCode} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Remove */}
|
|
<button
|
|
type="button"
|
|
onClick={() => removeItem(item.id)}
|
|
aria-label="Remove item"
|
|
className="mr-3 flex-shrink-0 p-2 text-sm text-lightsec transition-all duration-200 ease-in-out hover:text-trptkblue dark:text-darksec dark:hover:text-white"
|
|
>
|
|
<IoTrashOutline />
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
{items.length > 0 && (
|
|
<div className="p-6 md:p-8">
|
|
<div className="mb-4 flex flex-col gap-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-lightsec dark:text-darksec">Subtotal</span>
|
|
<span className="font-silkasb">
|
|
{formatPrice(cart?.subtotal ?? 0, currencyCode)}
|
|
</span>
|
|
</div>
|
|
{(cart?.discount_total ?? 0) > 0 && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-lightsec dark:text-darksec">Discount</span>
|
|
<span className="font-silkasb text-green-600 dark:text-green-400">
|
|
−{formatPrice(cart!.discount_total!, currencyCode)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDrawerOpen(false);
|
|
router.push("/checkout");
|
|
}}
|
|
className="w-full rounded-xl bg-trptkblue px-4 py-3 font-silkasb text-sm text-white shadow-lg transition-all duration-200 ease-in-out hover:opacity-90 dark:bg-white dark:text-lighttext"
|
|
>
|
|
Checkout
|
|
</button>
|
|
</div>
|
|
)}
|
|
</motion.aside>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|