843 lines
26 KiB
TypeScript
843 lines
26 KiB
TypeScript
"use client";
|
|
import "photoswipe/style.css";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Heart,
|
|
Minus,
|
|
Plus,
|
|
Ruler,
|
|
Shirt,
|
|
Truck,
|
|
ZoomIn,
|
|
} from "lucide-react";
|
|
import PhotoSwipeLightbox from "photoswipe/lightbox";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import type { ControllerRenderProps } 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, badgeVariants } from "@/components/ui/badge";
|
|
import { Button, buttonVariants } 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 { useGoogleFont } from "@/hooks/use-google-font";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type StockStatusCode = "IN_STOCK" | "OUT_OF_STOCK";
|
|
|
|
interface StockInfo {
|
|
stockStatusCode?: StockStatusCode;
|
|
stockQuantity?: number;
|
|
}
|
|
|
|
type option = {
|
|
id: string;
|
|
label: string;
|
|
value: string;
|
|
stockInfo: StockInfo;
|
|
};
|
|
|
|
interface Hinges {
|
|
label: string;
|
|
id: string;
|
|
name: FieldName;
|
|
options?: option[];
|
|
min?: number;
|
|
max?: number;
|
|
}
|
|
|
|
interface QuantityProps {
|
|
field: ControllerRenderProps<FormType>;
|
|
max?: number;
|
|
min?: number;
|
|
id?: string;
|
|
}
|
|
|
|
interface ProductFormProps {
|
|
hinges?: Record<FieldName, Hinges>;
|
|
selected: FormType;
|
|
}
|
|
|
|
interface RadioGroupProps {
|
|
options?: Array<option>;
|
|
field: ControllerRenderProps<FormType>;
|
|
}
|
|
|
|
interface PriceProps {
|
|
regular: number;
|
|
sale?: number;
|
|
currency: string;
|
|
text?: string;
|
|
}
|
|
|
|
interface ProductImagesProps {
|
|
images: Array<{
|
|
srcset: string;
|
|
src: string;
|
|
alt: string;
|
|
width: number;
|
|
height: number;
|
|
sizes: string;
|
|
}>;
|
|
galleryID: string;
|
|
}
|
|
|
|
type FormType = z.infer<typeof formSchema>;
|
|
type FieldName = keyof FormType;
|
|
|
|
interface ProductInfoProps {
|
|
info: {
|
|
title: {
|
|
icon: LucideIcon;
|
|
text: string;
|
|
};
|
|
content: string;
|
|
}[];
|
|
}
|
|
|
|
const PRODUCT_DETAILS = {
|
|
name: "کیف مزون لیورا",
|
|
price: {
|
|
regular: 420000.0,
|
|
sale: 399000.0,
|
|
currency: "ریال",
|
|
},
|
|
color: "black",
|
|
material: "leather",
|
|
description:
|
|
"این کیف دستی مجسمهای و نرم، ظرافت و کارایی را در یک قطعه یکپارچه جمع کرده است. با یک تنظیم ساده، از کیف دوشی به دستی یا کراسبادی تبدیل میشود. بند جداشدنی به شما اجازه میدهد هر طور که روزتان میخواهد حملش کنیدروی شانه، در دست یا به صورت کراس. جا برای وسایل روزانه مثل گوشی، کیف کارت، کلید و آینه جمعوجور کافی دارد. از چرم دانهدرشت ممتاز ساخته شده و با یراقهای طلایی مات تکمیل شده است. با بستهشدن مغناطیسی محافظت میشود و داخل آن جیب زیپدار و محفظه لغزشی برای نظم بیشتر قرار دارد.",
|
|
hinges: {
|
|
color: {
|
|
label: "رنگ",
|
|
id: "color",
|
|
name: "color",
|
|
options: [
|
|
{
|
|
id: "green",
|
|
value: "green",
|
|
label: "سبز",
|
|
stockInfo: {
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
},
|
|
{
|
|
id: "black",
|
|
value: "black",
|
|
label: "مشکی",
|
|
stockInfo: {
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
},
|
|
{
|
|
id: "white",
|
|
value: "white",
|
|
label: "سفید",
|
|
stockInfo: {
|
|
stockStatusCode: "OUT_OF_STOCK",
|
|
},
|
|
},
|
|
{
|
|
id: "red",
|
|
value: "red",
|
|
label: "قرمز",
|
|
stockInfo: {
|
|
stockStatusCode: "OUT_OF_STOCK",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
material: {
|
|
label: "جنس",
|
|
id: "material",
|
|
name: "material",
|
|
options: [
|
|
{
|
|
id: "leather",
|
|
value: "leather",
|
|
label: "چرم",
|
|
stockInfo: {
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
},
|
|
{
|
|
id: "polyester",
|
|
value: "polyester",
|
|
label: "پلیاستر",
|
|
stockInfo: {
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
},
|
|
{
|
|
id: "nylon",
|
|
value: "nylon",
|
|
label: "نایلون",
|
|
stockInfo: {
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
quantity: {
|
|
label: "تعداد",
|
|
id: "quantity",
|
|
name: "quantity",
|
|
min: 1,
|
|
max: 99,
|
|
},
|
|
} as Record<FieldName, Hinges>,
|
|
info: [
|
|
{
|
|
title: {
|
|
icon: Shirt,
|
|
text: "ترکیب ساخت",
|
|
},
|
|
content:
|
|
"از چرم دانهدرشت ممتاز با سیلوئت ساختیافته ساخته شده است. آستر داخلی از کتان نرم دوخته شده. با یراقهای طلایی مات تزیین شده. شامل دو محفظه داخلی و یک جیب زیپدار است. همراه با بند قابل تنظیم و جداشدنی برای حمل چندسبکه.",
|
|
},
|
|
{
|
|
title: {
|
|
icon: Truck,
|
|
text: "ارسال و مرجوعی",
|
|
},
|
|
content:
|
|
"از ارسال رایگان و مرجوعی آسان برای همه خریدها بهرهمند شوید. سفارشها معمولاً ظرف ۵ تا ۷ روز کاری ارسال میشوند.",
|
|
},
|
|
{
|
|
title: {
|
|
icon: Ruler,
|
|
text: "ابعاد کیف",
|
|
},
|
|
content:
|
|
"ارتفاع: ۲۱ سانتیمتر × عرض: ۲۸ سانتیمتر (۸٫۳ اینچ × ۱۱ اینچ) بهراحتی وسایل روزانه جا میشود.",
|
|
},
|
|
{
|
|
title: {
|
|
icon: Heart,
|
|
text: "راهنمای نگهداری",
|
|
},
|
|
content:
|
|
"با پارچه تمیز و نمدار بهآرامی پاک کنید. از مواد شوینده قوی و قرار گرفتن مستقیم در آفتاب یا رطوبت خودداری کنید. هنگام استفاده نکردن در کیس محافظ ارائهشده نگه دارید.",
|
|
},
|
|
],
|
|
images: [
|
|
{
|
|
srcset:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/pexels-429124762-20851442-3.jpg 1086w, /block-media/shadcnblocks/block/ecommerce/clothes/pexels-429124762-20851442-2.jpg 1086w, /block-media/shadcnblocks/block/ecommerce/clothes/pexels-429124762-20851442-1.jpg 1086w",
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/pexels-429124762-20851442-3.jpg",
|
|
alt: "کیف مزون لیورا",
|
|
width: 1086,
|
|
height: 1448,
|
|
sizes: "(min-width: 1086px) 1086px, 100vw",
|
|
},
|
|
{
|
|
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: "(min-width: 1024px) 1024px, 100vw",
|
|
},
|
|
{
|
|
srcset:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/Stylish-Woman-1-with-Tote-2.png 1024w, /block-media/shadcnblocks/block/ecommerce/clothes/Stylish-Woman-1-with-Tote-1.png 1024w",
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Stylish-Woman-1-with-Tote-2.png",
|
|
alt: "کیف مزون لیورا",
|
|
width: 1024,
|
|
height: 1536,
|
|
sizes: "(min-width: 1024px) 1024px, 100vw",
|
|
},
|
|
{
|
|
srcset:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/Minimalist-Fashion-Look-2.png 1023w, /block-media/shadcnblocks/block/ecommerce/clothes/Minimalist-Fashion-Look-1.png 1023w",
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Minimalist-Fashion-Look-2.png",
|
|
alt: "کیف مزون لیورا",
|
|
width: 1023,
|
|
height: 1537,
|
|
sizes: "(min-width: 1023px) 1023px, 100vw",
|
|
},
|
|
{
|
|
srcset:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/Woman-with-Leather-Tote-2.png 1024w, /block-media/shadcnblocks/block/ecommerce/clothes/Woman-with-Leather-Tote-1.png 1024w",
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Woman-with-Leather-Tote-2.png",
|
|
alt: "کیف مزون لیورا",
|
|
width: 1024,
|
|
height: 1536,
|
|
sizes: "(min-width: 1024px) 1024px, 100vw",
|
|
},
|
|
{
|
|
srcset:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/Woman-with-Tote-Bag-2.png 1023w, /block-media/shadcnblocks/block/ecommerce/clothes/Woman-with-Tote-Bag-1.png 1023w",
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Woman-with-Tote-Bag-2.png",
|
|
alt: "کیف مزون لیورا",
|
|
width: 1023,
|
|
height: 1537,
|
|
sizes: "(min-width: 1023px) 1023px, 100vw",
|
|
},
|
|
{
|
|
srcset:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/Stylish-Woman-in-Interior-2.png 1023w, /block-media/shadcnblocks/block/ecommerce/clothes/Stylish-Woman-in-Interior-1.png 1023w",
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Stylish-Woman-in-Interior-2.png",
|
|
alt: "کیف مزون لیورا",
|
|
width: 1023,
|
|
height: 1537,
|
|
sizes: "(min-width: 1023px) 1023px, 100vw",
|
|
},
|
|
],
|
|
};
|
|
|
|
interface ProductDetail2Props {
|
|
className?: string;
|
|
}
|
|
|
|
const ProductDetail2 = ({ className }: ProductDetail2Props) => {
|
|
useGoogleFont("Assistant");
|
|
return (
|
|
<section
|
|
className={cn("bg-background py-32 font-sans", className)}
|
|
style={
|
|
{
|
|
"--font-sans": "Assistant",
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<div className="mx-auto w-full max-w-[75rem] px-4 md:px-12.5">
|
|
<div className="relative flex flex-col gap-4 md:flex-row md:gap-10">
|
|
<div className="basis-[65%]">
|
|
<ProductImages
|
|
images={PRODUCT_DETAILS.images}
|
|
galleryID="gallery-2"
|
|
/>
|
|
</div>
|
|
<div className="basis-[35%]">
|
|
<div className="sticky top-12 flex flex-col gap-5">
|
|
<h1 className="text-3xl leading-tight md:text-[2.5rem]">
|
|
{PRODUCT_DETAILS.name}
|
|
</h1>
|
|
<div className="flex items-center gap-2.5">
|
|
<Price {...PRODUCT_DETAILS.price} />
|
|
</div>
|
|
<ProductForm
|
|
hinges={PRODUCT_DETAILS.hinges}
|
|
selected={{
|
|
color: PRODUCT_DETAILS.color,
|
|
material: PRODUCT_DETAILS.material,
|
|
quantity: 1,
|
|
}}
|
|
/>
|
|
<p className="text-base leading-loose text-muted-foreground">
|
|
{PRODUCT_DETAILS.description}
|
|
</p>
|
|
<ProductInfo info={PRODUCT_DETAILS.info} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
const ProductImages = ({ images, galleryID }: ProductImagesProps) => {
|
|
const [api, setApi] = useState<CarouselApi>();
|
|
const [current, setCurrent] = useState(1);
|
|
const lightboxRef = useRef<PhotoSwipeLightbox | null>(null);
|
|
const [canScrollPrev, setCanScrollPrev] = useState(false);
|
|
const [canScrollNext, setCanScrollNext] = useState(false);
|
|
|
|
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;
|
|
const buttonClassName = cn(
|
|
buttonVariants({
|
|
variant: "outline",
|
|
size: "icon",
|
|
className: "rounded-full",
|
|
}),
|
|
);
|
|
const indicatorClassName = cn(badgeVariants({ variant: "secondary" }));
|
|
|
|
lightbox.on("uiRegister", () => {
|
|
if (lightbox?.pswp?.ui) {
|
|
lightbox.pswp.ui.registerElement({
|
|
name: "custom-close-btn",
|
|
order: 10,
|
|
isButton: false,
|
|
appendTo: "root",
|
|
className: "absolute top-5 end-5",
|
|
html: `
|
|
<button type="button" id="pswp-close" class="${buttonClassName}">
|
|
<svg 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>
|
|
`,
|
|
onInit: (el, pswp) => {
|
|
el.querySelector("#pswp-close")?.addEventListener("click", () =>
|
|
pswp.close(),
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
if (lightbox?.pswp?.ui) {
|
|
lightbox.pswp.ui.registerElement({
|
|
name: "custom-indicator-btn",
|
|
order: 10,
|
|
isButton: false,
|
|
appendTo: "root",
|
|
className: "absolute top-5 start-5",
|
|
html: `
|
|
<div id="pswp-indicator" class="${indicatorClassName} h-8.5 px-4 !rounded-full">
|
|
</div>
|
|
`,
|
|
onInit: (el, pswp) => {
|
|
const indicatorElem = el.querySelector("#pswp-indicator");
|
|
let prevIndex = -1;
|
|
|
|
pswp.on("change", () => {
|
|
prevIndex = pswp.currIndex;
|
|
if (indicatorElem) {
|
|
indicatorElem.innerHTML = `${(prevIndex + 1).toLocaleString("fa-IR")} / ${pswp.getNumItems().toLocaleString("fa-IR")}`;
|
|
}
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
if (lightbox?.pswp?.ui) {
|
|
lightbox.pswp.ui.registerElement({
|
|
name: "custom-next-prev-btns",
|
|
order: 10,
|
|
isButton: false,
|
|
appendTo: "root",
|
|
className: "absolute top-1/2 inset-x-0 -translate-y-1/2",
|
|
html: `
|
|
<div class="flex items-center justify-between px-4">
|
|
<button id="pswp-prev" class="${buttonClassName}">
|
|
<svg 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-next" class="${buttonClassName}">
|
|
<svg 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>
|
|
</div>
|
|
|
|
`,
|
|
onInit: (el, pswp) => {
|
|
el.querySelector("#pswp-prev")?.addEventListener("click", () =>
|
|
pswp.prev(),
|
|
);
|
|
el.querySelector("#pswp-next")?.addEventListener("click", () =>
|
|
pswp.next(),
|
|
);
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
return () => lightbox.destroy();
|
|
}, [galleryID]);
|
|
|
|
const onSelect = useCallback((api: CarouselApi) => {
|
|
if (!api) return;
|
|
setCanScrollPrev(api.canScrollPrev());
|
|
setCanScrollNext(api.canScrollNext());
|
|
setCurrent(api.selectedScrollSnap() + 1);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!api) return;
|
|
onSelect(api);
|
|
api.on("reInit", onSelect);
|
|
api.on("select", onSelect);
|
|
|
|
return () => {
|
|
api?.off("select", onSelect);
|
|
};
|
|
}, [api, onSelect]);
|
|
|
|
useEffect(() => {
|
|
if (lightboxRef.current && api) {
|
|
lightboxRef.current.on("change", () => {
|
|
api?.scrollTo(lightboxRef.current?.pswp?.currIndex || 0);
|
|
});
|
|
}
|
|
}, [api, current]);
|
|
|
|
if (!images) return;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3" id={galleryID}>
|
|
<Carousel
|
|
setApi={setApi}
|
|
opts={{
|
|
breakpoints: {
|
|
"(min-width: 768px)": {
|
|
active: false,
|
|
},
|
|
},
|
|
}}
|
|
>
|
|
<CarouselContent className="gap-2 md:m-0 md:grid md:grid-cols-2">
|
|
{images.map((img, index) => (
|
|
<CarouselItem
|
|
className="first:col-span-2 md:p-0"
|
|
key={`product-detail-2-image-${index}`}
|
|
>
|
|
<AspectRatio ratio={1} className="overflow-hidden">
|
|
<a
|
|
href={img.src}
|
|
data-pswp-width={img.width}
|
|
data-pswp-height={img.height}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
data-cropped="true"
|
|
className="hover:cursor-zoom-in"
|
|
>
|
|
<img
|
|
src={img.src}
|
|
alt={img.alt}
|
|
className="block size-full object-cover object-center"
|
|
/>
|
|
<Badge
|
|
className="pointer-events-none absolute top-3 start-3 size-8 rounded-full bg-background p-1.5 text-xs shadow md:hidden"
|
|
variant="secondary"
|
|
>
|
|
<ZoomIn className="!size-3.5 stroke-muted-foreground" />
|
|
</Badge>
|
|
</a>
|
|
</AspectRatio>
|
|
</CarouselItem>
|
|
))}
|
|
</CarouselContent>
|
|
</Carousel>
|
|
<div className="flex items-center justify-center gap-2.5 md:hidden">
|
|
<Button
|
|
disabled={!canScrollPrev}
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => api?.scrollPrev()}
|
|
>
|
|
<ChevronLeft className="size-3 stroke-1 rtl:rotate-180" />
|
|
</Button>
|
|
<p className="text-xs text-muted-foreground">
|
|
{current.toLocaleString("fa-IR")}/{images.length.toLocaleString("fa-IR")}
|
|
</p>
|
|
<Button
|
|
disabled={!canScrollNext}
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => api?.scrollNext()}
|
|
>
|
|
<ChevronRight className="size-3 stroke-1 rtl:rotate-180" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Price = ({ regular, sale, currency }: PriceProps) => {
|
|
if (!regular || !currency) return;
|
|
|
|
const formatCurrency = (
|
|
value: number,
|
|
currency: string = "ریال",
|
|
locale: string = "fa-IR",
|
|
) => {
|
|
return `${value.toLocaleString(locale)} ${currency}`;
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-start gap-2">
|
|
{sale && (
|
|
<div
|
|
data-sale={!!sale}
|
|
aria-label="قیمت با تخفیف"
|
|
className="text-xl leading-tight text-primary"
|
|
>
|
|
{formatCurrency(sale, currency)}
|
|
</div>
|
|
)}
|
|
<div
|
|
data-sale={!!sale}
|
|
aria-label="قیمت اصلی"
|
|
className="text-xl leading-tight data-[sale=true]:text-xl data-[sale=true]:text-muted-foreground data-[sale=true]:line-through"
|
|
>
|
|
{formatCurrency(regular, currency)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const formSchema = z.object({
|
|
color: z.string(),
|
|
quantity: z.number().min(1),
|
|
material: z.string(),
|
|
});
|
|
|
|
const ProductForm = ({ hinges, selected }: ProductFormProps) => {
|
|
const form = useForm<FormType>({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: {
|
|
quantity: selected.quantity,
|
|
color: selected.color,
|
|
material: selected.material,
|
|
},
|
|
});
|
|
|
|
function onSubmit(values: FormType) {
|
|
console.log(values);
|
|
}
|
|
|
|
const colorHinges = hinges?.color;
|
|
const materialHinges = hinges?.material;
|
|
const quantityHinges = hinges?.quantity;
|
|
|
|
return (
|
|
<form
|
|
onSubmit={form.handleSubmit(onSubmit)}
|
|
className="flex flex-col gap-6"
|
|
>
|
|
{colorHinges && (
|
|
<Controller
|
|
control={form.control}
|
|
name={colorHinges.name}
|
|
render={({ field }) => (
|
|
<Field>
|
|
<FieldLabel
|
|
asChild
|
|
className="text-sm leading-normal text-muted-foreground"
|
|
>
|
|
<h2>{colorHinges.label}</h2>
|
|
</FieldLabel>
|
|
<ColorRadioGroup field={field} options={colorHinges.options} />
|
|
</Field>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{materialHinges && (
|
|
<Controller
|
|
control={form.control}
|
|
name={materialHinges.name}
|
|
render={({ field }) => (
|
|
<Field>
|
|
<FieldLabel
|
|
asChild
|
|
className="text-sm leading-normal text-muted-foreground"
|
|
>
|
|
<h2>{materialHinges.label}</h2>
|
|
</FieldLabel>
|
|
<MaterialRadioGroup
|
|
field={field}
|
|
options={materialHinges.options}
|
|
/>
|
|
</Field>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{quantityHinges && (
|
|
<Controller
|
|
control={form.control}
|
|
name={quantityHinges.name}
|
|
render={({ field }) => (
|
|
<Field>
|
|
<FieldLabel
|
|
htmlFor={quantityHinges.id}
|
|
className="text-sm leading-normal text-muted-foreground"
|
|
>
|
|
{quantityHinges.label}
|
|
</FieldLabel>
|
|
<Quantity
|
|
field={field}
|
|
min={quantityHinges.min}
|
|
max={quantityHinges.max}
|
|
id={quantityHinges.id}
|
|
/>
|
|
</Field>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-2.5">
|
|
<Button size="lg" variant="outline">
|
|
افزودن به سبد
|
|
</Button>
|
|
<Button size="lg">خرید</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
const Quantity = ({ field, max, min, id }: QuantityProps) => {
|
|
return (
|
|
<div className="flex h-9 w-31 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="shrink-0 rounded-none"
|
|
>
|
|
<Minus className="stroke-muted-foreground" />
|
|
</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"
|
|
id={id}
|
|
min={Math.max(min ?? 1, 1)}
|
|
max={max ?? 99}
|
|
className="h-full w-full flex-1 [appearance:textfield] rounded-none border-0 !bg-background px-1 text-center shadow-none 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="shrink-0 rounded-none"
|
|
>
|
|
<Plus className="stroke-muted-foreground" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ColorRadioGroup = ({ options, field }: RadioGroupProps) => {
|
|
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-4"
|
|
>
|
|
{options.map((item, index) => (
|
|
<div className="basis-20" key={`product-detail-2-size-input-${index}`}>
|
|
<label
|
|
htmlFor={item.id}
|
|
className="flex h-8 items-center justify-center rounded-md border-1 border-primary px-3 text-sm leading-snug font-normal has-checked:bg-primary has-checked:text-primary-foreground has-data-[disabled=true]:border-border has-data-[disabled=true]:text-muted-foreground has-data-[disabled=true]:line-through"
|
|
>
|
|
<RadioGroupItem
|
|
id={item.id}
|
|
className="sr-only"
|
|
value={item.value}
|
|
aria-label={`انتخاب ${item.label}`}
|
|
data-disabled={item.stockInfo.stockStatusCode === "OUT_OF_STOCK"}
|
|
/>
|
|
<span>{item.label}</span>
|
|
</label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
);
|
|
};
|
|
|
|
const MaterialRadioGroup = ({ options, field }: RadioGroupProps) => {
|
|
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-4"
|
|
>
|
|
{options.map((item, index) => (
|
|
<div key={`product-detail-2-material-input-${index}`}>
|
|
<label
|
|
htmlFor={item.id}
|
|
className="flex h-8 items-center justify-center rounded-md border-1 border-primary px-3 text-sm leading-snug font-normal has-checked:bg-primary has-checked:text-primary-foreground has-data-[disabled=true]:border-border has-data-[disabled=true]:text-muted-foreground has-data-[disabled=true]:line-through"
|
|
>
|
|
<RadioGroupItem
|
|
id={item.id}
|
|
className="sr-only"
|
|
value={item.value}
|
|
data-disabled={item.stockInfo.stockStatusCode === "OUT_OF_STOCK"}
|
|
/>
|
|
<span>{item.label}</span>
|
|
</label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
);
|
|
};
|
|
|
|
const ProductInfo = ({ info }: ProductInfoProps) => {
|
|
if (!info) return;
|
|
return (
|
|
<Accordion type="multiple" className="w-full">
|
|
{info.map((item, index) => (
|
|
<AccordionItem
|
|
key={`product-detail-2-info-${index}`}
|
|
value={`item-${index}`}
|
|
>
|
|
<AccordionTrigger>
|
|
<div className="text-base">{item.title.text}</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="flex flex-col gap-4 text-balance">
|
|
<p className="text-base leading-loose text-muted-foreground">
|
|
{item.content}
|
|
</p>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
))}
|
|
</Accordion>
|
|
);
|
|
};
|
|
|
|
export { ProductDetail2 };
|