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

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