825 lines
25 KiB
TypeScript
825 lines
25 KiB
TypeScript
"use client";
|
|
import "photoswipe/style.css";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { Check, Star, StarHalf } from "lucide-react";
|
|
import PhotoSwipeLightbox from "photoswipe/lightbox";
|
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { ControllerRenderProps, 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 {
|
|
Carousel,
|
|
type CarouselApi,
|
|
CarouselContent,
|
|
CarouselItem,
|
|
CarouselNext,
|
|
CarouselPrevious,
|
|
} from "@/components/ui/carousel";
|
|
import { Field } from "@/components/ui/field";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type StockStatusCode = "IN_STOCK" | "OUT_OF_STOCK" | "LOW_STOCK";
|
|
|
|
interface StockInfo {
|
|
stockStatusCode: StockStatusCode;
|
|
stockQuantity?: number;
|
|
}
|
|
|
|
interface StockMessageProps {
|
|
stockInfo?: StockInfo;
|
|
}
|
|
|
|
type FormType = z.infer<typeof formSchema>;
|
|
type FieldName = keyof FormType;
|
|
|
|
interface ReviewsProps {
|
|
rate: number;
|
|
totalReviewers: string;
|
|
}
|
|
|
|
type Option = {
|
|
label: string;
|
|
id: string;
|
|
value: string;
|
|
stockInfo: StockInfo;
|
|
image?: {
|
|
src: string;
|
|
alt: string;
|
|
};
|
|
};
|
|
|
|
interface SizeRadioGroupProps {
|
|
options?: Array<Option>;
|
|
label: string;
|
|
field: ControllerRenderProps<FormType>;
|
|
}
|
|
|
|
interface ColorRadioGroupProps {
|
|
options?: Array<Option>;
|
|
label: string;
|
|
field: ControllerRenderProps<FormType>;
|
|
}
|
|
|
|
interface Hinges {
|
|
label: string;
|
|
id: string;
|
|
name: FieldName;
|
|
options?: Option[];
|
|
}
|
|
|
|
interface ProductForm {
|
|
hinges: Record<FieldName, Hinges>;
|
|
onSubmit: (data: FormType) => void;
|
|
stockInfo?: StockInfo;
|
|
form: UseFormReturn<z.infer<typeof formSchema>>;
|
|
}
|
|
|
|
interface ProductInfoSectionProps {
|
|
info: Array<{
|
|
title: string;
|
|
content: string;
|
|
}>;
|
|
}
|
|
|
|
interface ProductImagesProps {
|
|
images: Array<{
|
|
src: string;
|
|
alt: string;
|
|
width: number;
|
|
height: number;
|
|
srcset: string;
|
|
sizes: string;
|
|
thumbnail: string;
|
|
}>;
|
|
galleryID: string;
|
|
availability?: boolean;
|
|
discount?: string;
|
|
}
|
|
|
|
const MAX_STARS = 5;
|
|
|
|
const STATUS_CONFIG = {
|
|
IN_STOCK: {
|
|
color: "green",
|
|
message: "موجود امروز تا ساعت ۲۳ سفارش دهید، امروز ارسال میشود",
|
|
},
|
|
LOW_STOCK: {
|
|
color: "amber",
|
|
getMessage: (qty?: number) =>
|
|
qty
|
|
? `موجودی کم - ${qty.toLocaleString("fa-IR")} عدد باقی مانده`
|
|
: "موجودی کم - چند عدد باقی مانده",
|
|
},
|
|
OUT_OF_STOCK: {
|
|
color: "red",
|
|
message: "ناموجود تحویل ظرف ۱۴ روز",
|
|
},
|
|
};
|
|
|
|
const PRODUCT_DETAILS = {
|
|
name: "پیراهن گلدوزی اسب داکوتا وسترن",
|
|
color: "white",
|
|
size: "34",
|
|
reviews: {
|
|
rate: 3.5,
|
|
totalReviewers: "۵٫۸ هزار",
|
|
},
|
|
price: {
|
|
regular: 100000.0,
|
|
sale: 300000.0,
|
|
currency: "ریال",
|
|
discount: "-۷۰٪",
|
|
},
|
|
images: [
|
|
{
|
|
srcset:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/Woman-in-Embroidered-Blue-Top-2.png 1024w, /block-media/shadcnblocks/block/ecommerce/clothes/Woman-in-Embroidered-Blue-Top-1.png 1024w",
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Woman-in-Embroidered-Blue-Top-2.png",
|
|
alt: "پیراهن گلدوزی اسب داکوتا وسترن",
|
|
width: 1024,
|
|
height: 1536,
|
|
sizes: "(max-width: 1240px) 100vw, 60vw",
|
|
thumbnail:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/Woman-in-Embroidered-Blue-Top-1.png",
|
|
},
|
|
{
|
|
srcset:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/Elegant-Professional-Look-2.png 1024w, /block-media/shadcnblocks/block/ecommerce/clothes/Elegant-Professional-Look-1.png 1024w",
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Elegant-Professional-Look-2.png",
|
|
alt: "پیراهن گلدوزی اسب داکوتا وسترن",
|
|
width: 1024,
|
|
height: 1536,
|
|
sizes: "(max-width: 1240px) 100vw, 60vw",
|
|
thumbnail:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/Elegant-Professional-Look-1.png",
|
|
},
|
|
{
|
|
srcset:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/Chic-Fashion-Portrait-2.png 1086w",
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Chic-Fashion-Portrait-2.png",
|
|
alt: "پیراهن گلدوزی اسب داکوتا وسترن",
|
|
width: 1086,
|
|
height: 1448,
|
|
sizes: "(max-width: 1240px) 100vw, 60vw",
|
|
thumbnail:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/Chic-Fashion-Portrait-2.png",
|
|
},
|
|
],
|
|
hinges: {
|
|
size: {
|
|
label: "سایز",
|
|
id: "size",
|
|
name: "size",
|
|
options: [
|
|
{
|
|
id: "34",
|
|
value: "34",
|
|
label: "34",
|
|
stockInfo: {
|
|
stockStatusCode: "IN_STOCK" as StockStatusCode,
|
|
},
|
|
},
|
|
{
|
|
id: "36",
|
|
value: "36",
|
|
label: "36",
|
|
stockInfo: {
|
|
stockStatusCode: "LOW_STOCK" as StockStatusCode,
|
|
},
|
|
},
|
|
{
|
|
id: "38",
|
|
value: "38",
|
|
label: "38",
|
|
stockInfo: {
|
|
stockStatusCode: "OUT_OF_STOCK" as StockStatusCode,
|
|
},
|
|
},
|
|
{
|
|
id: "40",
|
|
value: "40",
|
|
label: "40",
|
|
stockInfo: {
|
|
stockStatusCode: "OUT_OF_STOCK" as StockStatusCode,
|
|
},
|
|
},
|
|
{
|
|
id: "42",
|
|
value: "42",
|
|
label: "42",
|
|
stockInfo: {
|
|
stockStatusCode: "OUT_OF_STOCK" as StockStatusCode,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
color: {
|
|
label: "رنگ",
|
|
id: "color",
|
|
name: "color",
|
|
options: [
|
|
{
|
|
id: "white",
|
|
value: "white",
|
|
label: "سفید",
|
|
image: {
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Elegant-Professional-Look-1.png",
|
|
alt: "سفید",
|
|
},
|
|
stockInfo: {
|
|
stockStatusCode: "IN_STOCK" as StockStatusCode,
|
|
},
|
|
},
|
|
{
|
|
id: "pink",
|
|
value: "pink",
|
|
label: "صورتی",
|
|
image: {
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Chic-Fashion-Portrait-2.png",
|
|
alt: "صورتی",
|
|
},
|
|
stockInfo: {
|
|
stockStatusCode: "IN_STOCK" as StockStatusCode,
|
|
},
|
|
},
|
|
{
|
|
id: "blue",
|
|
value: "blue",
|
|
label: "آبی",
|
|
image: {
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Woman-in-Embroidered-Blue-Top-2.png",
|
|
alt: "آبی",
|
|
},
|
|
stockInfo: {
|
|
stockStatusCode: "OUT_OF_STOCK" as StockStatusCode,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
} as Record<FieldName, Hinges>,
|
|
summary:
|
|
"استایل خود را از روزمره تا مجلسی بهسرعت تغییر دهید و برای هر موقعیتی آماده باشید.",
|
|
services: [
|
|
"ارسال رایگان برای خریدهای بالای ۵۰ یورو",
|
|
"تحویل فردا با ارسال روز بعد",
|
|
],
|
|
accordion: [
|
|
{
|
|
title: "توضیحات",
|
|
content:
|
|
"پیراهن دکمهدار با برش مدرن و پارچه نرم پنبهای. مناسب برای محیط کار و جلسات رسمی. طراحی مینیمال با جزئیات دوخت دقیق و بستهشدنهای دکمهای کلاسیک. برای حرفهایهایی که به دنبال استایل شیک و راحت در محیط اداری هستند.",
|
|
},
|
|
{
|
|
title: "جنس و نگهداری",
|
|
content:
|
|
"این لباس از ۸۰٪ ویسکوز و ۲۰٪ پلیاستر ساخته شده است. برای حفظ بهترین حالت، شستشوی دستی با آب سرد توصیه میشود. از سفیدکننده استفاده نکنید و بهجای خشککن، لباس را صاف روی سطح خشک کنید. در صورت نیاز با دمای پایین اتو کنید. برای خشکشویی ملایم نیز مناسب است.",
|
|
},
|
|
{
|
|
title: "ارسال و مرجوعی",
|
|
content:
|
|
"زمان ارسال، سیاست مرجوعی و نحوه تعویض کالا را اینجا بخوانید. در تمام مراحل همراه شما هستیم.",
|
|
},
|
|
],
|
|
};
|
|
|
|
interface ProductDetail8Props {
|
|
className?: string;
|
|
}
|
|
|
|
const ProductDetail8 = ({ className }: ProductDetail8Props) => {
|
|
const form = useForm<FormType>({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: {
|
|
color: PRODUCT_DETAILS.color,
|
|
size: PRODUCT_DETAILS.size,
|
|
},
|
|
});
|
|
|
|
const onSubmit = (data: FormType) => {
|
|
console.log(data);
|
|
};
|
|
|
|
const size = form.watch("size");
|
|
|
|
const hinges = PRODUCT_DETAILS.hinges;
|
|
const stockInfo = useMemo(() => {
|
|
return hinges?.size?.options?.find((item) => item.value === size)
|
|
?.stockInfo;
|
|
}, [hinges, size]);
|
|
|
|
const formatCurrency = (
|
|
value: number,
|
|
currency: string = "ریال",
|
|
locale: string = "fa-IR",
|
|
) => {
|
|
return `${value.toLocaleString(locale)} ${currency}`;
|
|
};
|
|
|
|
return (
|
|
<section className={cn("py-32", className)}>
|
|
<div className="mx-auto w-full max-w-415 px-7.5">
|
|
<div className="relative mt-5.5 grid grid-cols-1 gap-6 lg:grid-cols-5 lg:gap-18.5">
|
|
<div className="lg:col-span-3">
|
|
<ProductImages
|
|
images={PRODUCT_DETAILS.images}
|
|
galleryID="product-detail-8-gallery"
|
|
availability={!(stockInfo?.stockStatusCode === "OUT_OF_STOCK")}
|
|
discount={PRODUCT_DETAILS.price.discount}
|
|
/>
|
|
</div>
|
|
<div className="sticky top-10 self-start lg:col-span-2">
|
|
<div className="flex flex-col gap-6">
|
|
<div className="flex flex-col gap-2.5">
|
|
<h1 className="text-xl leading-normal font-bold">
|
|
{PRODUCT_DETAILS.name}
|
|
</h1>
|
|
<Reviews
|
|
rate={PRODUCT_DETAILS.reviews.rate}
|
|
totalReviewers={PRODUCT_DETAILS.reviews.totalReviewers}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-wrap items-baseline gap-1 text-lg">
|
|
<div
|
|
data-sale={!!PRODUCT_DETAILS.price.sale}
|
|
aria-label="قیمت اصلی"
|
|
className="data-[sale=true]:text-red-600 data-[sale=true]:line-through"
|
|
>
|
|
{formatCurrency(
|
|
PRODUCT_DETAILS.price.regular,
|
|
PRODUCT_DETAILS.price.currency,
|
|
)}
|
|
</div>
|
|
{PRODUCT_DETAILS.price.sale ? (
|
|
<div aria-label="قیمت با تخفیف" className="text-primary">
|
|
{formatCurrency(
|
|
PRODUCT_DETAILS.price.sale,
|
|
PRODUCT_DETAILS.price.currency,
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<p className="text-sm leading-[1.8]">{PRODUCT_DETAILS.summary}</p>
|
|
<ProductForm
|
|
hinges={PRODUCT_DETAILS.hinges}
|
|
form={form}
|
|
onSubmit={onSubmit}
|
|
stockInfo={stockInfo}
|
|
/>
|
|
<ServicesList services={PRODUCT_DETAILS.services} />
|
|
<ProductInfoSection info={PRODUCT_DETAILS.accordion} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
const ProductImages = ({
|
|
images,
|
|
galleryID,
|
|
availability,
|
|
discount,
|
|
}: 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,
|
|
pswpModule: () => import("photoswipe"),
|
|
});
|
|
lightbox.init();
|
|
lightboxRef.current = lightbox;
|
|
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]);
|
|
|
|
if (!images) return;
|
|
|
|
return (
|
|
<div className="pswp-gallery" id={galleryID}>
|
|
<div className="relative">
|
|
<Carousel
|
|
opts={{
|
|
loop: true,
|
|
breakpoints: {
|
|
"(min-width: 1024px)": {
|
|
active: false,
|
|
align: "start",
|
|
loop: false,
|
|
},
|
|
},
|
|
}}
|
|
setApi={setApi}
|
|
className="w-full"
|
|
>
|
|
<CarouselContent className="-mt-2 lg:flex-col">
|
|
{images.map((img, index) => (
|
|
<CarouselItem
|
|
key={`product-detail-8-image-${index}`}
|
|
className="pt-2 lg:basis-1/3"
|
|
>
|
|
<AspectRatio ratio={0.797938144} className="overflow-hidden">
|
|
<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"
|
|
className="hover:cursor-zoom-in"
|
|
>
|
|
<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>
|
|
</AspectRatio>
|
|
</CarouselItem>
|
|
))}
|
|
</CarouselContent>
|
|
<div className="lg:hidden">
|
|
<CarouselPrevious className="start-4 size-11 border-0 bg-primary/20 hover:bg-primary/30 [&>svg]:!size-7 [&>svg]:stroke-primary-foreground" />
|
|
<CarouselNext className="end-4 size-11 border-0 bg-primary/20 hover:bg-primary/30 [&>svg]:!size-7 [&>svg]:stroke-primary-foreground" />
|
|
</div>
|
|
</Carousel>
|
|
<div className="pointer-events-none absolute top-4 start-4 flex items-center gap-2">
|
|
<Badge variant="destructive">حراج {discount}</Badge>
|
|
{availability === false ? <Badge>پیشسفارش</Badge> : null}
|
|
</div>
|
|
</div>
|
|
<div className="mt-5">
|
|
<ul className="flex items-center justify-center md:hidden">
|
|
{images.map((_, index) => (
|
|
<li
|
|
className="shrink-0"
|
|
key={`product-detail-8-indicator-${index}`}
|
|
>
|
|
<button
|
|
onClick={() => api?.scrollTo(index)}
|
|
data-state={index + 1 === current ? "active" : "inactive"}
|
|
type="button"
|
|
className="flex size-4 duration-200 data-[state=active]:[&>span]:bg-primary"
|
|
>
|
|
<span className="m-auto size-2 rounded-full bg-primary/30"></span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<ul className="hidden w-full max-w-full items-center justify-center gap-2 overflow-auto py-0.5 md:flex lg:hidden">
|
|
{images.map((img, index) => (
|
|
<li
|
|
className="h-16 shrink-0 grow-0 basis-13.5"
|
|
key={`product-detail-8-thumbnail-${index}`}
|
|
>
|
|
<button
|
|
onClick={() => api?.scrollTo(index)}
|
|
data-state={index + 1 === current ? "active" : "inactive"}
|
|
type="button"
|
|
className="block size-full ring-foreground transition-shadow duration-200 data-[state=active]:ring-2"
|
|
>
|
|
<img
|
|
src={img.thumbnail}
|
|
alt={img.alt}
|
|
className="block size-full object-cover object-center"
|
|
/>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Reviews = ({ rate, totalReviewers }: ReviewsProps) => {
|
|
const renderStars = () => {
|
|
const fullStars = Math.floor(rate);
|
|
const hasHalfStar = rate % 1 >= 0.5;
|
|
const emptyStars = MAX_STARS - fullStars - (hasHalfStar ? 1 : 0);
|
|
|
|
const stars = [];
|
|
|
|
for (let i = 0; i < fullStars; i++) {
|
|
stars.push(
|
|
<Star
|
|
key={`product-detail-8-star-full-${i}`}
|
|
className="size-3.5 fill-black stroke-black"
|
|
/>,
|
|
);
|
|
}
|
|
|
|
if (hasHalfStar) {
|
|
stars.push(
|
|
<div key="product-detail-8-half-star" className="relative size-3.5">
|
|
<StarHalf className="absolute top-0 end-0 size-full fill-black stroke-black dark:invert" />
|
|
<StarHalf className="absolute top-0 start-0 size-full -scale-x-100 fill-black/15 stroke-black/15 dark:invert" />
|
|
</div>,
|
|
);
|
|
}
|
|
|
|
for (let i = 0; i < emptyStars; i++) {
|
|
stars.push(
|
|
<Star
|
|
key={`product-detail-8-star-empty-${i}`}
|
|
className="size-3.5 fill-black/15 stroke-black/15 dark:invert"
|
|
/>,
|
|
);
|
|
}
|
|
|
|
return stars;
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="flex items-center gap-0.5">{renderStars()}</div>
|
|
<Button
|
|
variant="link"
|
|
className="h-fit p-0 leading-none font-normal text-muted-foreground"
|
|
>
|
|
{totalReviewers} نظر
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const formSchema = z.object({
|
|
color: z.string(),
|
|
size: z.string(),
|
|
});
|
|
|
|
const ProductForm = ({ hinges, form, onSubmit, stockInfo }: ProductForm) => {
|
|
const sizeHinges = hinges?.size;
|
|
const colorHinges = hinges?.color;
|
|
|
|
return (
|
|
<form
|
|
onSubmit={form.handleSubmit(onSubmit)}
|
|
className="flex w-full flex-col gap-7"
|
|
>
|
|
{colorHinges && (
|
|
<Controller
|
|
control={form.control}
|
|
name={colorHinges.name}
|
|
render={({ field }) => (
|
|
<Field>
|
|
<ColorRadioGroup
|
|
field={field}
|
|
options={colorHinges.options}
|
|
label={colorHinges.label}
|
|
/>
|
|
</Field>
|
|
)}
|
|
/>
|
|
)}
|
|
<Controller
|
|
control={form.control}
|
|
name={sizeHinges.name}
|
|
render={({ field }) => (
|
|
<Field>
|
|
<SizeRadioGroup
|
|
field={field}
|
|
options={sizeHinges.options}
|
|
label={sizeHinges.label}
|
|
/>
|
|
</Field>
|
|
)}
|
|
/>
|
|
|
|
<StockMessege stockInfo={stockInfo} />
|
|
<div className="flex w-full shrink-0 basis-full flex-col gap-6">
|
|
{stockInfo?.stockStatusCode === "OUT_OF_STOCK" ? (
|
|
<Fragment>
|
|
<Button size="lg">پیشسفارش</Button>
|
|
</Fragment>
|
|
) : (
|
|
<Button size="lg">افزودن به سبد</Button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
const SizeRadioGroup = ({ options, label, field }: SizeRadioGroupProps) => {
|
|
if (!options) return;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex flex-col gap-2.5">
|
|
<div className="flex w-full items-baseline justify-between gap-1">
|
|
<h2 className="text-sm leading-[1.8] font-semibold">{label}</h2>
|
|
</div>
|
|
<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-8-size-${index}`}>
|
|
<label
|
|
htmlFor={item.id}
|
|
className="relative flex h-10 min-w-10 cursor-pointer items-center justify-center rounded-md border-1 text-sm leading-snug font-normal shadow-xs has-checked:ring has-data-[disabled=true]:text-muted-foreground has-checked:has-data-[disabled=true]:text-muted-foreground"
|
|
>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ColorRadioGroup = ({ options, label, field }: ColorRadioGroupProps) => {
|
|
const colorRef = useRef<HTMLDivElement>(null);
|
|
|
|
const getColorName = (id: string) =>
|
|
options?.find((item) => item.id === id)?.label;
|
|
|
|
if (!options) return;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex flex-col gap-2.5">
|
|
<h2 className="text-sm leading-[1.8] font-semibold">
|
|
{label} <span ref={colorRef} className="text-sm font-normal"></span>
|
|
</h2>
|
|
<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-8-color-${index}`}>
|
|
<label
|
|
htmlFor={item.id}
|
|
data-disabled={
|
|
item.stockInfo.stockStatusCode === "OUT_OF_STOCK"
|
|
}
|
|
onMouseOver={() => {
|
|
if (colorRef.current) {
|
|
colorRef.current.innerHTML = item.label;
|
|
}
|
|
}}
|
|
onMouseLeave={() => {
|
|
if (colorRef.current) {
|
|
colorRef.current.innerHTML =
|
|
getColorName(field.value) || "";
|
|
}
|
|
}}
|
|
aria-label={`انتخاب ${item.label}`}
|
|
className="relative h-16 w-12.5 overflow-hidden rounded-md text-base leading-snug font-normal shadow-xs has-checked:ring-2"
|
|
>
|
|
<RadioGroupItem
|
|
id={item.id}
|
|
className="sr-only"
|
|
value={item.value}
|
|
aria-label={`انتخاب ${item.label}`}
|
|
/>
|
|
<img
|
|
src={item?.image?.src}
|
|
alt={item?.image?.alt}
|
|
className="block size-full object-cover object-center"
|
|
/>
|
|
{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>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const StockMessege = ({ stockInfo }: StockMessageProps) => {
|
|
if (!stockInfo) return;
|
|
|
|
const { stockStatusCode, stockQuantity } = stockInfo;
|
|
const statusColor = STATUS_CONFIG?.[stockStatusCode].color;
|
|
|
|
return (
|
|
<div className="inline-flex items-center gap-2.5">
|
|
<p
|
|
className="text-sm leading-[1.4]"
|
|
style={{
|
|
color: `var(--color-${statusColor}-600)`,
|
|
}}
|
|
>
|
|
{stockStatusCode === "LOW_STOCK"
|
|
? STATUS_CONFIG.LOW_STOCK.getMessage(stockQuantity)
|
|
: STATUS_CONFIG[stockStatusCode].message}
|
|
</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ServicesList = ({ services }: { services: string[] }) => {
|
|
if (!services) return;
|
|
|
|
return (
|
|
<ul className="flex flex-col">
|
|
{services.map((text, index) => (
|
|
<li
|
|
className="flex items-center gap-1.5"
|
|
key={`product-detail-8-service-${index}`}
|
|
>
|
|
<Check className="size-4 stroke-3" />
|
|
<p className="text-sm leading-[1.8]">{text}</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
};
|
|
|
|
const ProductInfoSection = ({ info }: ProductInfoSectionProps) => {
|
|
if (!info) return;
|
|
|
|
return (
|
|
<Accordion type="multiple" className="w-full border-t">
|
|
{info.map((item, index) => (
|
|
<AccordionItem
|
|
value={item.title}
|
|
key={`product-detail-8-info-${index}`}
|
|
>
|
|
<AccordionTrigger className="text-sm leading-[1.2] hover:no-underline">
|
|
{item.title}
|
|
</AccordionTrigger>
|
|
<AccordionContent className="text-base text-balance">
|
|
{item.content}
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
))}
|
|
</Accordion>
|
|
);
|
|
};
|
|
|
|
export { ProductDetail8 };
|