Files
reui-blocks/blocks/product-detail6.tsx
T

1019 lines
33 KiB
TypeScript

"use client";
import "photoswipe/style.css";
import { zodResolver } from "@hookform/resolvers/zod";
import {
ChevronLeft,
ChevronRight,
Minus,
Plus,
RotateCcw,
Store,
Truck,
ZoomIn,
} from "lucide-react";
import type { PhotoSwipe } from "photoswipe/lightbox";
import PhotoSwipeLightbox from "photoswipe/lightbox";
import { useEffect, useMemo, useRef, useState } from "react";
import type {
ControllerRenderProps,
SubmitHandler,
UseFormReturn,
} from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { CarouselApi } from "@/components/ui/carousel";
import {
Carousel,
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
type StockStatusCode = "IN_STOCK" | "OUT_OF_STOCK";
interface StockInfo {
stockStatusCode?: StockStatusCode;
stockQuantity?: number;
}
interface ProductImagesProps {
images: Array<{
alt: string;
width: number;
height: number;
srcset: string;
src: string;
sizes: string;
}>;
galleryID: string;
}
interface Option {
id: string;
value: string;
label: string;
stockInfo: StockInfo;
price?: price;
}
interface Hinges {
label: string;
id: string;
name: FieldName;
options?: Option[];
min?: number;
max?: number;
}
interface ProductFormProps {
hinges: Record<FieldName, Hinges>;
onSubmit: SubmitHandler<FormType>;
stockInfo?: StockInfo;
form: UseFormReturn<z.infer<typeof formSchema>>;
selectedSize?: string;
productInfo: {
name: string;
thumbnail: {
src: string;
alt: string;
};
price?: price;
};
}
type price = {
regular?: number;
sale?: number;
currency?: string;
};
interface PriceProps extends price {
size?: "default" | "sm";
}
type FormType = z.infer<typeof formSchema>;
type FieldName = keyof FormType;
interface SizeRadioGroupProps {
options?: Array<Option>;
field: ControllerRenderProps<FormType>;
}
interface ProductInfoSectionsProps {
info: {
title: string;
content: string | string[];
}[];
}
interface RecommendedProductsProps {
products: {
name: string;
price: price;
link: string;
image: {
src: string;
alt: string;
};
category: string;
}[];
}
interface QuantityProps {
field: ControllerRenderProps<FormType>;
max?: number;
min?: number;
}
const PRODUCT_DETAILS = {
name: "کرم درخشندگی رادیانت",
size: "200ml",
thumbnail: {
src: "/block-media/shadcnblocks/block/ecommerce/skin-care-product/brittney-weng-CSquI0aw_Rg-unsplash-1.jpg",
alt: "کرم درخشندگی رادیانت",
},
images: [
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/skin-care-product/brittney-weng-CSquI0aw_Rg-unsplash-3.jpg 2400w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/brittney-weng-CSquI0aw_Rg-unsplash-2.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/brittney-weng-CSquI0aw_Rg-unsplash-1.jpg 640w",
src: "/block-media/shadcnblocks/block/ecommerce/skin-care-product/brittney-weng-CSquI0aw_Rg-unsplash-3.jpg",
alt: "کرم درخشندگی رادیانت",
width: 2400,
height: 3000,
sizes: "(max-width: 1240px) 100vw, 60vw",
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/skin-care-product/onela-ymeri-vaQgJjd9TUY-unsplash-3.jpg 2400w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/onela-ymeri-vaQgJjd9TUY-unsplash-2.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/onela-ymeri-vaQgJjd9TUY-unsplash-1.jpg 640w",
src: "/block-media/shadcnblocks/block/ecommerce/skin-care-product/onela-ymeri-vaQgJjd9TUY-unsplash-3.jpg",
alt: "کرم درخشندگی رادیانت",
width: 2400,
height: 2767,
sizes: "(max-width: 1240px) 100vw, 60vw",
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/skin-care-product/cheyenne-doig-0vpZpEQ4FFM-unsplash-3.jpg 2400w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/cheyenne-doig-0vpZpEQ4FFM-unsplash-2.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/cheyenne-doig-0vpZpEQ4FFM-unsplash-1.jpg 640w",
src: "/block-media/shadcnblocks/block/ecommerce/skin-care-product/cheyenne-doig-0vpZpEQ4FFM-unsplash-3.jpg",
alt: "کرم درخشندگی رادیانت",
width: 2400,
height: 2400,
sizes: "(max-width: 1240px) 100vw, 60vw",
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/skin-care-product/ibnu-ihza-Z7u2bpbE65Q-unsplash-3.jpg 2400w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/ibnu-ihza-Z7u2bpbE65Q-unsplash-2.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/ibnu-ihza-Z7u2bpbE65Q-unsplash-1.jpg 640w",
src: "/block-media/shadcnblocks/block/ecommerce/skin-care-product/ibnu-ihza-Z7u2bpbE65Q-unsplash-3.jpg",
alt: "کرم درخشندگی رادیانت",
width: 2400,
height: 1371,
sizes: "(max-width: 1240px) 100vw, 60vw",
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/skin-care-product/crystalweed-cannabis-aZZ-pxkC2_c-unsplash-3.jpg 2400w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/crystalweed-cannabis-aZZ-pxkC2_c-unsplash-2.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/crystalweed-cannabis-aZZ-pxkC2_c-unsplash-1.jpg 640w",
src: "/block-media/shadcnblocks/block/ecommerce/skin-care-product/crystalweed-cannabis-aZZ-pxkC2_c-unsplash-3.jpg",
alt: "کرم درخشندگی رادیانت",
width: 2400,
height: 3600,
sizes: "(max-width: 1240px) 100vw, 60vw",
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/skin-care-product/nora-hutton-AjU6Z5k_uBI-unsplash-3.jpg 2400w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/nora-hutton-AjU6Z5k_uBI-unsplash-2.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/nora-hutton-AjU6Z5k_uBI-unsplash-1.jpg 640w",
src: "/block-media/shadcnblocks/block/ecommerce/skin-care-product/nora-hutton-AjU6Z5k_uBI-unsplash-3.jpg",
alt: "کرم درخشندگی رادیانت",
width: 2400,
height: 3600,
sizes: "(max-width: 1240px) 100vw, 60vw",
},
],
features: [
{
icon: Truck,
text: "ارسال رایگان برای سفارش‌های بالای ۵۰ دلار",
},
{
icon: RotateCcw,
text: "مرجوعی آسان تا ۳۰ روز",
},
],
description:
"درخشندگی پوستتان را با این کرم سبک افزایش دهیدآبرسانی، روشن‌کنندگی و نرمی برای درخششی سالم و درخشان.",
hinges: {
size: {
label: "سایز",
id: "size",
name: "size",
options: [
{
id: "100ml",
value: "100ml",
label: "100ml",
stockInfo: {
stockStatusCode: "IN_STOCK" as StockStatusCode,
},
price: {
regular: 990000.0,
sale: 790000.0,
currency: "ریال",
},
},
{
id: "200ml",
value: "200ml",
label: "200ml",
stockInfo: {
stockStatusCode: "IN_STOCK" as StockStatusCode,
stockQuantity: 10,
},
price: {
regular: 199000.0,
currency: "ریال",
},
},
{
id: "300ml",
value: "300ml",
label: "300ml",
stockInfo: {
stockStatusCode: "OUT_OF_STOCK" as StockStatusCode,
},
price: {
regular: 199000.0,
sale: 199000.0,
currency: "ریال",
},
},
],
},
quantity: {
label: "تعداد",
id: "quantity",
name: "quantity",
min: 1,
max: 99,
},
} as Record<FieldName, Hinges>,
accordion: [
{
title: "معرفی محصول",
content:
"سرم درخشندگی رادیانت لونارا، سرمی سبک و سریع‌جذب برای روشن‌کردن، آبرسانی و یکدست‌کردن رنگ پوست است. با ۱۰٪ ویتامین C و عصاره‌های گیاهی فرموله شده و برای بازگرداندن درخشش طبیعی و محافظت در برابر عوامل محیطی ایده‌آل است.",
},
{
title: "نحوه استفاده",
content:
"هر صبح ۲ تا ۳ قطره روی پوست تمیز و خشک بزنید. به‌آرامی روی صورت و گردن ماساژ دهید، پیش از مرطوب‌کننده یا ضدآفتاب. فقط برای استفاده خارجی.",
},
{
title: "لیست کامل مواد تشکیل‌دهنده",
content: [
"Aqua (Water)",
"Ascorbic Acid (Vitamin C)",
"Sodium Hyaluronate",
"Aloe Barbadensis Leaf Juice",
"Glycerin",
"Niacinamide",
"Tocopherol (Vitamin E)",
"Citrus Aurantium Dulcis (Orange) Peel Oil",
"Potassium Sorbate",
"Sodium Benzoate",
],
},
{
title: "ارسال و مرجوعی",
content:
"ارسال استاندارد برای سفارش‌های بالای ۵۰ دلار رایگان است. مرجوعی تا ۳۰ روز پس از خرید برای محصولات بازنشده و استفاده‌نشده پذیرفته می‌شود. برای جزئیات کامل به وب‌سایت ما مراجعه کنید.",
},
],
};
const RECOMMENDED_PRODUCTS = [
{
category: "سرم",
name: "احیاگر طبیعت",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/skin-care-product/kadarius-seegars-Mxy5gokl8mE-unsplash-1.jpg",
alt: "احیاگر طبیعت",
},
link: "#",
price: {
regular: 390000.0,
sale: 290000.0,
currency: "ریال",
},
},
{
category: "کرم",
name: "کرم دست لوکس‌تاچ",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/skin-care-product/point-normal-Xp0bTCLD07k-unsplash-1.jpg",
alt: "کرم دست لوکس‌تاچ",
},
link: "#",
price: {
regular: 790000.0,
currency: "ریال",
},
},
];
interface ProductDetail6Props {
className?: string;
}
const ProductDetail6 = ({ className }: ProductDetail6Props) => {
const form = useForm<FormType>({
resolver: zodResolver(formSchema),
defaultValues: {
size: PRODUCT_DETAILS.size,
quantity: 1,
},
});
const sizeHinges = PRODUCT_DETAILS.hinges?.size;
const size = form.watch("size");
const selectedItem = useMemo(() => {
return sizeHinges?.options?.find((item) => item.value === size);
}, [size, sizeHinges]);
const price = selectedItem?.price;
const stockInfo = selectedItem?.stockInfo;
const onSubmit = (data: FormType) => {
console.log(data);
};
return (
<section className={cn("font-dm-sans px-4 py-32 lg:px-12", className)}>
<div className="mx-auto w-full max-w-[88.125rem]">
<div className="relative grid grid-cols-1 gap-7 gap-7.5 lg:grid-cols-5 xl:gap-18">
<div className="lg:col-span-3">
<ProductImages
images={PRODUCT_DETAILS.images}
galleryID="gallery-6"
/>
</div>
<div className="lg:col-span-2">
<div className="sticky top-14 flex flex-col gap-4.5">
<h1 className="text-[1.75rem] leading-tight font-medium tracking-tight xl:text-[2.5rem]">
{PRODUCT_DETAILS.name}
</h1>
<div className="flex flex-col">
<Price
regular={price?.regular}
sale={price?.sale}
currency={price?.currency}
/>
</div>
<p>{PRODUCT_DETAILS.description}</p>
<ProductForm
hinges={PRODUCT_DETAILS.hinges}
form={form}
onSubmit={onSubmit}
selectedSize={selectedItem?.label}
stockInfo={stockInfo}
productInfo={{
name: PRODUCT_DETAILS.name,
price: price,
thumbnail: PRODUCT_DETAILS.thumbnail,
}}
/>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Truck className="h-5 w-5" />
<span className="text-sm">
ارسال رایگان برای سفارش‌های بالای ۵۰ دلار
</span>
</div>
<div className="flex items-center gap-2">
<RotateCcw className="h-5 w-5" />
<span className="text-sm">مرجوعی آسان تا ۳۰ روز</span>
</div>
<div className="flex items-center gap-2">
<Store className="h-5 w-5" />
<span className="text-sm">
آماده تحویل در شعبه هاراجوکو
</span>
</div>
</div>
<RecommendedProducts products={RECOMMENDED_PRODUCTS} />
<ProductInfoSections info={PRODUCT_DETAILS.accordion} />
</div>
</div>
</div>
</div>
</section>
);
};
const ProductImages = ({ images, galleryID }: ProductImagesProps) => {
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0);
const lightboxRef = useRef<PhotoSwipeLightbox | null>(null);
useEffect(() => {
const lightbox = new PhotoSwipeLightbox({
gallery: "#" + galleryID,
children: "a",
bgOpacity: 1,
wheelToZoom: true,
arrowPrev: false,
arrowNext: false,
close: false,
zoom: false,
counter: false,
mainClass: "[&>div:first-child]:!bg-background",
pswpModule: () => import("photoswipe"),
});
lightbox.init();
lightboxRef.current = lightbox;
lightbox.on("uiRegister", () => {
if (lightbox?.pswp?.ui) {
lightbox.pswp.ui.registerElement({
name: "custom-toolbar",
order: 10,
isButton: false,
appendTo: "root",
className:
"absolute bottom-7.5 inset-x-0 z-50 mx-auto flex w-fit items-center gap-7",
html: `
<button id="pswp-prev" class=" !bg-white flex !size-12 border text-white p-2 rounded-full">
<svg class="stroke-black m-auto size-6 stroke-1" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
</button>
<button id="pswp-close" class=" !bg-white flex !size-15.5 border text-white p-2 rounded-full">
<svg class="stroke-black m-auto size-7.5 stroke-1" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
</button>
<button id="pswp-next" class=" !bg-white flex !size-12 border text-white p-2 rounded-full">
<svg class="stroke-black m-auto size-6 stroke-1" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
</button>
`,
onInit: (el, pswp) => {
el.querySelector("#pswp-prev")?.addEventListener("click", () =>
pswp.prev(),
);
el.querySelector("#pswp-next")?.addEventListener("click", () =>
pswp.next(),
);
el.querySelector("#pswp-close")?.addEventListener("click", () =>
pswp.close(),
);
(
pswp as PhotoSwipe & { customToolbarEl?: HTMLElement }
).customToolbarEl = el;
},
});
}
});
lightbox.on("close", () => {
const pswp = lightbox.pswp as PhotoSwipe & {
customToolbarEl?: HTMLElement;
};
if (pswp?.customToolbarEl) {
pswp.customToolbarEl.remove();
pswp.customToolbarEl = undefined;
}
});
return () => lightbox.destroy();
}, [galleryID]);
useEffect(() => {
if (!api) return;
const updateCurrent = () => setCurrent(api.selectedScrollSnap() + 1);
updateCurrent();
api.on("select", updateCurrent);
return () => {
api.off("select", updateCurrent);
};
}, [api]);
useEffect(() => {
if (lightboxRef.current && api) {
lightboxRef.current.on("change", () => {
api?.scrollTo(lightboxRef.current?.pswp?.currIndex || 0);
});
}
}, [api, current]);
if (!images) return;
return (
<div>
<div className="overflow-hidden rounded-3xl" id={galleryID}>
<Carousel
setApi={setApi}
className="w-full"
opts={{
loop: true,
breakpoints: {
"(min-width: 1024px)": {
active: false,
align: "start",
loop: false,
},
},
}}
>
<CarouselContent className="-ms-1 lg:grid lg:grid-cols-1 lg:gap-2.5">
{images.map((img, index) => {
return (
<CarouselItem
key={`product-detail-6-image-${index}`}
className="group w-full ps-1"
>
<AspectRatio
ratio={1}
className="w-full overflow-hidden rounded-3xl bg-muted"
>
<a
href={img.src}
data-pswp-width={img.width}
data-pswp-height={img.height}
data-pswp-srcset={img.srcset}
target="_blank"
rel="noreferrer"
data-cropped="true"
>
<img
srcSet={img.srcset}
alt={img.alt}
width={img.width}
height={img.height}
sizes={img.sizes}
className="block size-full object-cover object-center"
/>
</a>
<Badge
className="pointer-events-none absolute top-5 end-5 h-10 w-10 rounded-full bg-background p-1.5 text-xs opacity-0 transition-opacity duration-200 group-hover:opacity-100"
variant="secondary"
>
<ZoomIn />
</Badge>
</AspectRatio>
</CarouselItem>
);
})}
</CarouselContent>
</Carousel>
</div>
<div className="lg:hidden">
<div className="mt-3.5 flex items-center justify-center">
<Button variant="ghost" size="icon" onClick={() => api?.scrollPrev()}>
<ChevronLeft className="rtl:rotate-180" />
</Button>
<div className="min-w-6 text-center text-sm">
{current.toLocaleString("fa-IR")}/{images.length.toLocaleString("fa-IR")}
</div>
<Button variant="ghost" size="icon" onClick={() => api?.scrollNext()}>
<ChevronRight className="rtl:rotate-180" />
</Button>
</div>
</div>
</div>
);
};
const Price = ({ regular, sale, currency, size }: PriceProps) => {
if (!regular || !currency) return;
const formatCurrency = (
value: number,
currency: string = "ریال",
locale: string = "fa-IR",
) => {
return `${value.toLocaleString(locale)} ${currency}`;
};
return (
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
{sale && (
<div
data-sale={!!sale}
data-size={size}
aria-label="قیمت با تخفیف"
className="text-xl leading-normal font-semibold data-[sale=true]:text-red-600 data-[size=sm]:text-base"
>
{formatCurrency(sale, currency)}
</div>
)}
<div
data-sale={!!sale}
data-size={size}
aria-label="قیمت اصلی"
className="text-xl leading-relaxed font-bold data-[sale=true]:text-base data-[sale=true]:font-normal data-[sale=true]:text-muted-foreground data-[sale=true]:line-through data-[size=sm]:text-base"
>
{formatCurrency(regular, currency)}
</div>
</div>
);
};
const formSchema = z.object({
quantity: z.number().min(1).max(99),
size: z.string(),
});
const ProductForm = ({
hinges,
form,
onSubmit,
stockInfo,
selectedSize,
productInfo,
}: ProductFormProps) => {
const cartButtonRef = useRef<HTMLButtonElement>(null);
const stickyFormRef = useRef<HTMLDivElement>(null);
const sizeHinges = hinges?.size;
const quantityHinges = hinges?.quantity;
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (stickyFormRef.current) {
stickyFormRef.current.classList.toggle(
"translate-y-full",
entry.isIntersecting,
);
}
});
},
{
threshold: 0.02,
},
);
if (cartButtonRef.current) {
observer.observe(cartButtonRef.current);
}
}, []);
return (
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-7"
>
{sizeHinges && (
<Controller
control={form.control}
name={sizeHinges.name}
render={({ field }) => (
<Field>
<FieldLabel asChild className="text-sm leading-relaxed font-bold">
<h2>
{sizeHinges.label}:{" "}
<span className="font-normal">{selectedSize}</span>
</h2>
</FieldLabel>
<SizeRadioGroup field={field} options={sizeHinges.options} />
</Field>
)}
/>
)}
<div className="flex flex-wrap items-center justify-start gap-4">
{quantityHinges && (
<Controller
control={form.control}
name={quantityHinges.name}
render={({ field }) => (
<Field>
<Quantity
field={field}
max={quantityHinges?.max}
min={quantityHinges?.min}
/>
</Field>
)}
/>
)}
<div className="flex-1">
<Button
variant="secondary"
className="w-full"
disabled={stockInfo?.stockStatusCode === "OUT_OF_STOCK"}
ref={cartButtonRef}
>
افزودن به سبد
</Button>
</div>
</div>
<div className="flex w-full shrink-0 basis-full flex-col gap-6">
<Button
className="w-full"
size="lg"
disabled={stockInfo?.stockStatusCode === "OUT_OF_STOCK"}
>
خرید فوری
</Button>
</div>
<div
ref={stickyFormRef}
className="fixed inset-x-0 bottom-0 z-40 w-full translate-y-full border-t transition-all duration-300"
>
<div className="flex items-center justify-between gap-2 bg-background px-4 py-2.5">
<div className="hidden items-center gap-5 md:flex">
<div className="size-[5.625rem] overflow-hidden rounded-[0.375rem]">
<img
src={productInfo.thumbnail.src}
alt={productInfo.thumbnail.alt}
className="block size-full object-cover object-center"
/>
</div>
<div>
<div className="mb-1 text-lg font-medium">{productInfo.name}</div>
{productInfo.price ? (
<Price size="sm" {...productInfo.price} />
) : null}
</div>
</div>
<div className="flex flex-1 gap-3 md:flex-none">
{sizeHinges && (
<Controller
control={form.control}
name={sizeHinges.name}
render={({ field }) => (
<Field className="flex-1 md:flex-none">
<Select
onValueChange={field.onChange}
value={String(field.value)}
>
<SelectTrigger className="w-full min-w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sizeHinges?.options?.map((item, index) => (
<SelectItem
key={`product-6-select-size-${index}`}
value={item.id}
>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
)}
{quantityHinges && (
<Controller
control={form.control}
name={quantityHinges.name}
render={({ field }) => (
<Field className="hidden md:grid">
<Quantity
field={field}
min={quantityHinges.min}
max={quantityHinges.max}
/>
</Field>
)}
/>
)}
<div className="flex-1 md:flex-none">
<Button
className="w-full"
disabled={stockInfo?.stockStatusCode === "OUT_OF_STOCK"}
>
افزودن به سبد
</Button>
</div>
</div>
</div>
</div>
</form>
);
};
const Quantity = ({ field, max, min }: QuantityProps) => {
return (
<div className="flex h-9 w-32 items-center justify-between overflow-hidden rounded-md border shadow-xs">
<Button
onClick={() =>
field.onChange(Math.max(min || 1, Number(field.value || 1) - 1))
}
variant="ghost"
type="button"
size="icon"
className="rounded-none"
>
<Minus />
</Button>
<Input
{...field}
value={field.value ?? ""}
onChange={(e) => {
const raw = e.target.value;
const parsed = parseInt(raw, 10);
// Accept empty string for typing, but validate if not a number
if (raw === "") {
field.onChange("");
} else if (!isNaN(parsed)) {
field.onChange(parsed);
}
}}
type="number"
min={min ? min : 1}
max={max ? max : 99}
className="h-full flex-1 [appearance:textfield] rounded-none border-0 text-center focus-visible:ring-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<Button
onClick={() =>
field.onChange(Math.min(max || 99, Number(field.value || 1) + 1))
}
variant="ghost"
type="button"
size="icon"
className="rounded-none"
>
<Plus />
</Button>
</div>
);
};
const SizeRadioGroup = ({ options, field }: SizeRadioGroupProps) => {
if (!options) return;
return (
<RadioGroup
{...field}
value={`${field.value}`}
onValueChange={(value) => {
if (value != field.value && value) {
field.onChange(value);
}
}}
className="flex w-full flex-wrap items-start gap-2"
>
{options &&
options.map((item, index) => (
<div key={`product-detail-6-size-${index}`}>
<label
htmlFor={item.id}
className="relative flex h-9 min-w-10 items-center justify-center rounded-md border px-3 py-2 text-sm font-normal hover:bg-accent hover:text-accent-foreground has-checked:bg-primary has-checked:text-primary-foreground has-data-[disabled=true]:cursor-not-allowed has-data-[disabled=true]:opacity-50"
>
<RadioGroupItem
id={item.id}
className="sr-only"
value={item.value}
aria-label={`انتخاب ${item.label}`}
data-disabled={
item.stockInfo.stockStatusCode === "OUT_OF_STOCK"
}
/>
<div>{item.label}</div>
{item.stockInfo.stockStatusCode === "OUT_OF_STOCK" && (
<span className="absolute top-0 bottom-0 start-1/2 z-10 block h-full w-0 rotate-45 border-s"></span>
)}
</label>
</div>
))}
</RadioGroup>
);
};
const RecommendedProducts = ({ products }: RecommendedProductsProps) => {
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(1);
useEffect(() => {
if (!api) return;
const updateCurrent = () => setCurrent(api.selectedScrollSnap() + 1);
updateCurrent();
api.on("select", updateCurrent);
return () => {
api.off("select", updateCurrent);
};
}, [api]);
if (!products) return;
return (
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="text-base font-medium tracking-tighter xl:text-xl">
محصولات مرتبط
</div>
<div className="flex items-center">
<Button variant="ghost" size="icon" onClick={() => api?.scrollPrev()}>
<ChevronLeft className="rtl:rotate-180" />
</Button>
<div className="w-6 text-center text-sm">
{current.toLocaleString("fa-IR")}/{products.length.toLocaleString("fa-IR")}
</div>
<Button variant="ghost" size="icon" onClick={() => api?.scrollNext()}>
<ChevronRight className="rtl:rotate-180" />
</Button>
</div>
</div>
<Carousel
opts={{
loop: true,
}}
setApi={setApi}
className="w-full"
>
<CarouselContent>
{products.map((item, index) => (
<CarouselItem key={`product-detail-6-recommended-${index}`}>
<div className="rounded-[1.25rem] bg-muted p-2.5">
<div className="grid grid-cols-[minmax(0,4.125rem)_minmax(0,1fr)] items-center gap-3">
<div>
<div className="aspect-square w-full overflow-hidden rounded-[0.625rem]">
<img
src={item.image.src}
alt={item.image.alt}
className="block size-full object-cover object-center"
/>
</div>
</div>
<div className="flex flex-col gap-0.5">
<div className="text-sm text-foreground/50">
{item.category}
</div>
<a
href={item.link}
className="relative block h-fit w-fit !p-0 text-lg leading-[1.5] font-medium after:absolute after:start-0 after:bottom-[0.35rem] after:h-px after:w-full after:origin-end after:scale-x-0 after:bg-foreground after:transition-transform after:duration-200 after:content-[''] hover:after:origin-start hover:after:scale-x-100"
>
{item.name}
</a>
<div className="mt-1">
<Price
regular={item.price.regular}
sale={item.price.sale}
currency={item.price.currency}
size="sm"
/>
</div>
</div>
</div>
</div>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</div>
);
};
const ProductInfoSections = ({ info }: ProductInfoSectionsProps) => {
if (!info) return;
return (
<Accordion type="multiple" className="w-full border-b">
{info.map((item, index) => (
<AccordionItem
value={`product-info-${index}`}
key={`product-detail-6-info-${index}`}
>
<AccordionTrigger>{item.title}</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-sm leading-normal text-balance">
{Array.isArray(item.content) ? (
<ul className="list-inside list-disc ps-5">
{item.content.map((item, index) => (
<li key={`product-detail-6-info-${index}`}>{item}</li>
))}
</ul>
) : (
item.content
)}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
};
export { ProductDetail6 };