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

643 lines
21 KiB
TypeScript

"use client";
import "photoswipe/style.css";
import { zodResolver } from "@hookform/resolvers/zod";
import type { LucideIcon } from "lucide-react";
import {
Circle,
Flower2,
Heart,
Leaf,
Minus,
Plus,
Sprout,
} from "lucide-react";
import PhotoSwipeLightbox from "photoswipe/lightbox";
import { Fragment, 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 } 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 { Input } from "@/components/ui/input";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { cn } from "@/lib/utils";
interface ProductImagesProps {
images: Array<{
srcset: string;
src: string;
alt: string;
width: number;
height: number;
sizes: string;
thumbnail: string;
}>;
galleryID: string;
}
interface ProductFeatures {
features: Array<string>;
}
interface SustainabilitySectionProps {
sustainability: Array<{
text: string;
icon: LucideIcon;
}>;
}
interface ProductInfoSectionsProps {
info: Array<{
title: string;
content: string;
}>;
}
interface PriceProps {
regular: number;
sale?: number;
currency: string;
discount?: string;
}
interface QuantityProps {
field: ControllerRenderProps<FormType>;
max?: number;
min?: number;
}
interface Hinges {
label: string;
id: string;
name: FieldName;
min?: number;
max?: number;
}
interface ProductFormProps {
availability: boolean;
hinges: Record<FieldName, Hinges>;
}
type FormType = z.infer<typeof formSchema>;
type FieldName = keyof FormType;
const PRODUCT_DETAILS = {
brand: "گیاهان لونارا",
name: "سرم درخشندگی رادیانت",
new: true,
availability: true,
price: {
regular: 550000,
currency: "ریال",
sale: 460000,
discount: "-۱۵٪",
},
hinges: {
quantity: {
label: "تعداد",
id: "quantity",
name: "quantity",
min: 1,
max: 99,
},
} as Record<FieldName, Hinges>,
features: [
"افزایش درخشندگی و آبرسانی پوست",
"غنی از ویتامین C و هیالورونیک اسید",
"مناسب همه انواع پوست، از جمله پوست حساس",
"تست‌شده توسط متخصص پوست و نکومدوژنیک",
"جذب سریع بدون حس چسبندگی",
],
sustainability: [
{
text: "سازگار با محیط زیست",
icon: Leaf,
},
{
text: "ارگانیک",
icon: Flower2,
},
{
text: "طبیعی",
icon: Sprout,
},
{
text: "تولید اخلاقی",
icon: Heart,
},
],
accordion: [
{
title: "معرفی محصول",
content:
"سرم درخشندگی رادیانت لونارا، سرمی سبک و سریع‌جذب برای روشن‌کردن، آبرسانی و یکدست‌کردن رنگ پوست است. با ۱۰٪ ویتامین C و عصاره‌های گیاهی فرموله شده و برای بازگرداندن درخشش طبیعی و محافظت در برابر عوامل محیطی ایده‌آل است.",
},
{
title: "نحوه استفاده",
content:
"هر صبح ۲ تا ۳ قطره روی پوست تمیز و خشک بزنید. به‌آرامی روی صورت و گردن ماساژ دهید، پیش از مرطوب‌کننده یا ضدآفتاب. فقط برای استفاده خارجی.",
},
{
title: "لیست کامل مواد تشکیل‌دهنده",
content:
"Aqua (Water), Ascorbic Acid (Vitamin C), Sodium Hyaluronate, Aloe Barbadensis Leaf Juice, Glycerin, Niacinamide, Tocopherol (Vitamin E), Citrus Aurantium Dulcis (Orange) Peel Oil, Potassium Sorbate, Sodium Benzoate.",
},
{
title: "ارسال و مرجوعی",
content:
"ارسال استاندارد برای سفارش‌های بالای ۵۰ ریال رایگان است. مرجوعی تا ۳۰ روز پس از خرید برای محصولات بازنشده و استفاده‌نشده پذیرفته می‌شود. برای جزئیات کامل به وب‌سایت ما مراجعه کنید.",
},
],
images: [
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/skin-care-product/point-normal-Xp0bTCLD07k-unsplash-3.jpg 2160w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/point-normal-Xp0bTCLD07k-unsplash-2.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/point-normal-Xp0bTCLD07k-unsplash-1.jpg 640w",
src: "/block-media/shadcnblocks/block/ecommerce/skin-care-product/point-normal-Xp0bTCLD07k-unsplash-3.jpg",
thumbnail:
"/block-media/shadcnblocks/block/ecommerce/skin-care-product/point-normal-Xp0bTCLD07k-unsplash-1.jpg",
sizes: "(min-width: 2160px) 2160px, (min-width: 1920px) 1920px, 100vw",
alt: "سرم درخشندگی رادیانت",
width: 2160,
height: 2700,
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/skin-care-product/brittney-weng-CSquI0aw_Rg-unsplash-3.jpg 2400w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/brittney-weng-CSquI0aw_Rg-unsplash-2.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/brittney-weng-CSquI0aw_Rg-unsplash-1.jpg 640w",
src: "/block-media/shadcnblocks/block/ecommerce/skin-care-product/brittney-weng-CSquI0aw_Rg-unsplash-3.jpg",
thumbnail:
"/block-media/shadcnblocks/block/ecommerce/skin-care-product/brittney-weng-CSquI0aw_Rg-unsplash-1.jpg",
sizes: "(min-width: 2400px) 2400px, (min-width: 1920px) 1920px, 100vw",
alt: "سرم درخشندگی رادیانت",
width: 2400,
height: 3000,
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/skin-care-product/kadarius-seegars-Mxy5gokl8mE-unsplash-3.jpg 2400w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/kadarius-seegars-Mxy5gokl8mE-unsplash-2.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/skin-care-product/kadarius-seegars-Mxy5gokl8mE-unsplash-1.jpg 640w",
src: "/block-media/shadcnblocks/block/ecommerce/skin-care-product/kadarius-seegars-Mxy5gokl8mE-unsplash-3.jpg",
thumbnail:
"/block-media/shadcnblocks/block/ecommerce/skin-care-product/kadarius-seegars-Mxy5gokl8mE-unsplash-1.jpg",
sizes: "(min-width: 2400px) 2400px, (min-width: 1920px) 1920px, 100vw",
alt: "سرم درخشندگی رادیانت",
width: 2400,
height: 3600,
},
],
};
interface ProductDetail7Props {
className?: string;
}
const ProductDetail7 = ({ className }: ProductDetail7Props) => {
return (
<section className={cn("py-32", className)}>
<div className="mx-auto w-full max-w-375 px-4 font-sans">
<div className="relative flex w-full flex-col items-start justify-center gap-6 md:flex-row md:gap-8 xl:gap-20">
<div className="top-4 w-full flex-1 self-start md:sticky lg:top-32">
<ProductImages
images={PRODUCT_DETAILS.images}
galleryID="gallery-7"
/>
</div>
<div className="w-full shrink-0 md:w-[42%]">
<div className="flex flex-col gap-1">
<div className="text-xs leading-normal tracking-widest text-muted-foreground uppercase">
{PRODUCT_DETAILS.brand}
</div>
<h1 className="font-serif text-[2rem] leading-tight">
{PRODUCT_DETAILS.name}
</h1>
</div>
<div className="py-3">
<Price {...PRODUCT_DETAILS.price} />
</div>
<ProductForm
hinges={PRODUCT_DETAILS.hinges}
availability={PRODUCT_DETAILS.availability}
/>
<ProductFeatures features={PRODUCT_DETAILS.features} />
<SustainabilitySection
sustainability={PRODUCT_DETAILS.sustainability}
/>
<ProductInfoSections info={PRODUCT_DETAILS.accordion} />
</div>
</div>
</div>
</section>
);
};
const Price = ({ regular, sale, currency, discount }: 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-2.5">
{sale ? (
<div
data-sale={!!sale}
aria-label="قیمت با تخفیف"
className="text-xl leading-normal font-medium text-red-600"
>
{formatCurrency(sale, currency)}
</div>
) : null}
<div
data-sale={!!sale}
aria-label="قیمت اصلی"
className="text-xl leading-normal font-medium data-[sale=true]:font-normal data-[sale=true]:text-muted-foreground data-[sale=true]:line-through"
>
{formatCurrency(regular, currency)}
</div>
{discount && <Badge variant="destructive">{discount}</Badge>}
</div>
);
};
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 >= 900px)");
useEffect(() => {
const lightbox = new PhotoSwipeLightbox({
gallery: "#" + galleryID,
children: "a",
bgOpacity: 1,
wheelToZoom: true,
mainClass:
"[&>div:first-child]:!bg-background [&_*]:!text-foreground [&_*]:!text-shadow-none",
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, 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 (
<Fragment>
<div className="flex w-full items-start justify-between gap-4">
<ul
ref={thumbnailRef}
className="hidden w-20 shrink-0 grow-0 flex-col gap-2 overflow-y-auto [-ms-overflow-style:none] [scrollbar-width:none] md:flex [::-webkit-scrollbar]:hidden"
>
{images.map((img, index) => (
<li
className="w-full shrink-0"
key={`product-detail-7-thumbnail-${index}`}
>
<button
onClick={() => api?.scrollTo(index)}
data-state={index + 1 === current ? "active" : "inactive"}
type="button"
className="relative block size-20 overflow-hidden rounded-[0.875rem] after:pointer-events-none after:absolute after:inset-0 after:z-10 after:block after:size-full after:rounded-[0.875rem] 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"
loading="lazy"
/>
</button>
</li>
))}
</ul>
<div className="w-full md:max-w-2xl">
<div className="group/product-photos relative h-fit w-full">
<Badge className="absolute top-4 start-4 z-10 rounded-full bg-lime-200 px-4 py-1 text-base leading-snug text-black">
جدید
</Badge>
<div className="pswp-gallery" id={galleryID}>
<Carousel setApi={setApi} className="w-full">
<CarouselContent className="-ms-2">
{images.map((img, index) => (
<CarouselItem
key={`product-detail-7-image-${index}`}
className="w-full ps-2"
>
<AspectRatio
ref={previewRef}
ratio={0.8}
className="w-full overflow-hidden rounded-[0.875rem] 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"
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 rounded-[0.875rem] object-cover object-center"
/>
</a>
</AspectRatio>
</CarouselItem>
))}
</CarouselContent>
<div className="hidden opacity-0 group-hover/product-photos:opacity-100 md:block">
{current > 1 && (
<CarouselPrevious className="start-0 ms-6 size-11 dark:bg-white dark:hover:bg-white dark:[&>svg]:stroke-black" />
)}
{current < images.length && (
<CarouselNext className="end-0 me-6 size-11 dark:bg-white dark:hover:bg-white dark:[&>svg]:stroke-black" />
)}
</div>
</Carousel>
</div>
<Badge
className="absolute bottom-4 start-4 rounded-full bg-background py-1.5 text-xs xl:hidden"
variant="secondary"
>
{(current || 1).toLocaleString("fa-IR")} /{" "}
{images.length.toLocaleString("fa-IR")}
</Badge>
</div>
<div className="relative my-2 h-[0.1875rem] w-full overflow-hidden rounded-[0.625rem] bg-muted xl:hidden">
<div
style={{
width: `calc(100% / ${images.length})`,
insetInlineStart: `calc((100% / ${images.length}) * ${current - 1})`,
}}
className="absolute h-full bg-foreground transition-[inset-inline-start] duration-300"
></div>
</div>
</div>
</div>
</Fragment>
);
};
const formSchema = z.object({
quantity: z.number().min(1),
});
const ProductForm = ({ availability, hinges }: ProductFormProps) => {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const stickyButtonWrapperRef = useRef<HTMLDivElement>(null);
const form = useForm<FormType>({
resolver: zodResolver(formSchema),
defaultValues: {
quantity: 1,
},
});
function onSubmit(values: FormType) {
console.log(values);
}
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (stickyButtonWrapperRef.current) {
stickyButtonWrapperRef.current.classList.toggle(
"opacity-0",
entry.isIntersecting,
);
}
});
},
{
threshold: 0.02,
},
);
if (submitButtonRef.current) {
observer.observe(submitButtonRef.current);
}
}, []);
const quantityHinges = hinges?.quantity;
return (
<form
className="flex flex-wrap items-center gap-4 py-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<Controller
control={form.control}
name="quantity"
render={({ field }) => (
<Field>
<Quantity
field={field}
min={quantityHinges.min}
max={quantityHinges.max}
/>
</Field>
)}
/>
<Button
size="lg"
type="submit"
className="flex-1"
ref={submitButtonRef}
disabled={!availability}
>
{availability ? "افزودن به سبد" : "فروخته شد"}
</Button>
{availability && (
<div
ref={stickyButtonWrapperRef}
className="fixed inset-x-0 bottom-0 z-10 w-full bg-background p-4 opacity-0 transition-opacity duration-300 md:hidden"
>
<Button
size="lg"
type="submit"
className="w-full"
disabled={!availability}
>
افزودن به سبد
</Button>
</div>
)}
</form>
);
};
const Quantity = ({ field, max, min }: QuantityProps) => {
return (
<div className="flex h-10 w-32 shrink-0 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="size-10 shrink-0 rounded-none"
>
<Minus />
</Button>
<Input
{...field}
value={field.value ?? ""}
onChange={(e) => {
const raw = e.target.value;
const parsed = parseInt(raw, 10);
if (raw === "") {
field.onChange("");
} else if (!isNaN(parsed)) {
field.onChange(parsed);
}
}}
type="number"
min={min ? min : 1}
max={max ? max : 99}
className="h-10 w-full [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="size-10 shrink-0 rounded-none"
>
<Plus />
</Button>
</div>
);
};
const ProductFeatures = ({ features }: ProductFeatures) => {
if (!features) return;
return (
<ul className="mt-2 mb-6 flex flex-col gap-1">
{features.map((text, index) => (
<li
className="flex items-center gap-3"
key={`product-detail-7-features-${index}`}
>
<Circle className="size-1 fill-foreground stroke-foreground" />
{text}
</li>
))}
</ul>
);
};
const SustainabilitySection = ({
sustainability,
}: SustainabilitySectionProps) => {
if (!sustainability) return;
return (
<div className="flex flex-wrap gap-10 pt-1 pb-8">
{sustainability.map((item, index) => (
<div
key={`product-detail-7-sustainability-${index}`}
className="flex flex-col flex-wrap items-center gap-1"
>
<item.icon className="size-9 stroke-foreground stroke-1" />
<div className="text-xs font-light">{item.text}</div>
</div>
))}
</div>
);
};
const ProductInfoSections = ({ info }: ProductInfoSectionsProps) => {
if (!info) return;
return (
<Accordion type="multiple" className="w-full border-t">
{info.map((item, index) => (
<AccordionItem
value={`product-info-${index}`}
key={`product-detail-7-info-${index}`}
>
<AccordionTrigger className="group/trigger font-merriweather py-5 text-xl hover:no-underline [&>svg:last-of-type]:hidden">
{item.title}
<div className="relative size-5">
<Plus className="absolute inset-0 size-full stroke-1 transition-opacity duration-300 group-data-[state=open]/trigger:opacity-0" />
<Minus className="absolute inset-0 size-full stroke-1 opacity-0 transition-opacity duration-300 group-data-[state=open]/trigger:opacity-100" />
</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-base text-balance">
{item.content}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
};
export { ProductDetail7 };