828 lines
25 KiB
TypeScript
828 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import "photoswipe/style.css";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import clsx from "clsx";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import { Tag, Truck } from "lucide-react";
|
|
import PhotoSwipeLightbox from "photoswipe/lightbox";
|
|
import { useEffect, useId, useRef, useState } from "react";
|
|
import type { ControllerRenderProps } from "react-hook-form";
|
|
import { Controller, useForm } from "react-hook-form";
|
|
import z from "zod";
|
|
|
|
import type { PriceType } from "@/components/shadcnblocks/price";
|
|
import { Price } from "@/components/shadcnblocks/price";
|
|
import QuantityInput from "@/components/shadcnblocks/quantity-input";
|
|
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 } from "@/components/ui/button";
|
|
import { buttonVariants } from "@/components/ui/button";
|
|
import type { CarouselApi } from "@/components/ui/carousel";
|
|
import {
|
|
Carousel,
|
|
CarouselContent,
|
|
CarouselItem,
|
|
CarouselNext,
|
|
CarouselPrevious,
|
|
} from "@/components/ui/carousel";
|
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
import { FieldLegend, FieldSet } from "@/components/ui/field";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const STOCK_STATUS = {
|
|
IN_STOCK: {
|
|
code: "IN_STOCK",
|
|
message: (qt?: number) =>
|
|
qt
|
|
? `پیشسفارش امروز ${qt.toLocaleString("fa-IR")} عدد باقی مانده`
|
|
: "موجود، آماده ارسال",
|
|
color: "oklch(64.8% 0.2 131.684)",
|
|
},
|
|
LOW_STOCK: {
|
|
code: "LOW_STOCK",
|
|
message: "دارد سریع فروش میرود!",
|
|
color: "oklch(76.9% 0.188 70.08)",
|
|
},
|
|
OUT_OF_STOCK: {
|
|
code: "OUT_OF_STOCK",
|
|
message: "ناموجود",
|
|
color: "oklch(58.6% 0.253 17.585)",
|
|
},
|
|
} as const;
|
|
|
|
type StockStatusCode = keyof typeof STOCK_STATUS;
|
|
|
|
type Image = {
|
|
src: string;
|
|
alt: string;
|
|
thumbnail: string;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
type SocialIcon = {
|
|
title: string;
|
|
light: string;
|
|
dark: string;
|
|
};
|
|
|
|
type SocialLink = {
|
|
link: string;
|
|
icon: SocialIcon;
|
|
};
|
|
|
|
type StockStatus = {
|
|
code: StockStatusCode;
|
|
quantity?: number;
|
|
};
|
|
|
|
interface ProductImagesProps {
|
|
images: Image[];
|
|
galleryID?: string;
|
|
badges?: string[];
|
|
}
|
|
|
|
interface StockStatusProps {
|
|
status?: StockStatus;
|
|
}
|
|
|
|
type ServiceListType = {
|
|
icon: LucideIcon;
|
|
text: string;
|
|
}[];
|
|
|
|
interface ServicesListProps {
|
|
list?: ServiceListType;
|
|
}
|
|
|
|
type FormType = z.infer<typeof formSchema>;
|
|
type FieldName = keyof FormType;
|
|
|
|
type Option = {
|
|
id: string;
|
|
value: string;
|
|
label: string;
|
|
thumbnail?: string;
|
|
color?: string;
|
|
stockStatusCode?: StockStatusCode;
|
|
};
|
|
|
|
interface Hinges {
|
|
label: string;
|
|
id: string;
|
|
min?: number;
|
|
max?: number;
|
|
name: FieldName;
|
|
options?: Option[];
|
|
}
|
|
|
|
interface FormProps {
|
|
hinges: Record<FieldName, Hinges>;
|
|
selected?: FormType;
|
|
}
|
|
|
|
interface RadioGroupProps {
|
|
options?: Array<Option>;
|
|
field: ControllerRenderProps<FormType>;
|
|
}
|
|
|
|
type AccordionItemType = {
|
|
title: string;
|
|
value: string;
|
|
content: {
|
|
text?: string;
|
|
list?: string[];
|
|
};
|
|
};
|
|
|
|
interface AccordionSectionProps {
|
|
section?: AccordionItemType[];
|
|
}
|
|
|
|
interface SocialMediaLinksProps {
|
|
links?: SocialLink[];
|
|
}
|
|
|
|
interface Product {
|
|
images: Image[];
|
|
galleryID?: string;
|
|
badges?: string[];
|
|
name: string;
|
|
price: PriceType;
|
|
stockStatus?: StockStatus;
|
|
services?: ServiceListType;
|
|
hinges: Record<FieldName, Hinges>;
|
|
link: string;
|
|
accordion?: AccordionItemType[];
|
|
socialLinks?: SocialLink[];
|
|
}
|
|
|
|
type ProductQuickView5Props = Product;
|
|
|
|
const formatCurrency = (
|
|
value: number,
|
|
currency: string = "ریال",
|
|
locale: string = "fa-IR",
|
|
) => {
|
|
return `${value.toLocaleString(locale)} ${currency}`;
|
|
};
|
|
|
|
const PRODUCT_DETAILS: Product = {
|
|
images: [
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/electronics/turntable/modern-audio-device.png",
|
|
thumbnail:
|
|
"/block-media/shadcnblocks/block/ecommerce/electronics/turntable/modern-audio-device.png",
|
|
alt: "دستگاه صوتی مدرن",
|
|
width: 992,
|
|
height: 1200,
|
|
},
|
|
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/electronics/turntable/modern-audio-panel.png",
|
|
thumbnail:
|
|
"/block-media/shadcnblocks/block/ecommerce/electronics/turntable/modern-audio-panel.png",
|
|
alt: "پنل صوتی مدرن",
|
|
width: 1200,
|
|
height: 800,
|
|
},
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/electronics/turntable/elegant-turntable-scene.png",
|
|
thumbnail:
|
|
"/block-media/shadcnblocks/block/ecommerce/electronics/turntable/elegant-turntable-scene.png",
|
|
alt: "صحنه گرامافون شیک",
|
|
width: 840,
|
|
height: 1200,
|
|
},
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/electronics/turntable/sleek-metallic-component.png",
|
|
thumbnail:
|
|
"/block-media/shadcnblocks/block/ecommerce/electronics/turntable/sleek-metallic-component.png",
|
|
alt: "قطعه فلزی براق",
|
|
width: 640,
|
|
height: 640,
|
|
},
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/electronics/turntable/turntable-stylus-close-up.png",
|
|
thumbnail:
|
|
"/block-media/shadcnblocks/block/ecommerce/electronics/turntable/turntable-stylus-close-up.png",
|
|
alt: "پنل کنترل مدرن",
|
|
width: 640,
|
|
height: 640,
|
|
},
|
|
],
|
|
badges: ["جدید"],
|
|
name: "گرامافون دقیق پرو",
|
|
price: {
|
|
regular: 1299000.0,
|
|
currency: "ریال",
|
|
},
|
|
stockStatus: {
|
|
code: "IN_STOCK",
|
|
quantity: 28,
|
|
},
|
|
services: [
|
|
{
|
|
icon: Truck,
|
|
text: "ارسال ویژه سفید شامل میشود",
|
|
},
|
|
{
|
|
icon: Tag,
|
|
text: "ارتقای رایگان کارتریج",
|
|
},
|
|
],
|
|
hinges: {
|
|
color: {
|
|
label: "رنگ",
|
|
id: "color",
|
|
name: "color",
|
|
options: [
|
|
{
|
|
id: "color-1",
|
|
value: "color-1",
|
|
label: "نقرهای",
|
|
color: "#C0C0C0",
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
{
|
|
id: "color-2",
|
|
value: "color-2",
|
|
label: "مشکی مات",
|
|
color: "#1a1a1a",
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
],
|
|
},
|
|
quantity: {
|
|
label: "تعداد",
|
|
id: "quantity",
|
|
name: "quantity",
|
|
min: 1,
|
|
max: 10,
|
|
},
|
|
},
|
|
link: "#",
|
|
accordion: [
|
|
{
|
|
value: "description",
|
|
title: "توضیحات",
|
|
content: {
|
|
text: "وینیل را همانگونه بشنوید که باید شنیده شود. گرامافون پرو با موتور دایرکتدرایو دقیق، بازوی صوتی قابل تنظیم و شاسی ضدلرزش، بازتولید صدایی پاکیزه ارائه میدهد. چه شنونده حرفهای باشید و چه تازه به دنیای صفحه برگشتهاید، این دستگاه صدایی گرم و جزئینما تحویل میدهد.",
|
|
},
|
|
},
|
|
{
|
|
value: "features",
|
|
title: "ویژگیها",
|
|
content: {
|
|
list: [
|
|
"موتور دایرکتدرایو با قفل کوارتز برای سرعت پایدار",
|
|
"بازوی صوتی S شکل با وزنه متقابل قابل تنظیم",
|
|
"پریآمپ فونو داخلی با امکان بایپس",
|
|
"پایههای ضدلرزش و صفحه سنگین برای کاهش رزونانس",
|
|
"خروجی USB برای دیجیتالسازی مجموعه وینیل شما",
|
|
],
|
|
},
|
|
},
|
|
{
|
|
value: "shipping",
|
|
title: "ارسال",
|
|
content: {
|
|
text: "ارسال ویژه سفید برای تمام سفارشهای گرامافون رایگان است. دستگاه شما توسط تیم تحویل با دقت باز و نصب میشود. زمان تحویل تقریبی: ۷ تا ۱۰ روز کاری.",
|
|
},
|
|
},
|
|
],
|
|
socialLinks: [
|
|
{
|
|
icon: {
|
|
title: "Facebook",
|
|
light:
|
|
"/block-media/shadcnblocks/block/logos/facebook-icon.svg",
|
|
dark: "/block-media/shadcnblocks/block/logos/facebook-icon.svg",
|
|
},
|
|
link: "#",
|
|
},
|
|
{
|
|
icon: {
|
|
title: "X",
|
|
light:
|
|
"/block-media/shadcnblocks/block/logos/x.svg",
|
|
dark: "/block-media/shadcnblocks/block/logos/x.svg",
|
|
},
|
|
link: "#",
|
|
},
|
|
{
|
|
icon: {
|
|
title: "Instagram",
|
|
light:
|
|
"/block-media/shadcnblocks/block/logos/instagram-icon.svg",
|
|
dark: "/block-media/shadcnblocks/block/logos/instagram-icon.svg",
|
|
},
|
|
link: "#",
|
|
},
|
|
],
|
|
};
|
|
|
|
const ProductQuickView5 = ({
|
|
images = PRODUCT_DETAILS.images,
|
|
badges = PRODUCT_DETAILS.badges,
|
|
name = PRODUCT_DETAILS.name,
|
|
price = PRODUCT_DETAILS.price,
|
|
stockStatus = PRODUCT_DETAILS.stockStatus,
|
|
services = PRODUCT_DETAILS.services,
|
|
hinges = PRODUCT_DETAILS.hinges,
|
|
link = PRODUCT_DETAILS.link,
|
|
accordion = PRODUCT_DETAILS.accordion,
|
|
socialLinks = PRODUCT_DETAILS.socialLinks,
|
|
}: ProductQuickView5Props) => {
|
|
const { regular, sale, currency } = price;
|
|
|
|
return (
|
|
<Dialog open>
|
|
<DialogContent
|
|
style={
|
|
{
|
|
"--dialog-height": "calc(100dvh - 2.5rem)",
|
|
"--dialog-max-height": "45rem",
|
|
} as React.CSSProperties
|
|
}
|
|
className="h-[var(--dialog-height)] w-dvw max-w-300! border-none p-0 max-md:top-auto max-md:bottom-0 max-md:start-0 max-md:h-[calc(100dvh-100px)] max-md:translate-0! max-md:overflow-auto md:max-h-[var(--dialog-max-height)] md:w-[calc(100dvw-4rem)]"
|
|
>
|
|
<div className="grid grid-cols-1 gap-5 max-md:p-10 max-sm:px-5 md:h-[var(--dialog-height)] md:max-h-[var(--dialog-max-height)] md:grid-cols-2">
|
|
<div className="overflow-hidden md:py-10 md:ps-10">
|
|
<ProductImages badges={badges} images={images} />
|
|
</div>
|
|
<div className="hide-scrollbar md:h-[var(--dialog-height)] md:max-h-[var(--dialog-max-height)] md:overflow-auto md:py-10 md:pe-10">
|
|
<div className="space-y-6">
|
|
<div className="space-y-1">
|
|
<DialogTitle className="text-3xl leading-tight font-light tracking-tight">
|
|
{name}
|
|
</DialogTitle>
|
|
<Price
|
|
onSale={sale != null}
|
|
className="items-end text-xl leading-relaxed"
|
|
>
|
|
{sale != null && (
|
|
<span className="leading-tight text-foreground">
|
|
{formatCurrency(sale, currency)}
|
|
</span>
|
|
)}
|
|
<span
|
|
className={
|
|
sale != null
|
|
? "leading-tight text-muted-foreground line-through"
|
|
: "leading-tight text-foreground"
|
|
}
|
|
>
|
|
{formatCurrency(regular, currency)}
|
|
</span>
|
|
</Price>
|
|
</div>
|
|
<div className="space-y-2 border-y py-4">
|
|
<StockStatus status={stockStatus} />
|
|
<ServicesList list={services} />
|
|
</div>
|
|
<div className="mt-7.5 space-y-3">
|
|
<Form hinges={hinges} />
|
|
<Button
|
|
asChild
|
|
variant="link"
|
|
className="px-0 underline underline-offset-4"
|
|
>
|
|
<a href={link}>مشاهده جزئیات</a>
|
|
</Button>
|
|
<AccordionSection section={accordion} />
|
|
</div>
|
|
<div className="mt-10">
|
|
<SocialMediaLinks links={socialLinks} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
const ProductImages = ({ images, galleryID, badges }: ProductImagesProps) => {
|
|
const [api, setApi] = useState<CarouselApi>();
|
|
const [current, setCurrent] = useState(0);
|
|
const lightboxRef = useRef<PhotoSwipeLightbox | null>(null);
|
|
|
|
const generatedGalleryID = useId();
|
|
const PhotoSwipeID = galleryID?.trim() ?? generatedGalleryID;
|
|
|
|
useEffect(() => {
|
|
const lightbox = new PhotoSwipeLightbox({
|
|
gallery: "#" + PhotoSwipeID,
|
|
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();
|
|
}, [PhotoSwipeID]);
|
|
|
|
useEffect(() => {
|
|
if (!api) return;
|
|
|
|
const updateCurrent = (api: CarouselApi) =>
|
|
setCurrent(api?.selectedScrollSnap() ?? 0);
|
|
updateCurrent(api);
|
|
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]);
|
|
|
|
const handleThumbnailClick = (index: number) => {
|
|
api?.scrollTo(index);
|
|
};
|
|
|
|
if (!images) return;
|
|
|
|
return (
|
|
<div className="space-y-4" id={PhotoSwipeID}>
|
|
<div className="relative overflow-hidden">
|
|
<Carousel setApi={setApi} className="group">
|
|
<CarouselContent className="ms-0">
|
|
{images.map((img, index) => (
|
|
<CarouselItem className="ps-0" key={index}>
|
|
<AspectRatio ratio={1} className="overflow-hidden rounded-sm">
|
|
<a
|
|
href={img.src}
|
|
data-pswp-width={img.width}
|
|
data-pswp-height={img.height}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
data-cropped="true"
|
|
>
|
|
<img
|
|
src={img.src}
|
|
alt={img.alt}
|
|
className="block size-full object-cover object-center"
|
|
/>
|
|
</a>
|
|
</AspectRatio>
|
|
</CarouselItem>
|
|
))}
|
|
</CarouselContent>
|
|
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
|
<CarouselPrevious className="start-6 -translate-x-5 group-hover:translate-x-0" />
|
|
<CarouselNext className="end-6 translate-x-5 group-hover:translate-x-0" />
|
|
</div>
|
|
</Carousel>
|
|
{badges && (
|
|
<div className="absolute inset-x-4 top-4">
|
|
<div className="flex items-center gap-3">
|
|
{badges?.map((item, index) => (
|
|
<Badge key={index}>{item}</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={clsx(
|
|
"relative max-md:hidden",
|
|
"after:pointer-events-none after:absolute after:inset-y-0 after:-end-px after:z-10 after:block after:h-full after:w-7.5 after:bg-linear-to-s after:from-background after:to-transparent",
|
|
"before:pointer-events-none before:absolute before:inset-y-0 before:-start-px before:z-10 before:block before:h-full before:w-7.5 before:bg-linear-to-e before:from-background before:to-transparent",
|
|
)}
|
|
>
|
|
<div className="hide-scrollbar w-full overflow-auto px-7.5">
|
|
<div className="flex items-center gap-4 py-0.5">
|
|
{images.map(({ thumbnail, alt }, index) => (
|
|
<button
|
|
data-state={index === current ? "active" : "inactive"}
|
|
className="size-18 shrink-0 basis-18 overflow-hidden rounded-xs border-border data-[state=active]:ring-2"
|
|
key={index}
|
|
onClick={() => handleThumbnailClick(index)}
|
|
>
|
|
<img
|
|
src={thumbnail}
|
|
alt={alt}
|
|
className="block size-full object-cover object-center"
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const StockStatus = ({ status }: StockStatusProps) => {
|
|
if (!status) return;
|
|
|
|
return (
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="shrink-0 basis-3">
|
|
<div
|
|
style={{
|
|
backgroundColor: STOCK_STATUS[status.code].color,
|
|
}}
|
|
className="size-3 rounded-full"
|
|
></div>
|
|
</div>
|
|
<p className="flex-1 leading-normal">
|
|
{status.code === STOCK_STATUS.IN_STOCK.code
|
|
? STOCK_STATUS.IN_STOCK.message(status?.quantity)
|
|
: STOCK_STATUS[status.code].message}
|
|
</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ServicesList = ({ list }: ServicesListProps) => {
|
|
if (!list) return;
|
|
|
|
return (
|
|
<ul className="space-y-2">
|
|
{list.map((item, index) => (
|
|
<li className="flex items-center gap-2.5" key={index}>
|
|
<item.icon className="size-4 shrink-0" />
|
|
<p className="flex-1 leading-normal">{item.text}</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
};
|
|
|
|
const formSchema = z.object({
|
|
color: z.string(),
|
|
quantity: z.number(),
|
|
});
|
|
|
|
const Form = ({ hinges, selected }: FormProps) => {
|
|
const form = useForm({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: {
|
|
color: selected?.color ?? "",
|
|
quantity: selected?.quantity ?? 1,
|
|
},
|
|
});
|
|
|
|
const onSubmit = (data: FormType) => {
|
|
console.log(data);
|
|
};
|
|
|
|
const colorHinges = hinges?.color;
|
|
const quantityHinges = hinges?.quantity;
|
|
|
|
return (
|
|
<div>
|
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
<div className="space-y-5">
|
|
{colorHinges && (
|
|
<Controller
|
|
control={form.control}
|
|
name={colorHinges.name}
|
|
render={({ field }) => (
|
|
<FieldSet className="gap-2">
|
|
<FieldLegend className="text-sm leading-normal">
|
|
{colorHinges.label}
|
|
</FieldLegend>
|
|
<ColorRadioGroup
|
|
field={field}
|
|
options={colorHinges.options}
|
|
/>
|
|
</FieldSet>
|
|
)}
|
|
/>
|
|
)}
|
|
{quantityHinges && (
|
|
<Controller
|
|
control={form.control}
|
|
name={quantityHinges.name}
|
|
render={({ field }) => (
|
|
<FieldSet>
|
|
<FieldLegend className="text-sm leading-normal">
|
|
{quantityHinges.label}
|
|
</FieldLegend>
|
|
<QuantityInput
|
|
onValueChange={field.onChange}
|
|
inputProps={{ ...field }}
|
|
className="max-w-30 rounded-sm"
|
|
/>
|
|
</FieldSet>
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="mt-8 space-y-2.5">
|
|
<Button size="lg" className="w-full uppercase">
|
|
افزودن به سبد
|
|
</Button>
|
|
<Button size="lg" variant="outline" className="w-full uppercase">
|
|
خرید فوری
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ColorRadioGroup = ({ options, field }: RadioGroupProps) => {
|
|
if (!options) return;
|
|
|
|
return (
|
|
<RadioGroup
|
|
{...field}
|
|
value={field.value as string}
|
|
onValueChange={field.onChange}
|
|
className="flex flex-wrap items-center gap-2"
|
|
>
|
|
{options.map((item, index) => (
|
|
<label
|
|
key={index}
|
|
htmlFor={item.id}
|
|
className="relative size-7 shrink-0 cursor-pointer overflow-hidden rounded-full border p-0.5 duration-400 has-checked:ring has-disabled:opacity-60"
|
|
>
|
|
<RadioGroupItem
|
|
id={item.id}
|
|
className="absolute size-px overflow-hidden opacity-0"
|
|
value={item.value}
|
|
aria-label={`انتخاب ${item.label}`}
|
|
disabled={item.stockStatusCode === STOCK_STATUS.OUT_OF_STOCK.code}
|
|
/>
|
|
<div
|
|
style={{
|
|
backgroundColor: item.color,
|
|
}}
|
|
className="size-full rounded-full"
|
|
></div>
|
|
</label>
|
|
))}
|
|
</RadioGroup>
|
|
);
|
|
};
|
|
|
|
const AccordionSection = ({ section }: AccordionSectionProps) => {
|
|
if (!section) return;
|
|
|
|
return (
|
|
<Accordion type="multiple" className="w-full border-y border-border">
|
|
{section.map(({ value, title, content }, index) => (
|
|
<AccordionItem value={value} key={index}>
|
|
<AccordionTrigger className="font-normal uppercase hover:no-underline">
|
|
{title}
|
|
</AccordionTrigger>
|
|
<AccordionContent>
|
|
<div className="space-y-4">
|
|
{content.text && (
|
|
<p className="leading-relaxed">{content.text}</p>
|
|
)}
|
|
{content.list && (
|
|
<ul className="space-y-1">
|
|
{content.list.map((text, index) => (
|
|
<li className="leading-relaxed" key={`list-${index}`}>
|
|
{text}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
))}
|
|
</Accordion>
|
|
);
|
|
};
|
|
|
|
const SocialMediaLinks = ({ links }: SocialMediaLinksProps) => {
|
|
if (!links) return;
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<p className="uppercase">اشتراکگذاری</p>
|
|
<ul className="flex flex-wrap gap-1">
|
|
{links.map(({ icon, link }) => (
|
|
<li key={crypto.randomUUID()}>
|
|
<Button size="icon-lg" asChild variant="ghost">
|
|
<a href={link}>
|
|
<img
|
|
className="size-5 dark:invert"
|
|
alt={icon.title}
|
|
src={icon.light}
|
|
/>
|
|
</a>
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export { ProductQuickView5 };
|