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

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 };