trptk/components/cart/CartDrawer.tsx
2026-02-24 17:14:07 +01:00

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">
&minus;{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>
);
}