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

805 lines
25 KiB
TypeScript

"use client";
import "photoswipe/style.css";
import { zodResolver } from "@hookform/resolvers/zod";
import { Ruler, ZoomIn } from "lucide-react";
import type { PhotoSwipe } from "photoswipe/lightbox";
import PhotoSwipeLightbox from "photoswipe/lightbox";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ControllerRenderProps } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
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,
} from "@/components/ui/carousel";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Field, FieldLabel } from "@/components/ui/field";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { cn } from "@/lib/utils";
type StockStatusCode = "IN_STOCK" | "LOW_STOCK" | "OUT_OF_STOCK";
interface StockInfo {
stockStatusCode: StockStatusCode;
stockQuantity?: number;
}
interface StockMessageProps {
stockInfo?: StockInfo;
}
type sizeChartData = Array<{
label: string;
usa: string;
uk: string;
europe: string;
}>;
type Option = {
id: string;
label: string;
stockInfo: StockInfo;
value: string;
};
interface Hinges {
label: string;
name: FieldName;
id: string;
sizeChart?: sizeChartData;
options?: Option[];
}
interface RadioGroupProps {
options?: Array<Option>;
id: string;
field: ControllerRenderProps<FormType>;
}
interface PriceProps {
regular: number;
sale?: number;
currency: string;
text?: string;
}
interface SizeChartProps {
data?: sizeChartData;
}
type FormType = z.infer<typeof formSchema>;
type FieldName = keyof FormType;
interface ProductForm {
hinges: Record<FieldName, Hinges>;
selected: FormType;
}
interface ProductImagesProps {
images: Array<{
srcset: string;
src: string;
alt: string;
width: number;
height: number;
sizes: string;
thumbnail: string;
}>;
galleryID: string;
}
const PRODUCT_DETAILS = {
id: "1",
name: "پیراهن گلدوزی اسب داکوتا وسترن",
description:
"پیراهن دکمه‌دار با برش مدرن و پارچه نرم پنبه‌ای. مناسب محیط کار و استفاده روزمره؛ طراحی مینیمال با جزئیات دوخت دقیق و بسته‌شدن‌های دکمه‌ای کلاسیک.",
price: {
regular: 590000,
currency: "ریال",
sale: 490000,
},
size: "medium",
color: "white",
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 640w",
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Woman-in-Embroidered-Blue-Top-2.png",
sizes: "(min-width: 1024px) 1024px, 100vw",
thumbnail:
"/block-media/shadcnblocks/block/ecommerce/clothes/Woman-in-Embroidered-Blue-Top-1.png",
alt: "پیراهن گلدوزی اسب داکوتا وسترن",
width: 1024,
height: 1536,
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/clothes/Elegant-Professional-Look-2.png 1024w, /block-media/shadcnblocks/block/ecommerce/clothes/Elegant-Professional-Look-1.png 640w",
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Elegant-Professional-Look-2.png",
sizes: "(min-width: 1024px) 1024px, 100vw",
thumbnail:
"/block-media/shadcnblocks/block/ecommerce/clothes/Elegant-Professional-Look-1.png",
alt: "پیراهن گلدوزی اسب داکوتا وسترن",
width: 1024,
height: 1536,
},
{
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",
sizes: "(min-width: 1086px) 1086px, 100vw",
thumbnail:
"/block-media/shadcnblocks/block/ecommerce/clothes/Chic-Fashion-Portrait-2.png",
alt: "پیراهن گلدوزی اسب داکوتا وسترن",
width: 1086,
height: 1448,
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/clothes/Minimalist-Fashion-Portrait-2.png 1087w",
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Minimalist-Fashion-Portrait-2.png",
sizes: "(min-width: 1087px) 1087px, 100vw",
thumbnail:
"/block-media/shadcnblocks/block/ecommerce/clothes/Minimalist-Fashion-Portrait-2.png",
alt: "پیراهن گلدوزی اسب داکوتا وسترن",
width: 1087,
height: 1446,
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/clothes/Contemplative-Casual-Chic-2.png 1084w",
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Contemplative-Casual-Chic-2.png",
sizes: "(min-width: 1084px) 1084px, 100vw",
thumbnail:
"/block-media/shadcnblocks/block/ecommerce/clothes/Contemplative-Casual-Chic-2.png",
alt: "پیراهن گلدوزی اسب داکوتا وسترن",
width: 1084,
height: 1451,
},
],
hinges: {
size: {
label: "سایز",
id: "size",
name: "size" as const,
sizeChart: [
{
usa: "0",
label: "خیلی کوچک",
uk: "4",
europe: "32",
},
{
usa: "4",
label: "کوچک",
uk: "6",
europe: "34",
},
{
usa: "6",
label: "کوچک",
uk: "8",
europe: "36",
},
{
usa: "8",
label: "متوسط",
uk: "10",
europe: "38",
},
{
usa: "10",
label: "بزرگ",
uk: "12",
europe: "40",
},
],
options: [
{
id: "small",
value: "small",
label: "S",
stockInfo: {
stockStatusCode: "LOW_STOCK" as StockStatusCode,
stockQuantity: 10,
},
},
{
id: "medium",
value: "medium",
label: "M",
stockInfo: {
stockStatusCode: "IN_STOCK" as StockStatusCode,
},
},
{
id: "large",
value: "large",
label: "L",
stockInfo: {
stockStatusCode: "OUT_OF_STOCK" as StockStatusCode,
},
},
],
},
color: {
label: "رنگ",
id: "color",
name: "color" as const,
options: [
{
id: "white",
value: "white",
label: "سفید",
stockInfo: {
stockStatusCode: "IN_STOCK" as StockStatusCode,
},
},
{
id: "gray",
value: "gray",
label: "خاکستری",
stockInfo: {
stockStatusCode: "IN_STOCK" as StockStatusCode,
},
},
{
id: "black",
value: "black",
label: "مشکی",
stockInfo: {
stockStatusCode: "OUT_OF_STOCK" as StockStatusCode,
},
},
],
},
} as Record<FieldName, Hinges>,
};
const STATUS_CONFIG = {
IN_STOCK: {
color: "green",
message: "موجود، آماده ارسال",
},
LOW_STOCK: {
color: "amber",
getMessage: (qty?: number) =>
qty
? `موجودی کم - ${qty.toLocaleString("fa-IR")} عدد باقی مانده`
: "موجودی کم - چند عدد باقی مانده",
},
};
interface ProductDetail5Props {
className?: string;
}
const ProductDetail5 = ({ className }: ProductDetail5Props) => {
return (
<section className={cn("py-32", className)}>
<div className="mx-auto w-full max-w-[93.75rem] lg:px-12">
<div className="relative grid w-full grid-cols-1 justify-between gap-2.5 lg:grid-cols-5 lg:gap-15.5">
<div className="top-5 self-start lg:sticky lg:col-span-3">
<ProductImages
images={PRODUCT_DETAILS.images}
galleryID="gallery-5"
/>
</div>
<div className="justify-self-center lg:col-span-2">
<div className="flex max-w-150 flex-col gap-6 px-4 lg:max-w-full lg:px-0">
<h1 className="text-3xl leading-tight font-light uppercase lg:text-xl">
{PRODUCT_DETAILS.name}
</h1>
<Price {...PRODUCT_DETAILS.price} />
<Separator />
<ProductForm
hinges={PRODUCT_DETAILS.hinges}
selected={{
size: PRODUCT_DETAILS.size,
color: PRODUCT_DETAILS.color,
}}
/>
<p className="text-muted-foreground">
{PRODUCT_DETAILS.description}
</p>
</div>
</div>
</div>
</div>
</section>
);
};
const ProductImages = ({ images, galleryID }: ProductImagesProps) => {
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0);
const lightboxRef = useRef<PhotoSwipeLightbox | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
const thumbnailRef = useRef<HTMLUListElement>(null);
const isDesktop = useMediaQuery("(width >= 1024px)");
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;
lightbox.on("uiRegister", () => {
if (lightbox?.pswp?.ui) {
lightbox.pswp.ui.registerElement({
name: "custom-toolbar",
order: 10,
isButton: false,
appendTo: "root",
className:
"absolute bottom-7.5 inset-x-0 z-50 mx-auto flex w-fit items-center gap-7 animate-fade-in",
html: `
<button id="pswp-prev" class=" !bg-white flex !size-12 border text-white p-2 rounded-full">
<svg class="stroke-black m-auto size-6 stroke-1" 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-close" class=" !bg-white flex !size-15.5 border text-white p-2 rounded-full">
<svg class="stroke-black m-auto size-7.5 stroke-1" 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>
<button id="pswp-next" class=" !bg-white flex !size-12 border text-white p-2 rounded-full">
<svg class="stroke-black m-auto size-6 stroke-1" 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>
`,
onInit: (el, pswp) => {
el.querySelector("#pswp-prev")?.addEventListener("click", () =>
pswp.prev(),
);
el.querySelector("#pswp-next")?.addEventListener("click", () =>
pswp.next(),
);
el.querySelector("#pswp-close")?.addEventListener("click", () =>
pswp.close(),
);
(
pswp as PhotoSwipe & { customToolbarEl?: HTMLElement }
).customToolbarEl = el;
},
});
}
});
lightbox.on("close", () => {
const pswp = lightbox.pswp as PhotoSwipe & {
customToolbarEl?: HTMLElement;
};
if (pswp?.customToolbarEl) {
pswp.customToolbarEl.remove();
pswp.customToolbarEl = undefined;
}
});
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, current]);
useEffect(() => {
if (!previewRef.current || !thumbnailRef.current) return;
const observer = new ResizeObserver(([entry]) => {
if (!isDesktop && thumbnailRef.current) {
thumbnailRef.current.style.height = `${entry.contentRect.height}px`;
}
});
if (!isDesktop) {
observer.observe(previewRef.current);
}
return () => observer.disconnect();
}, [isDesktop]);
if (!images) return;
return (
<div className="flex w-full items-start gap-4">
<ul
ref={thumbnailRef}
className="hidden shrink-0 grow-0 flex-col gap-4.5 overflow-y-auto [-ms-overflow-style:none] [scrollbar-width:none] lg:flex [::-webkit-scrollbar]:hidden"
>
{images.map((img, index) => (
<li
className="w-full shrink-0 grow-0"
key={`product-detail-5-image-thumbnail-${index}`}
>
<button
onClick={() => api?.scrollTo(index)}
data-state={index + 1 === current ? "active" : "inactive"}
type="button"
className="relative block aspect-[0.76] w-20 overflow-hidden after:pointer-events-none after:absolute after:inset-0 after:z-10 after:block after:size-full after:inset-ring-2 after:inset-ring-transparent after:transition-shadow after:duration-200 after:content-[''] data-[state=active]:after:inset-ring-current"
>
<img
src={img.thumbnail}
alt={img.alt}
className="block size-full object-cover object-center"
/>
</button>
</li>
))}
</ul>
<div className="w-full">
<div
id={galleryID}
className="group/product-photos relative h-fit w-full"
>
<Carousel
setApi={setApi}
opts={{ loop: true, align: "center", startIndex: 0 }}
className="w-full"
>
<CarouselContent className="-ms-1">
{images.map((img, index) => (
<CarouselItem
key={`product-detail-5-image-${index}`}
className="w-full max-w-[75%] ps-1 lg:max-w-full"
>
<AspectRatio
ref={previewRef}
ratio={0.8}
className="w-full overflow-hidden bg-muted"
>
<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"
>
<img
srcSet={img.srcset}
alt={img.alt}
width={img.width}
height={img.height}
sizes={img.sizes}
className="block size-full object-cover object-center"
/>
{index + 1 === current ? (
<Badge
className="pointer-events-none absolute end-2.5 bottom-2.5 size-8.5 rounded-full bg-background p-1.5 text-xs lg:hidden"
variant="secondary"
>
<ZoomIn className="!size-6.5" />
</Badge>
) : null}
</a>
</AspectRatio>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</div>
<div className="lg:hidden">
<ol className="my-2.5 flex items-center justify-center gap-0.5">
{images.map((_, index) => (
<button
onClick={() => api?.scrollTo(index)}
data-current={index + 1 === current}
key={`product-detail-5-image-indicator-${index}`}
className="block size-4 data-[current=true]:[&>span]:scale-150 data-[current=true]:[&>span]:bg-primary"
>
<span className="block size-1.5 rounded-full bg-primary/50"></span>
</button>
))}
</ol>
</div>
</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-lg leading-tight"
>
{formatCurrency(sale, currency)}
</div>
)}
<div
data-sale={!!sale}
aria-label="قیمت اصلی"
className="text-lg leading-tight data-[sale=true]:line-through"
>
{formatCurrency(regular, currency)}
</div>
</div>
);
};
const formSchema = z.object({
color: z.string(),
size: z.string(),
});
const ProductForm = ({ hinges, selected }: ProductForm) => {
const form = useForm<FormType>({
resolver: zodResolver(formSchema),
defaultValues: {
color: selected.color,
size: selected.size,
},
});
const sizeHinges = hinges?.size;
const colorHinges = hinges?.color;
const sizeValue = form.watch("size");
const stockInfo = useMemo(() => {
return sizeHinges?.options?.find((item) => item.value === sizeValue)
?.stockInfo;
}, [sizeHinges?.options, sizeValue]);
const isOutOfStock = stockInfo?.stockStatusCode === "OUT_OF_STOCK";
function onSubmit(values: FormType) {
console.log(values);
}
return (
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-7"
>
{sizeHinges && (
<Controller
control={form.control}
name={sizeHinges.name}
render={({ field }) => (
<Field>
<div className="flex items-center justify-between gap-2">
<FieldLabel
asChild
className="font-light text-muted-foreground"
>
<h2>{sizeHinges.label}</h2>
</FieldLabel>
<SizeChart data={sizeHinges.sizeChart} />
</div>
<OptionGroup
field={field}
options={sizeHinges.options}
id={sizeHinges.id}
/>
</Field>
)}
/>
)}
{colorHinges && (
<Controller
control={form.control}
name={colorHinges.name}
render={({ field }) => (
<Field>
<FieldLabel asChild className="font-light text-muted-foreground">
<h2>{colorHinges.label}</h2>
</FieldLabel>
<OptionGroup
field={field}
options={colorHinges.options}
id={colorHinges.id}
/>
</Field>
)}
/>
)}
<StockMessege stockInfo={stockInfo} />
<div className="flex flex-col gap-2.5">
<Button variant="outline" size="lg" disabled={isOutOfStock}>
{isOutOfStock ? "فروخته شد" : "افزودن به سبد"}
</Button>
{!isOutOfStock && <Button size="lg">خرید فوری</Button>}
</div>
</form>
);
};
const StatIRRot = ({ color }: { color: string }) => (
<span className="relative size-6">
<span
style={{
backgroundColor: `var(--color-${color}-600)`,
}}
className="absolute inset-0 m-auto block size-2 animate-ping rounded-full opacity-75"
/>
<span
style={{
backgroundColor: `var(--color-${color}-500)`,
}}
className="absolute inset-0 m-auto block size-2 rounded-full"
/>
</span>
);
const StockMessege = ({ stockInfo }: StockMessageProps) => {
if (!stockInfo) return;
const { stockStatusCode, stockQuantity } = stockInfo;
if (stockStatusCode === "IN_STOCK" || stockStatusCode === "LOW_STOCK") {
return (
<div className="flex items-center gap-2.5">
<StatIRRot color={STATUS_CONFIG[stockStatusCode].color} />
<p className="text-base leading-[1.4] font-light">
{stockStatusCode === "IN_STOCK"
? STATUS_CONFIG.IN_STOCK.message
: STATUS_CONFIG.LOW_STOCK.getMessage(stockQuantity)}
</p>
</div>
);
}
return null;
};
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 gap-2"
>
{options &&
options.map((item, index) => (
<div key={`product-detail-5-input-${index}-${id}`}>
<label
htmlFor={item.id}
className="relative h-9 items-center justify-center rounded-md border-1 px-4 py-2 font-normal shadow-xs ring-primary has-checked:ring has-data-[disabled=true]:text-muted-foreground has-data-[disabled=true]:has-checked: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>
);
};
const SizeChart = ({ data }: SizeChartProps) => {
if (!data) return;
const cellClass = "border px-4 py-2.5 text-base font-light";
return (
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
variant="link"
className="h-fit text-xs text-muted-foreground"
size="sm"
>
جدول سایز
<Ruler />
</Button>
</DialogTrigger>
<DialogContent className="w-full max-w-100 p-11">
<DialogHeader className="sr-only">
<DialogTitle>راهنمای تبدیل سایز</DialogTitle>
<DialogDescription>
از این جدول برای یافتن سایز معادل در استانداردهای آمریکا، انگلستان و
اروپا استفاده کنید.
</DialogDescription>
</DialogHeader>
<Table className="border">
<TableHeader>
<TableRow>
<TableHead colSpan={2} className={`${cellClass} font-semibold`}>
آمریکا
</TableHead>
<TableHead className={`${cellClass} font-semibold`}>
انگلستان
</TableHead>
<TableHead className={`${cellClass} font-semibold`}>
اروپا
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((sizes, index) => (
<TableRow key={`product-detail-5-row-${index}`}>
<TableCell className={cellClass}>{sizes.usa}</TableCell>
<TableCell className={cellClass}>{sizes.label}</TableCell>
<TableCell className={cellClass}>{sizes.uk}</TableCell>
<TableCell className={cellClass}>{sizes.europe}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DialogContent>
</Dialog>
);
};
export { ProductDetail5 };