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

843 lines
26 KiB
TypeScript

"use client";
import "photoswipe/style.css";
import { zodResolver } from "@hookform/resolvers/zod";
import type { LucideIcon } from "lucide-react";
import {
ChevronLeft,
ChevronRight,
Heart,
Minus,
Plus,
Ruler,
Shirt,
Truck,
ZoomIn,
} from "lucide-react";
import PhotoSwipeLightbox from "photoswipe/lightbox";
import { useCallback, useEffect, useRef, 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 { Badge, badgeVariants } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import type { CarouselApi } from "@/components/ui/carousel";
import {
Carousel,
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useGoogleFont } from "@/hooks/use-google-font";
import { cn } from "@/lib/utils";
type StockStatusCode = "IN_STOCK" | "OUT_OF_STOCK";
interface StockInfo {
stockStatusCode?: StockStatusCode;
stockQuantity?: number;
}
type option = {
id: string;
label: string;
value: string;
stockInfo: StockInfo;
};
interface Hinges {
label: string;
id: string;
name: FieldName;
options?: option[];
min?: number;
max?: number;
}
interface QuantityProps {
field: ControllerRenderProps<FormType>;
max?: number;
min?: number;
id?: string;
}
interface ProductFormProps {
hinges?: Record<FieldName, Hinges>;
selected: FormType;
}
interface RadioGroupProps {
options?: Array<option>;
field: ControllerRenderProps<FormType>;
}
interface PriceProps {
regular: number;
sale?: number;
currency: string;
text?: string;
}
interface ProductImagesProps {
images: Array<{
srcset: string;
src: string;
alt: string;
width: number;
height: number;
sizes: string;
}>;
galleryID: string;
}
type FormType = z.infer<typeof formSchema>;
type FieldName = keyof FormType;
interface ProductInfoProps {
info: {
title: {
icon: LucideIcon;
text: string;
};
content: string;
}[];
}
const PRODUCT_DETAILS = {
name: "کیف مزون لیورا",
price: {
regular: 420000.0,
sale: 399000.0,
currency: "ریال",
},
color: "black",
material: "leather",
description:
"این کیف دستی مجسمه‌ای و نرم، ظرافت و کارایی را در یک قطعه یکپارچه جمع کرده است. با یک تنظیم ساده، از کیف دوشی به دستی یا کراس‌بادی تبدیل می‌شود. بند جداشدنی به شما اجازه می‌دهد هر طور که روزتان می‌خواهد حملش کنیدروی شانه، در دست یا به صورت کراس. جا برای وسایل روزانه مثل گوشی، کیف کارت، کلید و آینه جمع‌وجور کافی دارد. از چرم دانه‌درشت ممتاز ساخته شده و با یراق‌های طلایی مات تکمیل شده است. با بسته‌شدن مغناطیسی محافظت می‌شود و داخل آن جیب زیپ‌دار و محفظه لغزشی برای نظم بیشتر قرار دارد.",
hinges: {
color: {
label: "رنگ",
id: "color",
name: "color",
options: [
{
id: "green",
value: "green",
label: "سبز",
stockInfo: {
stockStatusCode: "IN_STOCK",
},
},
{
id: "black",
value: "black",
label: "مشکی",
stockInfo: {
stockStatusCode: "IN_STOCK",
},
},
{
id: "white",
value: "white",
label: "سفید",
stockInfo: {
stockStatusCode: "OUT_OF_STOCK",
},
},
{
id: "red",
value: "red",
label: "قرمز",
stockInfo: {
stockStatusCode: "OUT_OF_STOCK",
},
},
],
},
material: {
label: "جنس",
id: "material",
name: "material",
options: [
{
id: "leather",
value: "leather",
label: "چرم",
stockInfo: {
stockStatusCode: "IN_STOCK",
},
},
{
id: "polyester",
value: "polyester",
label: "پلی‌استر",
stockInfo: {
stockStatusCode: "IN_STOCK",
},
},
{
id: "nylon",
value: "nylon",
label: "نایلون",
stockInfo: {
stockStatusCode: "IN_STOCK",
},
},
],
},
quantity: {
label: "تعداد",
id: "quantity",
name: "quantity",
min: 1,
max: 99,
},
} as Record<FieldName, Hinges>,
info: [
{
title: {
icon: Shirt,
text: "ترکیب ساخت",
},
content:
"از چرم دانه‌درشت ممتاز با سیلوئت ساخت‌یافته ساخته شده است. آستر داخلی از کتان نرم دوخته شده. با یراق‌های طلایی مات تزیین شده. شامل دو محفظه داخلی و یک جیب زیپ‌دار است. همراه با بند قابل تنظیم و جداشدنی برای حمل چندسبکه.",
},
{
title: {
icon: Truck,
text: "ارسال و مرجوعی",
},
content:
"از ارسال رایگان و مرجوعی آسان برای همه خریدها بهره‌مند شوید. سفارش‌ها معمولاً ظرف ۵ تا ۷ روز کاری ارسال می‌شوند.",
},
{
title: {
icon: Ruler,
text: "ابعاد کیف",
},
content:
"ارتفاع: ۲۱ سانتی‌متر × عرض: ۲۸ سانتی‌متر (۸٫۳ اینچ × ۱۱ اینچ) به‌راحتی وسایل روزانه جا می‌شود.",
},
{
title: {
icon: Heart,
text: "راهنمای نگهداری",
},
content:
"با پارچه تمیز و نم‌دار به‌آرامی پاک کنید. از مواد شوینده قوی و قرار گرفتن مستقیم در آفتاب یا رطوبت خودداری کنید. هنگام استفاده نکردن در کیس محافظ ارائه‌شده نگه دارید.",
},
],
images: [
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/clothes/pexels-429124762-20851442-3.jpg 1086w, /block-media/shadcnblocks/block/ecommerce/clothes/pexels-429124762-20851442-2.jpg 1086w, /block-media/shadcnblocks/block/ecommerce/clothes/pexels-429124762-20851442-1.jpg 1086w",
src: "/block-media/shadcnblocks/block/ecommerce/clothes/pexels-429124762-20851442-3.jpg",
alt: "کیف مزون لیورا",
width: 1086,
height: 1448,
sizes: "(min-width: 1086px) 1086px, 100vw",
},
{
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: "(min-width: 1024px) 1024px, 100vw",
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/clothes/Stylish-Woman-1-with-Tote-2.png 1024w, /block-media/shadcnblocks/block/ecommerce/clothes/Stylish-Woman-1-with-Tote-1.png 1024w",
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Stylish-Woman-1-with-Tote-2.png",
alt: "کیف مزون لیورا",
width: 1024,
height: 1536,
sizes: "(min-width: 1024px) 1024px, 100vw",
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/clothes/Minimalist-Fashion-Look-2.png 1023w, /block-media/shadcnblocks/block/ecommerce/clothes/Minimalist-Fashion-Look-1.png 1023w",
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Minimalist-Fashion-Look-2.png",
alt: "کیف مزون لیورا",
width: 1023,
height: 1537,
sizes: "(min-width: 1023px) 1023px, 100vw",
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/clothes/Woman-with-Leather-Tote-2.png 1024w, /block-media/shadcnblocks/block/ecommerce/clothes/Woman-with-Leather-Tote-1.png 1024w",
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Woman-with-Leather-Tote-2.png",
alt: "کیف مزون لیورا",
width: 1024,
height: 1536,
sizes: "(min-width: 1024px) 1024px, 100vw",
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/clothes/Woman-with-Tote-Bag-2.png 1023w, /block-media/shadcnblocks/block/ecommerce/clothes/Woman-with-Tote-Bag-1.png 1023w",
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Woman-with-Tote-Bag-2.png",
alt: "کیف مزون لیورا",
width: 1023,
height: 1537,
sizes: "(min-width: 1023px) 1023px, 100vw",
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/clothes/Stylish-Woman-in-Interior-2.png 1023w, /block-media/shadcnblocks/block/ecommerce/clothes/Stylish-Woman-in-Interior-1.png 1023w",
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Stylish-Woman-in-Interior-2.png",
alt: "کیف مزون لیورا",
width: 1023,
height: 1537,
sizes: "(min-width: 1023px) 1023px, 100vw",
},
],
};
interface ProductDetail2Props {
className?: string;
}
const ProductDetail2 = ({ className }: ProductDetail2Props) => {
useGoogleFont("Assistant");
return (
<section
className={cn("bg-background py-32 font-sans", className)}
style={
{
"--font-sans": "Assistant",
} as React.CSSProperties
}
>
<div className="mx-auto w-full max-w-[75rem] px-4 md:px-12.5">
<div className="relative flex flex-col gap-4 md:flex-row md:gap-10">
<div className="basis-[65%]">
<ProductImages
images={PRODUCT_DETAILS.images}
galleryID="gallery-2"
/>
</div>
<div className="basis-[35%]">
<div className="sticky top-12 flex flex-col gap-5">
<h1 className="text-3xl leading-tight md:text-[2.5rem]">
{PRODUCT_DETAILS.name}
</h1>
<div className="flex items-center gap-2.5">
<Price {...PRODUCT_DETAILS.price} />
</div>
<ProductForm
hinges={PRODUCT_DETAILS.hinges}
selected={{
color: PRODUCT_DETAILS.color,
material: PRODUCT_DETAILS.material,
quantity: 1,
}}
/>
<p className="text-base leading-loose text-muted-foreground">
{PRODUCT_DETAILS.description}
</p>
<ProductInfo info={PRODUCT_DETAILS.info} />
</div>
</div>
</div>
</div>
</section>
);
};
const ProductImages = ({ images, galleryID }: ProductImagesProps) => {
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(1);
const lightboxRef = useRef<PhotoSwipeLightbox | null>(null);
const [canScrollPrev, setCanScrollPrev] = useState(false);
const [canScrollNext, setCanScrollNext] = useState(false);
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;
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();
}, [galleryID]);
const onSelect = useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
setCurrent(api.selectedScrollSnap() + 1);
}, []);
useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
useEffect(() => {
if (lightboxRef.current && api) {
lightboxRef.current.on("change", () => {
api?.scrollTo(lightboxRef.current?.pswp?.currIndex || 0);
});
}
}, [api, current]);
if (!images) return;
return (
<div className="flex flex-col gap-3" id={galleryID}>
<Carousel
setApi={setApi}
opts={{
breakpoints: {
"(min-width: 768px)": {
active: false,
},
},
}}
>
<CarouselContent className="gap-2 md:m-0 md:grid md:grid-cols-2">
{images.map((img, index) => (
<CarouselItem
className="first:col-span-2 md:p-0"
key={`product-detail-2-image-${index}`}
>
<AspectRatio ratio={1} className="overflow-hidden">
<a
href={img.src}
data-pswp-width={img.width}
data-pswp-height={img.height}
target="_blank"
rel="noreferrer"
data-cropped="true"
className="hover:cursor-zoom-in"
>
<img
src={img.src}
alt={img.alt}
className="block size-full object-cover object-center"
/>
<Badge
className="pointer-events-none absolute top-3 start-3 size-8 rounded-full bg-background p-1.5 text-xs shadow md:hidden"
variant="secondary"
>
<ZoomIn className="!size-3.5 stroke-muted-foreground" />
</Badge>
</a>
</AspectRatio>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
<div className="flex items-center justify-center gap-2.5 md:hidden">
<Button
disabled={!canScrollPrev}
size="icon"
variant="ghost"
onClick={() => api?.scrollPrev()}
>
<ChevronLeft className="size-3 stroke-1 rtl:rotate-180" />
</Button>
<p className="text-xs text-muted-foreground">
{current.toLocaleString("fa-IR")}/{images.length.toLocaleString("fa-IR")}
</p>
<Button
disabled={!canScrollNext}
size="icon"
variant="ghost"
onClick={() => api?.scrollNext()}
>
<ChevronRight className="size-3 stroke-1 rtl:rotate-180" />
</Button>
</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-xl leading-tight text-primary"
>
{formatCurrency(sale, currency)}
</div>
)}
<div
data-sale={!!sale}
aria-label="قیمت اصلی"
className="text-xl leading-tight data-[sale=true]:text-xl data-[sale=true]:text-muted-foreground data-[sale=true]:line-through"
>
{formatCurrency(regular, currency)}
</div>
</div>
);
};
const formSchema = z.object({
color: z.string(),
quantity: z.number().min(1),
material: z.string(),
});
const ProductForm = ({ hinges, selected }: ProductFormProps) => {
const form = useForm<FormType>({
resolver: zodResolver(formSchema),
defaultValues: {
quantity: selected.quantity,
color: selected.color,
material: selected.material,
},
});
function onSubmit(values: FormType) {
console.log(values);
}
const colorHinges = hinges?.color;
const materialHinges = hinges?.material;
const quantityHinges = hinges?.quantity;
return (
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
{colorHinges && (
<Controller
control={form.control}
name={colorHinges.name}
render={({ field }) => (
<Field>
<FieldLabel
asChild
className="text-sm leading-normal text-muted-foreground"
>
<h2>{colorHinges.label}</h2>
</FieldLabel>
<ColorRadioGroup field={field} options={colorHinges.options} />
</Field>
)}
/>
)}
{materialHinges && (
<Controller
control={form.control}
name={materialHinges.name}
render={({ field }) => (
<Field>
<FieldLabel
asChild
className="text-sm leading-normal text-muted-foreground"
>
<h2>{materialHinges.label}</h2>
</FieldLabel>
<MaterialRadioGroup
field={field}
options={materialHinges.options}
/>
</Field>
)}
/>
)}
{quantityHinges && (
<Controller
control={form.control}
name={quantityHinges.name}
render={({ field }) => (
<Field>
<FieldLabel
htmlFor={quantityHinges.id}
className="text-sm leading-normal text-muted-foreground"
>
{quantityHinges.label}
</FieldLabel>
<Quantity
field={field}
min={quantityHinges.min}
max={quantityHinges.max}
id={quantityHinges.id}
/>
</Field>
)}
/>
)}
<div className="flex flex-col gap-2.5">
<Button size="lg" variant="outline">
افزودن به سبد
</Button>
<Button size="lg">خرید</Button>
</div>
</form>
);
};
const Quantity = ({ field, max, min, id }: QuantityProps) => {
return (
<div className="flex h-9 w-31 items-center justify-between overflow-hidden rounded-md border shadow-xs">
<Button
onClick={() =>
field.onChange(Math.max(min || 1, Number(field.value || 1) - 1))
}
variant="ghost"
type="button"
size="icon"
className="shrink-0 rounded-none"
>
<Minus className="stroke-muted-foreground" />
</Button>
<Input
{...field}
value={field.value ?? ""}
onChange={(e) => {
const raw = e.target.value;
const parsed = parseInt(raw, 10);
// Accept empty string for typing, but validate if not a number
if (raw === "") {
field.onChange("");
} else if (!isNaN(parsed)) {
field.onChange(parsed);
}
}}
type="number"
id={id}
min={Math.max(min ?? 1, 1)}
max={max ?? 99}
className="h-full w-full flex-1 [appearance:textfield] rounded-none border-0 !bg-background px-1 text-center shadow-none focus-visible:ring-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<Button
onClick={() =>
field.onChange(Math.min(max || 99, Number(field.value || 1) + 1))
}
variant="ghost"
type="button"
size="icon"
className="shrink-0 rounded-none"
>
<Plus className="stroke-muted-foreground" />
</Button>
</div>
);
};
const ColorRadioGroup = ({ options, 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-4"
>
{options.map((item, index) => (
<div className="basis-20" key={`product-detail-2-size-input-${index}`}>
<label
htmlFor={item.id}
className="flex h-8 items-center justify-center rounded-md border-1 border-primary px-3 text-sm leading-snug font-normal has-checked:bg-primary has-checked:text-primary-foreground has-data-[disabled=true]:border-border has-data-[disabled=true]:text-muted-foreground has-data-[disabled=true]:line-through"
>
<RadioGroupItem
id={item.id}
className="sr-only"
value={item.value}
aria-label={`انتخاب ${item.label}`}
data-disabled={item.stockInfo.stockStatusCode === "OUT_OF_STOCK"}
/>
<span>{item.label}</span>
</label>
</div>
))}
</RadioGroup>
);
};
const MaterialRadioGroup = ({ options, 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-4"
>
{options.map((item, index) => (
<div key={`product-detail-2-material-input-${index}`}>
<label
htmlFor={item.id}
className="flex h-8 items-center justify-center rounded-md border-1 border-primary px-3 text-sm leading-snug font-normal has-checked:bg-primary has-checked:text-primary-foreground has-data-[disabled=true]:border-border has-data-[disabled=true]:text-muted-foreground has-data-[disabled=true]:line-through"
>
<RadioGroupItem
id={item.id}
className="sr-only"
value={item.value}
data-disabled={item.stockInfo.stockStatusCode === "OUT_OF_STOCK"}
/>
<span>{item.label}</span>
</label>
</div>
))}
</RadioGroup>
);
};
const ProductInfo = ({ info }: ProductInfoProps) => {
if (!info) return;
return (
<Accordion type="multiple" className="w-full">
{info.map((item, index) => (
<AccordionItem
key={`product-detail-2-info-${index}`}
value={`item-${index}`}
>
<AccordionTrigger>
<div className="text-base">{item.title.text}</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<p className="text-base leading-loose text-muted-foreground">
{item.content}
</p>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
};
export { ProductDetail2 };