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);
|
||||
156
src/components/ecommerce/productCatalog/ProductCatalog.tsx
Normal file
156
src/components/ecommerce/productCatalog/ProductCatalog.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Input from "@/components/form/Input";
|
||||
import ProductDetailVariantSelect from "@/components/ecommerce/productDetail/ProductDetailVariantSelect";
|
||||
import type { ProductVariant } from "@/components/ecommerce/productDetail/ProductDetailCard";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { useProducts } from "@/hooks/useProducts";
|
||||
import ProductCatalogItem from "./ProductCatalogItem";
|
||||
import type { CatalogProduct } from "./ProductCatalogItem";
|
||||
|
||||
interface ProductCatalogProps {
|
||||
layout: "page" | "section";
|
||||
products?: CatalogProduct[];
|
||||
searchValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
filters?: ProductVariant[];
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
gridClassName?: string;
|
||||
cardClassName?: string;
|
||||
imageClassName?: string;
|
||||
searchClassName?: string;
|
||||
filterClassName?: string;
|
||||
toolbarClassName?: string;
|
||||
}
|
||||
|
||||
const ProductCatalog = ({
|
||||
layout,
|
||||
products: productsProp,
|
||||
searchValue = "",
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Search products...",
|
||||
filters,
|
||||
emptyMessage = "No products found",
|
||||
className = "",
|
||||
gridClassName = "",
|
||||
cardClassName = "",
|
||||
imageClassName = "",
|
||||
searchClassName = "",
|
||||
filterClassName = "",
|
||||
toolbarClassName = "",
|
||||
}: ProductCatalogProps) => {
|
||||
const router = useRouter();
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
|
||||
const handleProductClick = useCallback((productId: string) => {
|
||||
router.push(`/shop/${productId}`);
|
||||
}, [router]);
|
||||
|
||||
const products: CatalogProduct[] = useMemo(() => {
|
||||
if (productsProp && productsProp.length > 0) {
|
||||
return productsProp;
|
||||
}
|
||||
|
||||
if (fetchedProducts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fetchedProducts.map((product) => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
imageSrc: product.imageSrc,
|
||||
imageAlt: product.imageAlt || product.name,
|
||||
rating: product.rating || 0,
|
||||
reviewCount: product.reviewCount,
|
||||
category: product.brand,
|
||||
onProductClick: () => handleProductClick(product.id),
|
||||
}));
|
||||
}, [productsProp, fetchedProducts, handleProductClick]);
|
||||
|
||||
if (isLoading && (!productsProp || productsProp.length === 0)) {
|
||||
return (
|
||||
<section
|
||||
className={cls(
|
||||
"relative w-content-width mx-auto",
|
||||
layout === "page" ? "pt-hero-page-padding pb-20" : "py-20",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<p className="text-sm text-foreground/50 text-center py-20">
|
||||
Loading products...
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cls(
|
||||
"relative w-content-width mx-auto",
|
||||
layout === "page" ? "pt-hero-page-padding pb-20" : "py-20",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{(onSearchChange || (filters && filters.length > 0)) && (
|
||||
<div
|
||||
className={cls(
|
||||
"flex flex-col md:flex-row gap-4 md:items-end mb-6",
|
||||
toolbarClassName
|
||||
)}
|
||||
>
|
||||
{onSearchChange && (
|
||||
<Input
|
||||
value={searchValue}
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
ariaLabel={searchPlaceholder}
|
||||
className={cls("flex-1 w-full h-9 text-sm", searchClassName)}
|
||||
/>
|
||||
)}
|
||||
{filters && filters.length > 0 && (
|
||||
<div className="flex gap-4 items-end">
|
||||
{filters.map((filter) => (
|
||||
<ProductDetailVariantSelect
|
||||
key={filter.label}
|
||||
variant={filter}
|
||||
selectClassName={filterClassName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products.length === 0 ? (
|
||||
<p className="text-sm text-foreground/50 text-center py-20">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className={cls(
|
||||
"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6",
|
||||
gridClassName
|
||||
)}
|
||||
>
|
||||
{products.map((product) => (
|
||||
<ProductCatalogItem
|
||||
key={product.id}
|
||||
product={product}
|
||||
className={cardClassName}
|
||||
imageClassName={imageClassName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
ProductCatalog.displayName = "ProductCatalog";
|
||||
|
||||
export default memo(ProductCatalog);
|
||||
115
src/components/ecommerce/productCatalog/ProductCatalogItem.tsx
Normal file
115
src/components/ecommerce/productCatalog/ProductCatalogItem.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import ProductImage from "@/components/shared/ProductImage";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
export interface CatalogProduct {
|
||||
id: string;
|
||||
category?: string;
|
||||
name: string;
|
||||
price: string;
|
||||
rating: number;
|
||||
reviewCount?: string;
|
||||
imageSrc: string;
|
||||
imageAlt?: string;
|
||||
onProductClick?: () => void;
|
||||
onFavorite?: () => void;
|
||||
isFavorited?: boolean;
|
||||
}
|
||||
|
||||
interface ProductCatalogItemProps {
|
||||
product: CatalogProduct;
|
||||
className?: string;
|
||||
imageClassName?: string;
|
||||
categoryClassName?: string;
|
||||
nameClassName?: string;
|
||||
priceClassName?: string;
|
||||
ratingClassName?: string;
|
||||
}
|
||||
|
||||
const ProductCatalogItem = ({
|
||||
product,
|
||||
className = "",
|
||||
imageClassName = "",
|
||||
categoryClassName = "",
|
||||
nameClassName = "",
|
||||
priceClassName = "",
|
||||
ratingClassName = "",
|
||||
}: ProductCatalogItemProps) => {
|
||||
return (
|
||||
<article
|
||||
className={cls(
|
||||
"card group relative h-full flex flex-col gap-4 cursor-pointer p-4 rounded-theme-capped",
|
||||
className
|
||||
)}
|
||||
onClick={product.onProductClick}
|
||||
role="article"
|
||||
aria-label={`${product.category ? `${product.category} ` : ""}${product.name} - ${product.price}`}
|
||||
>
|
||||
<ProductImage
|
||||
imageSrc={product.imageSrc}
|
||||
imageAlt={product.imageAlt || `${product.category} ${product.name}`}
|
||||
isFavorited={product.isFavorited}
|
||||
onFavoriteToggle={product.onFavorite}
|
||||
showActionButton={true}
|
||||
className="h-70 2xl:h-80"
|
||||
actionButtonAriaLabel={`View ${product.name} details`}
|
||||
onActionClick={product.onProductClick}
|
||||
imageClassName={imageClassName}
|
||||
/>
|
||||
|
||||
<div className="relative z-1 flex-1 min-w-0 flex flex-col gap-2">
|
||||
{product.category && (
|
||||
<p className={cls("text-sm leading-tight text-foreground", categoryClassName)}>
|
||||
{product.category}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3
|
||||
className={cls(
|
||||
"text-xl font-medium truncate leading-tight text-foreground",
|
||||
nameClassName
|
||||
)}
|
||||
>
|
||||
{product.name}
|
||||
</h3>
|
||||
<div className={cls("flex items-center gap-2", ratingClassName)}>
|
||||
<div className="flex items-center gap-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={cls(
|
||||
"h-(--text-xl) w-auto",
|
||||
i < Math.floor(product.rating)
|
||||
? "text-accent fill-accent"
|
||||
: "text-accent opacity-20"
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{product.reviewCount && (
|
||||
<span className="text-sm leading-tight text-foreground">
|
||||
({product.reviewCount})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={cls(
|
||||
"text-2xl font-medium leading-tight text-foreground",
|
||||
priceClassName
|
||||
)}
|
||||
>
|
||||
{product.price}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
ProductCatalogItem.displayName = "ProductCatalogItem";
|
||||
|
||||
export default memo(ProductCatalogItem);
|
||||
255
src/components/ecommerce/productDetail/ProductDetailCard.tsx
Normal file
255
src/components/ecommerce/productDetail/ProductDetailCard.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import type { LucideIcon } 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 ProductDetailGallery from "./ProductDetailGallery";
|
||||
import ProductDetailVariantSelect from "./ProductDetailVariantSelect";
|
||||
|
||||
export interface ProductVariant {
|
||||
label: string;
|
||||
options: string[];
|
||||
selected: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
interface ProductDetailCardProps {
|
||||
layout: "page" | "section";
|
||||
name: string;
|
||||
price: string;
|
||||
salePrice?: string;
|
||||
showRating?: boolean;
|
||||
rating?: number;
|
||||
ratingIcon?: LucideIcon;
|
||||
description?: string;
|
||||
images: { src: string; alt: string }[];
|
||||
variants?: ProductVariant[];
|
||||
quantity?: ProductVariant;
|
||||
buttons: ButtonConfig[];
|
||||
ribbon?: string;
|
||||
inventoryStatus?: string;
|
||||
inventoryQuantity?: number;
|
||||
sku?: string;
|
||||
className?: string;
|
||||
imageContainerClassName?: string;
|
||||
infoContainerClassName?: string;
|
||||
nameClassName?: string;
|
||||
priceClassName?: string;
|
||||
descriptionClassName?: string;
|
||||
variantSelectClassName?: string;
|
||||
variantLabelClassName?: string;
|
||||
buttonClassName?: string;
|
||||
}
|
||||
|
||||
const ProductDetailCard = ({
|
||||
layout,
|
||||
name,
|
||||
price,
|
||||
salePrice,
|
||||
showRating = true,
|
||||
rating = 0,
|
||||
ratingIcon: RatingIcon = Star,
|
||||
description,
|
||||
images,
|
||||
variants,
|
||||
quantity,
|
||||
buttons,
|
||||
ribbon,
|
||||
inventoryStatus,
|
||||
inventoryQuantity,
|
||||
sku,
|
||||
className = "",
|
||||
imageContainerClassName = "",
|
||||
infoContainerClassName = "",
|
||||
nameClassName = "",
|
||||
priceClassName = "",
|
||||
descriptionClassName = "",
|
||||
variantSelectClassName = "",
|
||||
variantLabelClassName = "",
|
||||
buttonClassName = "",
|
||||
}: ProductDetailCardProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const getButtonConfigProps = () => {
|
||||
if (theme.defaultButtonVariant === "hover-bubble") {
|
||||
return { bgClassName: "w-full" };
|
||||
}
|
||||
if (theme.defaultButtonVariant === "icon-arrow") {
|
||||
return { className: "justify-between" };
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cls(
|
||||
"relative w-content-width mx-auto",
|
||||
layout === "page" ? "pt-hero-page-padding pb-20" : "py-20",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<ProductDetailGallery
|
||||
images={images}
|
||||
className={cls("md:w-1/2", imageContainerClassName)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cls(
|
||||
"w-full md:w-1/2 p-6 card rounded-theme-capped flex flex-col gap-6",
|
||||
infoContainerClassName
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h2
|
||||
className={cls(
|
||||
"text-2xl md:text-3xl font-medium text-foreground leading-tight flex-1",
|
||||
nameClassName
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</h2>
|
||||
{ribbon && (
|
||||
<span className="px-3 py-1 text-sm font-medium rounded-theme bg-accent text-background whitespace-nowrap">
|
||||
{ribbon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full h-px bg-background-accent" />
|
||||
<div className="w-full flex items-center justify-between gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
{salePrice ? (
|
||||
<>
|
||||
<p
|
||||
className={cls(
|
||||
"text-xl md:text-2xl font-medium text-foreground/50 line-through leading-tight",
|
||||
priceClassName
|
||||
)}
|
||||
>
|
||||
{price}
|
||||
</p>
|
||||
<p
|
||||
className={cls(
|
||||
"text-xl md:text-2xl font-medium text-accent leading-tight",
|
||||
priceClassName
|
||||
)}
|
||||
>
|
||||
{salePrice}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p
|
||||
className={cls(
|
||||
"text-xl md:text-2xl font-medium text-foreground leading-tight",
|
||||
priceClassName
|
||||
)}
|
||||
>
|
||||
{price}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{showRating && (
|
||||
<div className="flex items-center gap-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<RatingIcon
|
||||
key={i}
|
||||
className={cls(
|
||||
"h-(--text-xl) md:h-(--text-2xl) w-auto",
|
||||
i < Math.floor(rating)
|
||||
? "text-accent fill-accent"
|
||||
: "text-accent opacity-20"
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(inventoryStatus || inventoryQuantity !== undefined || sku) && (
|
||||
<div className="flex flex-wrap gap-4 text-sm text-foreground/60">
|
||||
{inventoryStatus && (
|
||||
<span className={cls(
|
||||
"px-2 py-1 rounded-theme",
|
||||
inventoryStatus === "in-stock" ? "bg-green-500/20 text-green-600" : "bg-red-500/20 text-red-600"
|
||||
)}>
|
||||
{inventoryStatus === "in-stock" ? "In Stock" : inventoryStatus}
|
||||
</span>
|
||||
)}
|
||||
{inventoryQuantity !== undefined && (
|
||||
<span>
|
||||
{inventoryQuantity} available
|
||||
</span>
|
||||
)}
|
||||
{sku && (
|
||||
<span>
|
||||
SKU: {sku}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<p
|
||||
className={cls(
|
||||
"text-sm md:text-base text-foreground/75 leading-tight",
|
||||
descriptionClassName
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{variants && variants.length > 0 && (
|
||||
<div className="flex flex-wrap gap-6">
|
||||
{variants.map((variant, index) => (
|
||||
<div
|
||||
key={variant.label}
|
||||
className={cls(
|
||||
"min-w-0",
|
||||
variants.length === 1 || (variants.length % 2 !== 0 && index === variants.length - 1)
|
||||
? "w-full"
|
||||
: "flex-1"
|
||||
)}
|
||||
>
|
||||
<ProductDetailVariantSelect
|
||||
variant={variant}
|
||||
selectClassName={variantSelectClassName}
|
||||
labelClassName={variantLabelClassName}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{quantity && (
|
||||
<ProductDetailVariantSelect
|
||||
variant={quantity}
|
||||
selectClassName={variantSelectClassName}
|
||||
labelClassName={variantLabelClassName}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 mt-auto pt-6">
|
||||
{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>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
ProductDetailCard.displayName = "ProductDetailCard";
|
||||
|
||||
export default memo(ProductDetailCard);
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import MediaContent from "@/components/shared/MediaContent";
|
||||
import { usePrevNextButtons } from "@/components/cardStack/hooks/usePrevNextButtons";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ProductDetailGalleryProps {
|
||||
images: { src: string; alt: string }[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ProductDetailGallery = ({
|
||||
images,
|
||||
className = "",
|
||||
}: ProductDetailGalleryProps) => {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
|
||||
const {
|
||||
prevBtnDisabled,
|
||||
nextBtnDisabled,
|
||||
onPrevButtonClick,
|
||||
onNextButtonClick,
|
||||
} = usePrevNextButtons(emblaApi);
|
||||
|
||||
return (
|
||||
<div className={cls("relative w-full aspect-square", className)}>
|
||||
<div className="relative overflow-hidden rounded-theme-capped cursor-grab h-full" ref={emblaRef}>
|
||||
<div className="flex h-full">
|
||||
{images.map((image, index) => (
|
||||
<div key={index} className="flex-none w-full min-w-0 h-full">
|
||||
<MediaContent
|
||||
imageSrc={image.src}
|
||||
imageAlt={image.alt}
|
||||
imageClassName="w-full h-full object-cover rounded-none!"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-5 left-5 right-5 flex justify-start gap-5" >
|
||||
<button
|
||||
onClick={onPrevButtonClick}
|
||||
disabled={prevBtnDisabled}
|
||||
className="secondary-button h-8 aspect-square flex items-center justify-center rounded-theme cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
aria-label="Previous image"
|
||||
>
|
||||
<ChevronLeft className="h-[40%] w-auto aspect-square text-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onNextButtonClick}
|
||||
disabled={nextBtnDisabled}
|
||||
className="secondary-button h-8 aspect-square flex items-center justify-center rounded-theme cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
aria-label="Next image"
|
||||
>
|
||||
<ChevronRight className="h-[40%] w-auto aspect-square text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProductDetailGallery.displayName = "ProductDetailGallery";
|
||||
|
||||
export default memo(ProductDetailGallery);
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import type { ProductVariant } from "./ProductDetailCard";
|
||||
|
||||
interface ProductDetailVariantSelectProps {
|
||||
variant: ProductVariant;
|
||||
selectClassName?: string;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
const ProductDetailVariantSelect = ({
|
||||
variant,
|
||||
selectClassName = "",
|
||||
labelClassName = "",
|
||||
}: ProductDetailVariantSelectProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className={cls(
|
||||
"text-sm font-medium text-foreground",
|
||||
labelClassName
|
||||
)}
|
||||
>
|
||||
{variant.label}:
|
||||
</label>
|
||||
<div className="relative secondary-button rounded-theme h-9">
|
||||
<select
|
||||
value={variant.selected}
|
||||
onChange={(e) => variant.onChange(e.target.value)}
|
||||
aria-label={variant.label}
|
||||
className={cls(
|
||||
"relative z-1 w-full h-full px-4 text-sm text-foreground focus:outline-none appearance-none cursor-pointer",
|
||||
selectClassName
|
||||
)}
|
||||
>
|
||||
{variant.options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 h-(--text-sm) w-auto text-foreground pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProductDetailVariantSelect.displayName = "ProductDetailVariantSelect";
|
||||
|
||||
export default memo(ProductDetailVariantSelect);
|
||||
Reference in New Issue
Block a user