Files
reui-blocks/blocks/product-quick-view5.tsx
T

828 lines
25 KiB
TypeScript

"use client";
import "photoswipe/style.css";
import { zodResolver } from "@hookform/resolvers/zod";
import clsx from "clsx";
import type { LucideIcon } from "lucide-react";
import { Tag, Truck } from "lucide-react";
import PhotoSwipeLightbox from "photoswipe/lightbox";
import { useEffect, useId, useRef, useState } from "react";
import type { ControllerRenderProps } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import z from "zod";
import type { PriceType } from "@/components/shadcnblocks/price";
import { Price } from "@/components/shadcnblocks/price";
import QuantityInput from "@/components/shadcnblocks/quantity-input";
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 } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button";
import type { CarouselApi } from "@/components/ui/carousel";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { FieldLegend, FieldSet } from "@/components/ui/field";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { cn } from "@/lib/utils";
const STOCK_STATUS = {
IN_STOCK: {
code: "IN_STOCK",
message: (qt?: number) =>
qt
? `پیش‌سفارش امروز ${qt.toLocaleString("fa-IR")} عدد باقی مانده`
: "موجود، آماده ارسال",
color: "oklch(64.8% 0.2 131.684)",
},
LOW_STOCK: {
code: "LOW_STOCK",
message: "دارد سریع فروش می‌رود!",
color: "oklch(76.9% 0.188 70.08)",
},
OUT_OF_STOCK: {
code: "OUT_OF_STOCK",
message: "ناموجود",
color: "oklch(58.6% 0.253 17.585)",
},
} as const;
type StockStatusCode = keyof typeof STOCK_STATUS;
type Image = {
src: string;
alt: string;
thumbnail: string;
width: number;
height: number;
};
type SocialIcon = {
title: string;
light: string;
dark: string;
};
type SocialLink = {
link: string;
icon: SocialIcon;
};
type StockStatus = {
code: StockStatusCode;
quantity?: number;
};
interface ProductImagesProps {
images: Image[];
galleryID?: string;
badges?: string[];
}
interface StockStatusProps {
status?: StockStatus;
}
type ServiceListType = {
icon: LucideIcon;
text: string;
}[];
interface ServicesListProps {
list?: ServiceListType;
}
type FormType = z.infer<typeof formSchema>;
type FieldName = keyof FormType;
type Option = {
id: string;
value: string;
label: string;
thumbnail?: string;
color?: string;
stockStatusCode?: StockStatusCode;
};
interface Hinges {
label: string;
id: string;
min?: number;
max?: number;
name: FieldName;
options?: Option[];
}
interface FormProps {
hinges: Record<FieldName, Hinges>;
selected?: FormType;
}
interface RadioGroupProps {
options?: Array<Option>;
field: ControllerRenderProps<FormType>;
}
type AccordionItemType = {
title: string;
value: string;
content: {
text?: string;
list?: string[];
};
};
interface AccordionSectionProps {
section?: AccordionItemType[];
}
interface SocialMediaLinksProps {
links?: SocialLink[];
}
interface Product {
images: Image[];
galleryID?: string;
badges?: string[];
name: string;
price: PriceType;
stockStatus?: StockStatus;
services?: ServiceListType;
hinges: Record<FieldName, Hinges>;
link: string;
accordion?: AccordionItemType[];
socialLinks?: SocialLink[];
}
type ProductQuickView5Props = Product;
const formatCurrency = (
value: number,
currency: string = "ریال",
locale: string = "fa-IR",
) => {
return `${value.toLocaleString(locale)} ${currency}`;
};
const PRODUCT_DETAILS: Product = {
images: [
{
src: "/block-media/shadcnblocks/block/ecommerce/electronics/turntable/modern-audio-device.png",
thumbnail:
"/block-media/shadcnblocks/block/ecommerce/electronics/turntable/modern-audio-device.png",
alt: "دستگاه صوتی مدرن",
width: 992,
height: 1200,
},
{
src: "/block-media/shadcnblocks/block/ecommerce/electronics/turntable/modern-audio-panel.png",
thumbnail:
"/block-media/shadcnblocks/block/ecommerce/electronics/turntable/modern-audio-panel.png",
alt: "پنل صوتی مدرن",
width: 1200,
height: 800,
},
{
src: "/block-media/shadcnblocks/block/ecommerce/electronics/turntable/elegant-turntable-scene.png",
thumbnail:
"/block-media/shadcnblocks/block/ecommerce/electronics/turntable/elegant-turntable-scene.png",
alt: "صحنه گرامافون شیک",
width: 840,
height: 1200,
},
{
src: "/block-media/shadcnblocks/block/ecommerce/electronics/turntable/sleek-metallic-component.png",
thumbnail:
"/block-media/shadcnblocks/block/ecommerce/electronics/turntable/sleek-metallic-component.png",
alt: "قطعه فلزی براق",
width: 640,
height: 640,
},
{
src: "/block-media/shadcnblocks/block/ecommerce/electronics/turntable/turntable-stylus-close-up.png",
thumbnail:
"/block-media/shadcnblocks/block/ecommerce/electronics/turntable/turntable-stylus-close-up.png",
alt: "پنل کنترل مدرن",
width: 640,
height: 640,
},
],
badges: ["جدید"],
name: "گرامافون دقیق پرو",
price: {
regular: 1299000.0,
currency: "ریال",
},
stockStatus: {
code: "IN_STOCK",
quantity: 28,
},
services: [
{
icon: Truck,
text: "ارسال ویژه سفید شامل می‌شود",
},
{
icon: Tag,
text: "ارتقای رایگان کارتریج",
},
],
hinges: {
color: {
label: "رنگ",
id: "color",
name: "color",
options: [
{
id: "color-1",
value: "color-1",
label: "نقره‌ای",
color: "#C0C0C0",
stockStatusCode: "IN_STOCK",
},
{
id: "color-2",
value: "color-2",
label: "مشکی مات",
color: "#1a1a1a",
stockStatusCode: "IN_STOCK",
},
],
},
quantity: {
label: "تعداد",
id: "quantity",
name: "quantity",
min: 1,
max: 10,
},
},
link: "#",
accordion: [
{
value: "description",
title: "توضیحات",
content: {
text: "وینیل را همان‌گونه بشنوید که باید شنیده شود. گرامافون پرو با موتور دایرکت‌درایو دقیق، بازوی صوتی قابل تنظیم و شاسی ضدلرزش، بازتولید صدایی پاکیزه ارائه می‌دهد. چه شنونده حرفه‌ای باشید و چه تازه به دنیای صفحه برگشته‌اید، این دستگاه صدایی گرم و جزئی‌نما تحویل می‌دهد.",
},
},
{
value: "features",
title: "ویژگی‌ها",
content: {
list: [
"موتور دایرکت‌درایو با قفل کوارتز برای سرعت پایدار",
"بازوی صوتی S شکل با وزنه متقابل قابل تنظیم",
"پری‌آمپ فونو داخلی با امکان بای‌پس",
"پایه‌های ضدلرزش و صفحه سنگین برای کاهش رزونانس",
"خروجی USB برای دیجیتال‌سازی مجموعه وینیل شما",
],
},
},
{
value: "shipping",
title: "ارسال",
content: {
text: "ارسال ویژه سفید برای تمام سفارش‌های گرامافون رایگان است. دستگاه شما توسط تیم تحویل با دقت باز و نصب می‌شود. زمان تحویل تقریبی: ۷ تا ۱۰ روز کاری.",
},
},
],
socialLinks: [
{
icon: {
title: "Facebook",
light:
"/block-media/shadcnblocks/block/logos/facebook-icon.svg",
dark: "/block-media/shadcnblocks/block/logos/facebook-icon.svg",
},
link: "#",
},
{
icon: {
title: "X",
light:
"/block-media/shadcnblocks/block/logos/x.svg",
dark: "/block-media/shadcnblocks/block/logos/x.svg",
},
link: "#",
},
{
icon: {
title: "Instagram",
light:
"/block-media/shadcnblocks/block/logos/instagram-icon.svg",
dark: "/block-media/shadcnblocks/block/logos/instagram-icon.svg",
},
link: "#",
},
],
};
const ProductQuickView5 = ({
images = PRODUCT_DETAILS.images,
badges = PRODUCT_DETAILS.badges,
name = PRODUCT_DETAILS.name,
price = PRODUCT_DETAILS.price,
stockStatus = PRODUCT_DETAILS.stockStatus,
services = PRODUCT_DETAILS.services,
hinges = PRODUCT_DETAILS.hinges,
link = PRODUCT_DETAILS.link,
accordion = PRODUCT_DETAILS.accordion,
socialLinks = PRODUCT_DETAILS.socialLinks,
}: ProductQuickView5Props) => {
const { regular, sale, currency } = price;
return (
<Dialog open>
<DialogContent
style={
{
"--dialog-height": "calc(100dvh - 2.5rem)",
"--dialog-max-height": "45rem",
} as React.CSSProperties
}
className="h-[var(--dialog-height)] w-dvw max-w-300! border-none p-0 max-md:top-auto max-md:bottom-0 max-md:start-0 max-md:h-[calc(100dvh-100px)] max-md:translate-0! max-md:overflow-auto md:max-h-[var(--dialog-max-height)] md:w-[calc(100dvw-4rem)]"
>
<div className="grid grid-cols-1 gap-5 max-md:p-10 max-sm:px-5 md:h-[var(--dialog-height)] md:max-h-[var(--dialog-max-height)] md:grid-cols-2">
<div className="overflow-hidden md:py-10 md:ps-10">
<ProductImages badges={badges} images={images} />
</div>
<div className="hide-scrollbar md:h-[var(--dialog-height)] md:max-h-[var(--dialog-max-height)] md:overflow-auto md:py-10 md:pe-10">
<div className="space-y-6">
<div className="space-y-1">
<DialogTitle className="text-3xl leading-tight font-light tracking-tight">
{name}
</DialogTitle>
<Price
onSale={sale != null}
className="items-end text-xl leading-relaxed"
>
{sale != null && (
<span className="leading-tight text-foreground">
{formatCurrency(sale, currency)}
</span>
)}
<span
className={
sale != null
? "leading-tight text-muted-foreground line-through"
: "leading-tight text-foreground"
}
>
{formatCurrency(regular, currency)}
</span>
</Price>
</div>
<div className="space-y-2 border-y py-4">
<StockStatus status={stockStatus} />
<ServicesList list={services} />
</div>
<div className="mt-7.5 space-y-3">
<Form hinges={hinges} />
<Button
asChild
variant="link"
className="px-0 underline underline-offset-4"
>
<a href={link}>مشاهده جزئیات</a>
</Button>
<AccordionSection section={accordion} />
</div>
<div className="mt-10">
<SocialMediaLinks links={socialLinks} />
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
const ProductImages = ({ images, galleryID, badges }: ProductImagesProps) => {
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0);
const lightboxRef = useRef<PhotoSwipeLightbox | null>(null);
const generatedGalleryID = useId();
const PhotoSwipeID = galleryID?.trim() ?? generatedGalleryID;
useEffect(() => {
const lightbox = new PhotoSwipeLightbox({
gallery: "#" + PhotoSwipeID,
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();
}, [PhotoSwipeID]);
useEffect(() => {
if (!api) return;
const updateCurrent = (api: CarouselApi) =>
setCurrent(api?.selectedScrollSnap() ?? 0);
updateCurrent(api);
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]);
const handleThumbnailClick = (index: number) => {
api?.scrollTo(index);
};
if (!images) return;
return (
<div className="space-y-4" id={PhotoSwipeID}>
<div className="relative overflow-hidden">
<Carousel setApi={setApi} className="group">
<CarouselContent className="ms-0">
{images.map((img, index) => (
<CarouselItem className="ps-0" key={index}>
<AspectRatio ratio={1} className="overflow-hidden rounded-sm">
<a
href={img.src}
data-pswp-width={img.width}
data-pswp-height={img.height}
target="_blank"
rel="noreferrer"
data-cropped="true"
>
<img
src={img.src}
alt={img.alt}
className="block size-full object-cover object-center"
/>
</a>
</AspectRatio>
</CarouselItem>
))}
</CarouselContent>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<CarouselPrevious className="start-6 -translate-x-5 group-hover:translate-x-0" />
<CarouselNext className="end-6 translate-x-5 group-hover:translate-x-0" />
</div>
</Carousel>
{badges && (
<div className="absolute inset-x-4 top-4">
<div className="flex items-center gap-3">
{badges?.map((item, index) => (
<Badge key={index}>{item}</Badge>
))}
</div>
</div>
)}
</div>
<div
className={clsx(
"relative max-md:hidden",
"after:pointer-events-none after:absolute after:inset-y-0 after:-end-px after:z-10 after:block after:h-full after:w-7.5 after:bg-linear-to-s after:from-background after:to-transparent",
"before:pointer-events-none before:absolute before:inset-y-0 before:-start-px before:z-10 before:block before:h-full before:w-7.5 before:bg-linear-to-e before:from-background before:to-transparent",
)}
>
<div className="hide-scrollbar w-full overflow-auto px-7.5">
<div className="flex items-center gap-4 py-0.5">
{images.map(({ thumbnail, alt }, index) => (
<button
data-state={index === current ? "active" : "inactive"}
className="size-18 shrink-0 basis-18 overflow-hidden rounded-xs border-border data-[state=active]:ring-2"
key={index}
onClick={() => handleThumbnailClick(index)}
>
<img
src={thumbnail}
alt={alt}
className="block size-full object-cover object-center"
/>
</button>
))}
</div>
</div>
</div>
</div>
);
};
const StockStatus = ({ status }: StockStatusProps) => {
if (!status) return;
return (
<div className="flex items-center gap-2.5">
<div className="shrink-0 basis-3">
<div
style={{
backgroundColor: STOCK_STATUS[status.code].color,
}}
className="size-3 rounded-full"
></div>
</div>
<p className="flex-1 leading-normal">
{status.code === STOCK_STATUS.IN_STOCK.code
? STOCK_STATUS.IN_STOCK.message(status?.quantity)
: STOCK_STATUS[status.code].message}
</p>
</div>
);
};
const ServicesList = ({ list }: ServicesListProps) => {
if (!list) return;
return (
<ul className="space-y-2">
{list.map((item, index) => (
<li className="flex items-center gap-2.5" key={index}>
<item.icon className="size-4 shrink-0" />
<p className="flex-1 leading-normal">{item.text}</p>
</li>
))}
</ul>
);
};
const formSchema = z.object({
color: z.string(),
quantity: z.number(),
});
const Form = ({ hinges, selected }: FormProps) => {
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
color: selected?.color ?? "",
quantity: selected?.quantity ?? 1,
},
});
const onSubmit = (data: FormType) => {
console.log(data);
};
const colorHinges = hinges?.color;
const quantityHinges = hinges?.quantity;
return (
<div>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="space-y-5">
{colorHinges && (
<Controller
control={form.control}
name={colorHinges.name}
render={({ field }) => (
<FieldSet className="gap-2">
<FieldLegend className="text-sm leading-normal">
{colorHinges.label}
</FieldLegend>
<ColorRadioGroup
field={field}
options={colorHinges.options}
/>
</FieldSet>
)}
/>
)}
{quantityHinges && (
<Controller
control={form.control}
name={quantityHinges.name}
render={({ field }) => (
<FieldSet>
<FieldLegend className="text-sm leading-normal">
{quantityHinges.label}
</FieldLegend>
<QuantityInput
onValueChange={field.onChange}
inputProps={{ ...field }}
className="max-w-30 rounded-sm"
/>
</FieldSet>
)}
/>
)}
</div>
<div className="mt-8 space-y-2.5">
<Button size="lg" className="w-full uppercase">
افزودن به سبد
</Button>
<Button size="lg" variant="outline" className="w-full uppercase">
خرید فوری
</Button>
</div>
</form>
</div>
);
};
const ColorRadioGroup = ({ options, field }: RadioGroupProps) => {
if (!options) return;
return (
<RadioGroup
{...field}
value={field.value as string}
onValueChange={field.onChange}
className="flex flex-wrap items-center gap-2"
>
{options.map((item, index) => (
<label
key={index}
htmlFor={item.id}
className="relative size-7 shrink-0 cursor-pointer overflow-hidden rounded-full border p-0.5 duration-400 has-checked:ring has-disabled:opacity-60"
>
<RadioGroupItem
id={item.id}
className="absolute size-px overflow-hidden opacity-0"
value={item.value}
aria-label={`انتخاب ${item.label}`}
disabled={item.stockStatusCode === STOCK_STATUS.OUT_OF_STOCK.code}
/>
<div
style={{
backgroundColor: item.color,
}}
className="size-full rounded-full"
></div>
</label>
))}
</RadioGroup>
);
};
const AccordionSection = ({ section }: AccordionSectionProps) => {
if (!section) return;
return (
<Accordion type="multiple" className="w-full border-y border-border">
{section.map(({ value, title, content }, index) => (
<AccordionItem value={value} key={index}>
<AccordionTrigger className="font-normal uppercase hover:no-underline">
{title}
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
{content.text && (
<p className="leading-relaxed">{content.text}</p>
)}
{content.list && (
<ul className="space-y-1">
{content.list.map((text, index) => (
<li className="leading-relaxed" key={`list-${index}`}>
{text}
</li>
))}
</ul>
)}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
};
const SocialMediaLinks = ({ links }: SocialMediaLinksProps) => {
if (!links) return;
return (
<div className="space-y-3">
<p className="uppercase">اشتراک‌گذاری</p>
<ul className="flex flex-wrap gap-1">
{links.map(({ icon, link }) => (
<li key={crypto.randomUUID()}>
<Button size="icon-lg" asChild variant="ghost">
<a href={link}>
<img
className="size-5 dark:invert"
alt={icon.title}
src={icon.light}
/>
</a>
</Button>
</li>
))}
</ul>
</div>
);
};
export { ProductQuickView5 };