563 lines
18 KiB
TypeScript
563 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { ArrowLeft, ArrowRight, Award, Hammer, MapPin } from "lucide-react";
|
|
import { useCallback, useEffect, 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 { Button } from "@/components/ui/button";
|
|
import {
|
|
Carousel,
|
|
type CarouselApi,
|
|
CarouselContent,
|
|
CarouselItem,
|
|
} from "@/components/ui/carousel";
|
|
import { Field, FieldLabel } from "@/components/ui/field";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type StockStatusCode = "IN_STOCK" | "OUT_OF_STOCK";
|
|
|
|
interface StockInfo {
|
|
stockStatusCode: StockStatusCode;
|
|
stockQuantity?: number;
|
|
}
|
|
interface ProductImagesProps {
|
|
images: Array<{
|
|
srcset: string;
|
|
sizes: string;
|
|
alt: string;
|
|
}>;
|
|
}
|
|
|
|
interface ProductInfoSectionProps {
|
|
info: Array<{
|
|
title: string;
|
|
content: string;
|
|
}>;
|
|
}
|
|
|
|
type Option = {
|
|
id: string;
|
|
value: string;
|
|
label: string;
|
|
stockInfo: StockInfo;
|
|
};
|
|
|
|
interface Hinges {
|
|
label: string;
|
|
id: string;
|
|
min?: number;
|
|
max?: number;
|
|
name: FieldName;
|
|
options?: Option[];
|
|
}
|
|
|
|
interface ProductForm {
|
|
selected: FormType;
|
|
hinges: Record<FieldName, Hinges>;
|
|
}
|
|
|
|
interface RadioGroupProps {
|
|
options?: Array<Option>;
|
|
id: string;
|
|
field: ControllerRenderProps<FormType>;
|
|
}
|
|
|
|
interface PriceProps {
|
|
regular: number;
|
|
sale?: number;
|
|
currency: string;
|
|
}
|
|
|
|
type FormType = z.infer<typeof formSchema>;
|
|
type FieldName = keyof FormType;
|
|
|
|
const PRODUCT_DETAILS = {
|
|
id: "1",
|
|
images: [
|
|
{
|
|
srcset:
|
|
"/block-media/shadcnblocks/block/ecommerce/furniture/ozgur-ivo-1BQySo2Pnfc-unsplash-3.jpg 2400w, /block-media/shadcnblocks/block/ecommerce/furniture/ozgur-ivo-1BQySo2Pnfc-unsplash-2.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/furniture/ozgur-ivo-1BQySo2Pnfc-unsplash-1.jpg 640w",
|
|
sizes: "(min-width: 2400px) 2400px, (min-width: 1920px) 1920px, 100vw",
|
|
alt: "مبل چرمی اصل بریکستون",
|
|
},
|
|
|
|
{
|
|
srcset:
|
|
"/block-media/shadcnblocks/block/ecommerce/furniture/ozgur-ivo-dOfuTQp6PiI-unsplash-3.jpg 2400w, /block-media/shadcnblocks/block/ecommerce/furniture/ozgur-ivo-dOfuTQp6PiI-unsplash-2.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/furniture/ozgur-ivo-dOfuTQp6PiI-unsplash-1.jpg 640w",
|
|
sizes: "(min-width: 2400px) 2400px, (min-width: 1920px) 1920px, 100vw",
|
|
alt: "مبل چرمی اصل بریکستون",
|
|
},
|
|
{
|
|
srcset:
|
|
"/block-media/shadcnblocks/block/ecommerce/furniture/ozgur-ivo-XE5vVNRbkOk-unsplash-3.jpg 2400w, /block-media/shadcnblocks/block/ecommerce/furniture/ozgur-ivo-XE5vVNRbkOk-unsplash-2.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/furniture/ozgur-ivo-XE5vVNRbkOk-unsplash-1.jpg 640w",
|
|
sizes: "(min-width: 2400px) 2400px, (min-width: 1920px) 1920px, 100vw",
|
|
alt: "مبل چرمی اصل بریکستون",
|
|
},
|
|
{
|
|
srcset:
|
|
"/block-media/shadcnblocks/block/ecommerce/furniture/lucas-de-moura-uNZpPRPEnY4-unsplash-3.jpg 2400w, /block-media/shadcnblocks/block/ecommerce/furniture/lucas-de-moura-uNZpPRPEnY4-unsplash-2.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/furniture/lucas-de-moura-uNZpPRPEnY4-unsplash-1.jpg 640w",
|
|
sizes: "(min-width: 2400px) 2400px, (min-width: 1920px) 1920px, 100vw",
|
|
alt: "مبل چرمی اصل بریکستون",
|
|
},
|
|
{
|
|
srcset:
|
|
"/block-media/shadcnblocks/block/ecommerce/furniture/ozgur-ivo-GpKO8VYYWrA-unsplash-3.jpg 2400w, /block-media/shadcnblocks/block/ecommerce/furniture/ozgur-ivo-GpKO8VYYWrA-unsplash-2.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/furniture/ozgur-ivo-GpKO8VYYWrA-unsplash-1.jpg 640w",
|
|
sizes: "(min-width: 2400px) 2400px, (min-width: 1920px) 1920px, 100vw",
|
|
alt: "مبل چرمی اصل بریکستون",
|
|
},
|
|
{
|
|
srcset:
|
|
"/block-media/shadcnblocks/block/ecommerce/furniture/john-black-RV9T8txb2uY-unsplash-3.jpg 2400w, /block-media/shadcnblocks/block/ecommerce/furniture/john-black-RV9T8txb2uY-unsplash-2.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/furniture/john-black-RV9T8txb2uY-unsplash-1.jpg 640w",
|
|
sizes: "(min-width: 2400px) 2400px, (min-width: 1920px) 1920px, 100vw",
|
|
alt: "مبل چرمی اصل بریکستون",
|
|
},
|
|
],
|
|
hinges: {
|
|
material: {
|
|
label: "جنس",
|
|
id: "material",
|
|
name: "material",
|
|
options: [
|
|
{
|
|
id: "light-brown-leather",
|
|
value: "light-brown-leather",
|
|
label: "چرم قهوهای روشن",
|
|
stockInfo: {
|
|
stockStatusCode: "IN_STOCK" as StockStatusCode,
|
|
},
|
|
},
|
|
{
|
|
id: "dark-brown-leather",
|
|
value: "dark-brown-leather",
|
|
label: "چرم قهوهای تیره",
|
|
stockInfo: {
|
|
stockStatusCode: "IN_STOCK" as StockStatusCode,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
color: {
|
|
label: "رنگ",
|
|
id: "color",
|
|
name: "color",
|
|
options: [
|
|
{
|
|
id: "light-brown",
|
|
value: "light-brown",
|
|
label: "قهوهای روشن",
|
|
stockInfo: {
|
|
stockStatusCode: "IN_STOCK" as StockStatusCode,
|
|
},
|
|
},
|
|
{
|
|
id: "dark-brown",
|
|
value: "dark-brown",
|
|
label: "قهوهای تیره",
|
|
stockInfo: {
|
|
stockStatusCode: "IN_STOCK" as StockStatusCode,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
size: {
|
|
label: "سایز",
|
|
id: "size",
|
|
name: "size",
|
|
options: [
|
|
{
|
|
id: "90-inch",
|
|
value: "90-inch",
|
|
label: "عرض ۹۰ اینچ",
|
|
stockInfo: {
|
|
stockStatusCode: "OUT_OF_STOCK" as StockStatusCode,
|
|
},
|
|
},
|
|
{
|
|
id: "80-inch",
|
|
value: "80-inch",
|
|
label: "عرض ۸۰ اینچ",
|
|
stockInfo: {
|
|
stockStatusCode: "IN_STOCK" as StockStatusCode,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
quantity: {
|
|
label: "تعداد",
|
|
id: "quantity",
|
|
name: "quantity",
|
|
min: 1,
|
|
max: 90,
|
|
},
|
|
} as Record<FieldName, Hinges>,
|
|
material: "light-brown-leather",
|
|
color: "light-brown",
|
|
size: "80-inch",
|
|
price: { regular: 1899000, sale: 1599000, currency: "ریال" },
|
|
stockInfo: {
|
|
stockStatusCode: "IN_STOCK" as StockStatusCode,
|
|
},
|
|
name: "مبل چرمی اصل بریکستون",
|
|
location: "لس آنجلس، کالیفرنیا",
|
|
accordion: [
|
|
{
|
|
title: "جزئیات",
|
|
content:
|
|
"با چوب سخت کورهای و روکش چرم قهوهای روشن ممتاز ساخته شده، مبل بریکستون ظرافت مدرن میانه قرن را با راحتی روزمره ترکیب میکند. کوسنهای مقاوم و ساخت بادوام آن را برای هر فضای نشیمنی ایدهآل میکند.",
|
|
},
|
|
{
|
|
title: "زمان تحویل",
|
|
content:
|
|
"ارسال ظرف ۵ تا ۷ روز کاری. زمان تحویل بسته به موقعیت شما ممکن است متفاوت باشد.",
|
|
},
|
|
],
|
|
description:
|
|
"<p>جذابیت بیزمان مبل بریکستون را در مجموعه چرمی ما کشف کنید. برای راحتی طراحی شده و برای ماندگاری ساخته شده؛ این قطعه هر فضای نشیمنی را ارتقا میدهد.</p><p>با دقت و توجه به جزئیات ساخته شده و هر قطعه پیش از عرضه از آزمونهای سختگیرانه کیفیت عبور میکند. مواد ممتاز و تکنیکهای ساخت حرفهای، مبلمانی میسازد که نهتنها چشمنواز است بلکه در برابر زمان نیز مقاوم میماند.</p><p>تعهد ما به کیفیت فراتر از خود محصول است. از ایده اولیه طراحی تا تحویل نهایی، با هنرمندان ماهر همکاری میکنیم تا مبلمانی استثنایی خلق کنیم که زیبایی و کارایی را همزمان به خانه شما میآورد.</p>",
|
|
};
|
|
|
|
interface ProductDetail10Props {
|
|
className?: string;
|
|
}
|
|
|
|
const ProductDetail10 = ({ className }: ProductDetail10Props) => {
|
|
return (
|
|
<section className={cn("font-ubuntu py-32", className)}>
|
|
<div className="flex flex-col gap-7">
|
|
<ProductImages images={PRODUCT_DETAILS.images} />
|
|
<div className="flex w-full flex-col gap-8 px-4 lg:grid lg:grid-cols-3">
|
|
<div className="order-2 flex flex-col gap-8 lg:order-1">
|
|
<ul className="flex flex-col gap-2">
|
|
<li className="flex items-center gap-2 text-sm">
|
|
<Award className="size-5 stroke-1" />
|
|
چرم اصل
|
|
</li>
|
|
<li className="flex items-center gap-2 text-sm">
|
|
<Hammer className="size-5 stroke-1" />
|
|
دستساز
|
|
</li>
|
|
<li className="flex items-center gap-2 text-sm">
|
|
<MapPin className="size-5 stroke-1" />
|
|
طراحیشده در {PRODUCT_DETAILS.location}
|
|
</li>
|
|
</ul>
|
|
<ProductInfoSection info={PRODUCT_DETAILS.accordion} />
|
|
</div>
|
|
<div className="order-1 flex flex-col gap-8 lg:order-2">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex flex-col gap-4">
|
|
<h2 className="text-lg leading-none font-semibold">
|
|
{PRODUCT_DETAILS.name}
|
|
</h2>
|
|
<Price {...PRODUCT_DETAILS.price} />
|
|
</div>
|
|
<Button>خرید فوری</Button>
|
|
</div>
|
|
|
|
<ProductForm
|
|
hinges={PRODUCT_DETAILS.hinges}
|
|
selected={{
|
|
material: PRODUCT_DETAILS.material,
|
|
color: PRODUCT_DETAILS.color,
|
|
size: PRODUCT_DETAILS.size,
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="order-3 flex flex-col gap-4 text-muted-foreground">
|
|
<div
|
|
className="prose prose-sm"
|
|
dangerouslySetInnerHTML={{ __html: PRODUCT_DETAILS.description }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
const ProductImages = ({ images }: ProductImagesProps) => {
|
|
const [api, setApi] = useState<CarouselApi>();
|
|
const [current, setCurrent] = useState(0);
|
|
|
|
const onSelect = useCallback((carouselApi: CarouselApi) => {
|
|
if (!carouselApi) return;
|
|
setCurrent(carouselApi.selectedScrollSnap() + 1);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!api) return;
|
|
|
|
onSelect(api);
|
|
api.on("reInit", onSelect);
|
|
api.on("select", onSelect);
|
|
|
|
return () => {
|
|
api.off("select", onSelect);
|
|
};
|
|
}, [api, onSelect]);
|
|
|
|
if (!images) return;
|
|
|
|
return (
|
|
<div dir="rtl">
|
|
<Carousel
|
|
dir="rtl"
|
|
opts={{
|
|
loop: true,
|
|
align: "start",
|
|
direction: "rtl",
|
|
startIndex: 0,
|
|
}}
|
|
setApi={setApi}
|
|
className="w-full"
|
|
>
|
|
<CarouselContent className="ms-0 -me-4">
|
|
{images.map((img, index) => (
|
|
<CarouselItem
|
|
key={`product-detail-10-image-${index}`}
|
|
className="basis-full ps-0 pe-4 lg:basis-[22%]"
|
|
>
|
|
<AspectRatio ratio={1} className="overflow-hidden">
|
|
<img
|
|
srcSet={img.srcset}
|
|
sizes={img.sizes}
|
|
alt={img.alt}
|
|
className="block size-full object-cover object-center"
|
|
/>
|
|
</AspectRatio>
|
|
</CarouselItem>
|
|
))}
|
|
</CarouselContent>
|
|
</Carousel>
|
|
<div className="mt-2 grid grid-cols-2 items-center justify-between px-4">
|
|
<ul
|
|
data-show-dots={images.length >= 6}
|
|
className="flex w-fit items-center data-[show-dots=false]:lg:hidden"
|
|
>
|
|
{images.map((_, index) => (
|
|
<li key={`product-detail-10-indicator-${index}`}>
|
|
<button
|
|
aria-current={index + 1 === current ? "true" : undefined}
|
|
data-state={index + 1 === current ? "active" : "inactive"}
|
|
onClick={() => api?.scrollTo(index)}
|
|
type="button"
|
|
className="group flex size-4 shrink-0"
|
|
>
|
|
<div className="m-auto size-2 rounded-full border border-primary group-data-[state=active]:bg-primary"></div>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<div className="flex w-fit items-center justify-self-end">
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => api?.scrollPrev()}
|
|
aria-label="اسلاید قبلی"
|
|
>
|
|
<ArrowRight />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => api?.scrollNext()}
|
|
aria-label="اسلاید بعدی"
|
|
>
|
|
<ArrowLeft />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ProductInfoSection = ({ info }: ProductInfoSectionProps) => {
|
|
if (!info) return;
|
|
|
|
return (
|
|
<Accordion type="multiple" className="w-full">
|
|
{info.map((item, index) => (
|
|
<AccordionItem
|
|
value={`product-info-10-${index}`}
|
|
key={`product-detail-10-info-${index}`}
|
|
>
|
|
<AccordionTrigger className="text-sm font-normal text-foreground">
|
|
{item.title}
|
|
</AccordionTrigger>
|
|
<AccordionContent className="text-sm text-balance text-muted-foreground">
|
|
{item.content}
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
))}
|
|
</Accordion>
|
|
);
|
|
};
|
|
|
|
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 flex-wrap items-center gap-x-2.5 gap-y-1">
|
|
{sale ? (
|
|
<div
|
|
data-sale={!!sale}
|
|
aria-label="قیمت با تخفیف"
|
|
className="text-base data-[sale=true]:text-foreground"
|
|
>
|
|
{formatCurrency(sale, currency)}
|
|
</div>
|
|
) : null}
|
|
<div
|
|
data-sale={!!sale}
|
|
aria-label="قیمت اصلی"
|
|
className="text-base data-[sale=true]:font-normal data-[sale=true]:text-muted-foreground data-[sale=true]:line-through"
|
|
>
|
|
{formatCurrency(regular, currency)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const formSchema = z.object({
|
|
material: z.string(),
|
|
color: z.string(),
|
|
size: z.string(),
|
|
});
|
|
|
|
const ProductForm = ({ hinges, selected }: ProductForm) => {
|
|
const form = useForm<FormType>({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: {
|
|
material: selected.material,
|
|
color: selected.color,
|
|
size: selected.size,
|
|
},
|
|
});
|
|
|
|
function onSubmit(values: FormType) {
|
|
console.log(values);
|
|
}
|
|
|
|
const materialHinges = hinges?.material;
|
|
const colorHinges = hinges?.color;
|
|
const sizeHinges = hinges?.size;
|
|
|
|
return (
|
|
<form
|
|
className="flex flex-col gap-8"
|
|
onSubmit={form.handleSubmit(onSubmit)}
|
|
>
|
|
{materialHinges && (
|
|
<Controller
|
|
control={form.control}
|
|
name={materialHinges.name}
|
|
render={({ field }) => (
|
|
<Field>
|
|
<FieldLabel asChild>
|
|
<h2>{materialHinges.label}:</h2>
|
|
</FieldLabel>
|
|
<OptionGroup
|
|
field={field}
|
|
options={materialHinges.options}
|
|
id={materialHinges.id}
|
|
/>
|
|
</Field>
|
|
)}
|
|
/>
|
|
)}
|
|
{colorHinges && (
|
|
<Controller
|
|
control={form.control}
|
|
name={colorHinges.name}
|
|
render={({ field }) => (
|
|
<Field>
|
|
<FieldLabel asChild>
|
|
<h2>{colorHinges.label}:</h2>
|
|
</FieldLabel>
|
|
<OptionGroup
|
|
field={field}
|
|
options={colorHinges.options}
|
|
id={colorHinges.id}
|
|
/>
|
|
</Field>
|
|
)}
|
|
/>
|
|
)}
|
|
{sizeHinges && (
|
|
<Controller
|
|
control={form.control}
|
|
name={sizeHinges.name}
|
|
render={({ field }) => (
|
|
<Field>
|
|
<FieldLabel asChild>
|
|
<h2>{sizeHinges.label}:</h2>
|
|
</FieldLabel>
|
|
<OptionGroup
|
|
field={field}
|
|
options={sizeHinges.options}
|
|
id={sizeHinges.id}
|
|
/>
|
|
</Field>
|
|
)}
|
|
/>
|
|
)}
|
|
</form>
|
|
);
|
|
};
|
|
|
|
const OptionGroup = ({ options, id, 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-2"
|
|
>
|
|
{options &&
|
|
options.map((item, index) => (
|
|
<Field key={`product-detail-10-input-${id}-${index}`}>
|
|
<FieldLabel
|
|
htmlFor={item.id}
|
|
className="flex h-8 max-w-fit items-center justify-center rounded-md border border-muted bg-background px-3 text-xs font-normal shadow-xs has-checked:border-primary has-disabled:bg-muted has-disabled:text-muted-foreground"
|
|
>
|
|
<RadioGroupItem
|
|
id={item.id}
|
|
className="sr-only"
|
|
value={item.value}
|
|
aria-label={`انتخاب ${item.label}`}
|
|
disabled={item.stockInfo.stockStatusCode === "OUT_OF_STOCK"}
|
|
/>
|
|
<div>{item.label}</div>
|
|
</FieldLabel>
|
|
</Field>
|
|
))}
|
|
</RadioGroup>
|
|
);
|
|
};
|
|
|
|
export { ProductDetail10 };
|