Initial commit
This commit is contained in:
126
src/components/ecommerce/ProductCart.tsx
Normal file
126
src/components/ecommerce/ProductCart.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect } from "react";
|
||||
import { X, Plus, Minus, Trash2 } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type CartItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
quantity: number;
|
||||
imageSrc: string;
|
||||
};
|
||||
|
||||
type ProductCartProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
items: CartItem[];
|
||||
total: string;
|
||||
onQuantityChange?: (id: string, quantity: number) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
onCheckout?: () => void;
|
||||
};
|
||||
|
||||
const ProductCart = ({ isOpen, onClose, items, total, onQuantityChange, onRemove, onCheckout }: ProductCartProps) => {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = isOpen ? "hidden" : "";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-1001">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="absolute inset-0 bg-foreground/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<motion.aside
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="card absolute right-0 top-0 flex flex-col p-5 h-screen w-screen md:w-96"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-medium text-foreground">Cart ({items.length})</h2>
|
||||
<button onClick={onClose} className="card flex items-center justify-center size-8 rounded cursor-pointer" aria-label="Close cart">
|
||||
<X className="size-4 text-foreground" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 h-px w-full bg-foreground/10" />
|
||||
|
||||
<div className="flex-1 py-5 min-h-0 overflow-y-auto">
|
||||
{items.length === 0 ? (
|
||||
<p className="py-20 text-center text-sm text-foreground/50">Your cart is empty</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex gap-4">
|
||||
<div className="shrink-0 size-24 overflow-hidden rounded">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} className="size-full object-cover" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-between min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-base font-medium text-foreground truncate">{item.name}</h3>
|
||||
<p className="shrink-0 text-base font-medium text-foreground">{item.price}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => item.quantity > 1 && onQuantityChange?.(item.id, item.quantity - 1)}
|
||||
className="card flex items-center justify-center size-8 rounded cursor-pointer"
|
||||
>
|
||||
<Minus className="size-4 text-foreground" strokeWidth={1.5} />
|
||||
</button>
|
||||
<span className="min-w-5 text-center text-sm font-medium text-foreground">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => onQuantityChange?.(item.id, item.quantity + 1)}
|
||||
className="card flex items-center justify-center size-8 rounded cursor-pointer"
|
||||
>
|
||||
<Plus className="size-4 text-foreground" strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemove?.(item.id)}
|
||||
className="card flex items-center justify-center ml-auto size-8 rounded cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-4 text-foreground" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="h-px w-full bg-foreground/10" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-base font-medium text-foreground">Total</span>
|
||||
<span className="text-base font-medium text-foreground">{total}</span>
|
||||
</div>
|
||||
<Button text="Checkout" onClick={onCheckout} variant="primary" className="w-full" />
|
||||
</div>
|
||||
</motion.aside>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCart;
|
||||
export type { CartItem };
|
||||
142
src/components/ecommerce/ProductCatalog.tsx
Normal file
142
src/components/ecommerce/ProductCatalog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Star, ArrowUpRight, Loader2 } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import useProducts from "@/hooks/useProducts";
|
||||
import type { ProductVariant } from "./ProductDetailCard";
|
||||
|
||||
type CatalogProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
imageSrc: string;
|
||||
category?: string;
|
||||
rating?: number;
|
||||
reviewCount?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type ProductCatalogProps = {
|
||||
products?: CatalogProduct[];
|
||||
searchValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
filters?: ProductVariant[];
|
||||
};
|
||||
|
||||
const ProductCatalog = ({ products: productsProp, searchValue = "", onSearchChange, filters }: ProductCatalogProps) => {
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
|
||||
const products: CatalogProduct[] = productsProp && productsProp.length > 0
|
||||
? productsProp
|
||||
: fetchedProducts.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
price: p.price,
|
||||
imageSrc: p.imageSrc,
|
||||
category: p.brand,
|
||||
rating: p.rating,
|
||||
reviewCount: p.reviewCount,
|
||||
onClick: p.onProductClick,
|
||||
}));
|
||||
|
||||
if (isLoading && (!productsProp || productsProp.length === 0)) {
|
||||
return (
|
||||
<section className="mx-auto py-20 w-content-width">
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-8 text-foreground animate-spin" strokeWidth={1.5} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto py-20 w-content-width">
|
||||
{(onSearchChange || (filters && filters.length > 0)) && (
|
||||
<div className="flex flex-col gap-5 mb-5 md:flex-row md:items-end">
|
||||
{onSearchChange && (
|
||||
<div className="flex flex-1 flex-col gap-2 min-w-32">
|
||||
<label className="text-sm font-medium text-foreground">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search products..."
|
||||
className="card px-4 h-9 w-full md:w-80 text-base text-foreground bg-transparent rounded focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{filters && filters.length > 0 && (
|
||||
<div className="flex gap-5 items-end">
|
||||
{filters.map((filter) => (
|
||||
<div key={filter.label} className="flex flex-col gap-2 min-w-32">
|
||||
<label className="text-sm font-medium text-foreground">{filter.label}</label>
|
||||
<div className="secondary-button flex items-center px-3 h-9 rounded">
|
||||
<select
|
||||
value={filter.selected}
|
||||
onChange={(e) => filter.onChange(e.target.value)}
|
||||
className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none"
|
||||
>
|
||||
{filter.options.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products.length === 0 ? (
|
||||
<p className="py-20 text-center text-sm text-foreground/50">No products found</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{products.map((product) => (
|
||||
<button
|
||||
key={product.id}
|
||||
onClick={product.onClick}
|
||||
className="card group h-full flex flex-col gap-3 p-3 text-left rounded cursor-pointer"
|
||||
>
|
||||
<div className="relative aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={product.imageSrc} className="size-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
||||
<div className="absolute inset-0 flex items-center justify-center transition-all duration-300 group-hover:bg-background/20 group-hover:backdrop-blur-xs">
|
||||
<div className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 scale-75 transition-all duration-300 group-hover:opacity-100 group-hover:scale-100">
|
||||
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{product.category && (
|
||||
<span className="secondary-button w-fit px-2 py-0.5 text-sm text-secondary-cta-text rounded">{product.category}</span>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-xl font-medium text-foreground truncate">{product.name}</h3>
|
||||
{product.rating && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={cls("size-4 text-accent", i < Math.floor(product.rating || 0) ? "fill-accent" : "opacity-20")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{product.reviewCount && (
|
||||
<span className="text-sm text-foreground">({product.reviewCount})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-2xl font-medium text-foreground">{product.price}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCatalog;
|
||||
export type { CatalogProduct };
|
||||
137
src/components/ecommerce/ProductDetailCard.tsx
Normal file
137
src/components/ecommerce/ProductDetailCard.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Transition from "@/components/ui/Transition";
|
||||
|
||||
type ProductVariant = {
|
||||
label: string;
|
||||
options: string[];
|
||||
selected: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
type ProductDetailCardProps = {
|
||||
name: string;
|
||||
price: string;
|
||||
salePrice?: string;
|
||||
images: string[];
|
||||
description?: string;
|
||||
rating?: number;
|
||||
ribbon?: string;
|
||||
inventoryStatus?: "in-stock" | "out-of-stock";
|
||||
inventoryQuantity?: number;
|
||||
sku?: string;
|
||||
variants?: ProductVariant[];
|
||||
quantity?: ProductVariant;
|
||||
onAddToCart?: () => void;
|
||||
onBuyNow?: () => void;
|
||||
};
|
||||
|
||||
const ProductDetailCard = ({ name, price, salePrice, images, description, rating = 0, ribbon, inventoryStatus, inventoryQuantity, sku, variants, quantity, onAddToCart, onBuyNow }: ProductDetailCardProps) => {
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
|
||||
return (
|
||||
<section className="mx-auto py-20 w-content-width">
|
||||
<div className="flex flex-col gap-5 md:flex-row">
|
||||
<div className="relative md:w-1/2">
|
||||
<Transition key={selectedImage} className="card aspect-square overflow-hidden rounded" transitionType="fade" whileInView={false}>
|
||||
<ImageOrVideo imageSrc={images[selectedImage]} className="size-full object-cover" />
|
||||
</Transition>
|
||||
{images.length > 1 && (
|
||||
<div className="absolute right-3 top-0 bottom-0 flex flex-col gap-3 py-3 overflow-y-auto mask-fade-y">
|
||||
{images.map((src, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedImage(i)}
|
||||
className="group card relative shrink-0 size-16 overflow-hidden rounded cursor-pointer"
|
||||
>
|
||||
<ImageOrVideo imageSrc={src} className="size-full object-cover transition-transform duration-300 group-hover:scale-110" />
|
||||
<div className={cls(
|
||||
"absolute top-1 right-1 primary-button size-3 rounded-full transition-transform duration-300",
|
||||
selectedImage === i ? "scale-100" : "scale-0 group-hover:scale-100"
|
||||
)} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card flex flex-col gap-5 p-5 md:w-1/2 rounded">
|
||||
<div className="flex items-start justify-between gap-5">
|
||||
<h2 className="flex-1 text-2xl font-medium text-foreground md:text-3xl">{name}</h2>
|
||||
{ribbon && <span className="secondary-button shrink-0 px-3 py-1 text-sm font-medium rounded text-secondary-cta-text">{ribbon}</span>}
|
||||
</div>
|
||||
<div className="h-px w-full bg-foreground/10" />
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xl font-medium text-foreground md:text-2xl">
|
||||
{salePrice ? (
|
||||
<>
|
||||
<span className="text-foreground/75 line-through mr-1">{price}</span>
|
||||
<span>{salePrice}</span>
|
||||
</>
|
||||
) : (
|
||||
price
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star key={i} className={cls("size-5 text-accent", i < Math.floor(rating) ? "fill-accent" : "opacity-20")} strokeWidth={1.5} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(inventoryStatus || inventoryQuantity || sku) && (
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
{inventoryStatus && (
|
||||
<span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">
|
||||
{inventoryStatus === "in-stock" ? "In Stock" : "Out of Stock"}
|
||||
</span>
|
||||
)}
|
||||
{inventoryQuantity && (
|
||||
<span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">{inventoryQuantity} available</span>
|
||||
)}
|
||||
{sku && <span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">SKU: {sku}</span>}
|
||||
</div>
|
||||
)}
|
||||
{description && <p className="text-sm text-foreground/75 md:text-base">{description}</p>}
|
||||
{variants && variants.length > 0 && (
|
||||
<div className="flex flex-wrap gap-5">
|
||||
{variants.map((variant) => (
|
||||
<div key={variant.label} className="flex flex-1 flex-col gap-2 min-w-32">
|
||||
<label className="text-sm font-medium text-foreground">{variant.label}</label>
|
||||
<div className="secondary-button flex items-center px-3 h-9 rounded">
|
||||
<select value={variant.selected} onChange={(e) => variant.onChange(e.target.value)} className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none">
|
||||
{variant.options.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{quantity && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-foreground">{quantity.label}</label>
|
||||
<div className="secondary-button flex items-center px-3 h-9 w-24 rounded">
|
||||
<select value={quantity.selected} onChange={(e) => quantity.onChange(e.target.value)} className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none">
|
||||
{quantity.options.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col mt-auto gap-3 pt-5">
|
||||
<Button text="Add To Cart" onClick={onAddToCart} variant="primary" className="w-full" />
|
||||
<Button text="Buy Now" onClick={onBuyNow} variant="secondary" className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailCard;
|
||||
export type { ProductVariant };
|
||||
Reference in New Issue
Block a user