Initial commit
This commit is contained in:
191
src/components/ecommerce/cart/ProductCart.tsx
Normal file
191
src/components/ecommerce/cart/ProductCart.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import Button from "@/components/button/Button";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import { getButtonProps } from "@/lib/buttonUtils";
|
||||
import { cls } from "@/lib/utils";
|
||||
import type { ButtonConfig } from "@/types/button";
|
||||
import AnimationContainer from "@/components/sections/AnimationContainer";
|
||||
import ProductCartItem from "./ProductCartItem";
|
||||
import type { CartItem } from "./ProductCartItem";
|
||||
|
||||
interface ProductCartProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
items: CartItem[];
|
||||
onQuantityChange?: (id: string, quantity: number) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
total: string;
|
||||
buttons: ButtonConfig[];
|
||||
title?: string;
|
||||
totalLabel?: string;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
panelClassName?: string;
|
||||
itemClassName?: string;
|
||||
buttonClassName?: string;
|
||||
}
|
||||
|
||||
const ProductCart = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
items,
|
||||
onQuantityChange,
|
||||
onRemove,
|
||||
total,
|
||||
buttons,
|
||||
title = "Cart",
|
||||
totalLabel = "Total",
|
||||
emptyMessage = "Your cart is empty",
|
||||
className = "",
|
||||
panelClassName = "",
|
||||
itemClassName = "",
|
||||
buttonClassName = "",
|
||||
}: ProductCartProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const getButtonConfigProps = () => {
|
||||
if (theme.defaultButtonVariant === "hover-bubble") {
|
||||
return { bgClassName: "w-full" };
|
||||
}
|
||||
if (theme.defaultButtonVariant === "icon-arrow") {
|
||||
return { className: "justify-between" };
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"fixed inset-0 z-[100] pointer-events-none",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute inset-0 bg-foreground/50 transition-opacity duration-500 ease-[cubic-bezier(0.625,0.05,0,1)]",
|
||||
isOpen
|
||||
? "opacity-100 pointer-events-auto"
|
||||
: "opacity-0 pointer-events-none"
|
||||
)}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cls(
|
||||
"absolute right-0 top-0 h-screen overflow-hidden transition-[width] duration-500 ease-[cubic-bezier(0.625,0.05,0,1)]",
|
||||
isOpen
|
||||
? "w-full md:w-30 pointer-events-auto"
|
||||
: "w-0 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<aside
|
||||
className={cls(
|
||||
"w-full md:w-30 h-full card flex flex-col p-6",
|
||||
panelClassName
|
||||
)}
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-medium text-foreground whitespace-nowrap">
|
||||
{title} ({items.length})
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="secondary-button h-8 aspect-square rounded-theme flex items-center justify-center cursor-pointer flex-shrink-0"
|
||||
aria-label="Close cart"
|
||||
type="button"
|
||||
>
|
||||
<X className="relative h-4/10 text-foreground" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-background-accent mt-6" />
|
||||
|
||||
<div className="flex-1 min-h-0 h-full overflow-y-auto mask-fade-y" data-lenis-prevent>
|
||||
<AnimationContainer
|
||||
key={items.map((i) => i.id).join("-")}
|
||||
className="w-full h-full"
|
||||
animationType="fade"
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center" >
|
||||
<p className="text-sm text-foreground/50 text-center py-10 whitespace-nowrap">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6 py-6">
|
||||
{items.map((item) => (
|
||||
<ProductCartItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onQuantityChange={onQuantityChange}
|
||||
onRemove={onRemove}
|
||||
className={itemClassName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AnimationContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="w-full h-px bg-background-accent" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-base font-medium text-foreground whitespace-nowrap">
|
||||
{totalLabel}
|
||||
</span>
|
||||
<span className="text-base font-medium text-foreground whitespace-nowrap">
|
||||
{total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{buttons.slice(0, 2).map((button, index) => (
|
||||
<Button
|
||||
key={`${button.text}-${index}`}
|
||||
{...getButtonProps(
|
||||
{ ...button, props: { ...button.props, ...getButtonConfigProps() } },
|
||||
index,
|
||||
theme.defaultButtonVariant,
|
||||
cls("w-full", buttonClassName)
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProductCart.displayName = "ProductCart";
|
||||
|
||||
export default memo(ProductCart);
|
||||
106
src/components/ecommerce/cart/ProductCartItem.tsx
Normal file
106
src/components/ecommerce/cart/ProductCartItem.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback } from "react";
|
||||
import { Plus, Minus, Trash2 } from "lucide-react";
|
||||
import MediaContent from "@/components/shared/MediaContent";
|
||||
import QuantityButton from "@/components/shared/QuantityButton";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
export interface CartItem {
|
||||
id: string;
|
||||
name: string;
|
||||
variants?: string[];
|
||||
price: string;
|
||||
quantity: number;
|
||||
imageSrc: string;
|
||||
imageAlt?: string;
|
||||
}
|
||||
|
||||
interface ProductCartItemProps {
|
||||
item: CartItem;
|
||||
onQuantityChange?: (id: string, quantity: number) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ProductCartItem = ({
|
||||
item,
|
||||
onQuantityChange,
|
||||
onRemove,
|
||||
className = "",
|
||||
}: ProductCartItemProps) => {
|
||||
const handleIncrement = useCallback(() => {
|
||||
onQuantityChange?.(item.id, item.quantity + 1);
|
||||
}, [item.id, item.quantity, onQuantityChange]);
|
||||
|
||||
const handleDecrement = useCallback(() => {
|
||||
if (item.quantity <= 1) return;
|
||||
onQuantityChange?.(item.id, item.quantity - 1);
|
||||
}, [item.id, item.quantity, onQuantityChange]);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove?.(item.id);
|
||||
}, [item.id, onRemove]);
|
||||
|
||||
return (
|
||||
<div className={cls("flex gap-6", className)}>
|
||||
<div className="relative w-1/2 h-auto aspect-square flex-shrink-0 rounded-theme-capped overflow-hidden">
|
||||
<MediaContent
|
||||
imageSrc={item.imageSrc}
|
||||
imageAlt={item.imageAlt || item.name}
|
||||
imageClassName="w-full h-full object-cover rounded-none!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-between gap-2">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-medium text-foreground truncate">
|
||||
{item.name}
|
||||
</h3>
|
||||
{item.variants && item.variants.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{item.variants.map((variant) => (
|
||||
<p key={variant} className="text-sm text-foreground/50">
|
||||
{variant}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base font-medium text-foreground flex-shrink-0">
|
||||
{item.price}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<QuantityButton
|
||||
onClick={handleDecrement}
|
||||
ariaLabel="Decrease quantity"
|
||||
Icon={Minus}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground min-w-5 text-center">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<QuantityButton
|
||||
onClick={handleIncrement}
|
||||
ariaLabel="Increase quantity"
|
||||
Icon={Plus}
|
||||
/>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="secondary-button h-8 aspect-square rounded-theme flex items-center justify-center cursor-pointer ml-auto"
|
||||
aria-label={`Remove ${item.name} from cart`}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="relative h-4/10 text-foreground" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProductCartItem.displayName = "ProductCartItem";
|
||||
|
||||
export default memo(ProductCartItem);
|
||||
Reference in New Issue
Block a user