409 lines
12 KiB
TypeScript
409 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { GitCompareArrows, Heart, Minus, Plus, X } from "lucide-react";
|
|
import type { ControllerRenderProps } from "react-hook-form";
|
|
import { useForm } from "react-hook-form";
|
|
import { Controller } from "react-hook-form";
|
|
import z from "zod";
|
|
|
|
import { Price } from "@/components/shadcnblocks/price";
|
|
import { Rating } from "@/components/shadcnblocks/rating";
|
|
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Carousel,
|
|
CarouselContent,
|
|
CarouselItem,
|
|
} from "@/components/ui/carousel";
|
|
import { Field, FieldLabel } from "@/components/ui/field";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import {
|
|
Sheet,
|
|
SheetClose,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet";
|
|
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
|
|
|
const STOCK_STATUS = {
|
|
IN_STOCK: "IN_STOCK",
|
|
OUT_OF_STOCK: "OUT_OF_STOCK",
|
|
} as const;
|
|
|
|
type StockStatusCode = keyof typeof STOCK_STATUS;
|
|
|
|
type Image = {
|
|
src: string;
|
|
alt: string;
|
|
};
|
|
|
|
interface Reviews {
|
|
rate: number;
|
|
totalReviewers: string;
|
|
link: string;
|
|
}
|
|
|
|
interface ProductPrice {
|
|
regular: number;
|
|
sale?: number;
|
|
currency: string;
|
|
}
|
|
|
|
type Product = {
|
|
images: Image[];
|
|
name: string;
|
|
reviews: Reviews;
|
|
price: ProductPrice;
|
|
stockStatusCode: StockStatusCode;
|
|
description: string;
|
|
hinges: Record<FieldName, Hinges>;
|
|
categories: {
|
|
text: string;
|
|
link: string;
|
|
}[];
|
|
};
|
|
|
|
type ProductQuickView6Props = Product;
|
|
|
|
interface ProductImagesProps {
|
|
images: Image[];
|
|
}
|
|
|
|
type ProductFormType = z.infer<typeof productForm>;
|
|
type FieldName = keyof ProductFormType;
|
|
|
|
interface QuantityProps {
|
|
field: ControllerRenderProps<ProductFormType>;
|
|
max?: number;
|
|
min?: number;
|
|
}
|
|
|
|
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/furniture/pexels-karolina-grabowska-7193566-3.jpg",
|
|
alt: "صندلی ناهارخوری از نمای جلو",
|
|
},
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/furniture/pexels-karolina-grabowska-7193654-3.jpg",
|
|
alt: "صندلی ناهارخوری از نمای جانبی",
|
|
},
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/furniture/pexels-karolina-grabowska-7193627-3.jpg",
|
|
alt: "جزئیات بافت صندلی ناهارخوری",
|
|
},
|
|
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/furniture/pexels-karolina-grabowska-7193650-3.jpg",
|
|
alt: "صندلی در فضای ناهارخوری",
|
|
},
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/furniture/pexels-karolina-grabowska-7193649-3.jpg",
|
|
alt: "صندلی ناهارخوری در دکور داخلی",
|
|
},
|
|
],
|
|
name: "صندلی ناهارخوری آلو",
|
|
reviews: {
|
|
rate: 4.5,
|
|
totalReviewers: "۵٫۸ هزار نظر",
|
|
link: "#",
|
|
},
|
|
price: {
|
|
regular: 499000.0,
|
|
sale: 389000.0,
|
|
currency: "ریال",
|
|
},
|
|
stockStatusCode: "IN_STOCK",
|
|
description:
|
|
"طراحی مدرن و راحت برای میز ناهارخوری؛ ترکیبی از استحکام، زیبایی و راحتی در استفاده روزمره.",
|
|
hinges: {
|
|
quantity: {
|
|
id: "quantity",
|
|
name: "quantity",
|
|
label: "تعداد",
|
|
min: 1,
|
|
max: 99,
|
|
},
|
|
},
|
|
categories: [
|
|
{
|
|
text: "مبلمان",
|
|
link: "#",
|
|
},
|
|
{
|
|
text: "صندلی",
|
|
link: "#",
|
|
},
|
|
],
|
|
};
|
|
|
|
const ProductQuickView6 = ({
|
|
images = PRODUCT_DETAILS.images,
|
|
name = PRODUCT_DETAILS.name,
|
|
price = PRODUCT_DETAILS.price,
|
|
reviews = PRODUCT_DETAILS.reviews,
|
|
stockStatusCode = PRODUCT_DETAILS.stockStatusCode,
|
|
description = PRODUCT_DETAILS.description,
|
|
hinges = PRODUCT_DETAILS.hinges,
|
|
categories = PRODUCT_DETAILS.categories,
|
|
}: ProductQuickView6Props) => {
|
|
const { regular, sale, currency } = price;
|
|
|
|
return (
|
|
<Sheet defaultOpen>
|
|
<SheetContent className="w-full max-md:!max-w-[calc(100dvw-3rem)] sm:!max-w-200 md:!max-w-200 lg:!max-w-240 [&>button]:hidden">
|
|
<div className="absolute end-2 top-2 z-10">
|
|
<SheetClose asChild>
|
|
<Button size="icon-lg" className="rounded-full" variant="secondary">
|
|
<X />
|
|
</Button>
|
|
</SheetClose>
|
|
</div>
|
|
<div className="h-full p-4 max-md:overflow-auto md:p-6">
|
|
<div className="flex h-full gap-6 max-md:flex-col lg:gap-10">
|
|
<div className="h-full basis-5/12">
|
|
<ProductImages images={images} />
|
|
</div>
|
|
<div className="h-full basis-7/12 pb-10">
|
|
<div className="hide-scrollbar h-full overflow-auto md:mt-2">
|
|
<SheetTitle className="text-xl leading-tight md:text-xl lg:text-xl">
|
|
{name}
|
|
</SheetTitle>
|
|
<div className="mt-2.5 flex flex-wrap items-center">
|
|
<Rating
|
|
className="me-1 gap-0.5 [&_svg]:size-3.5 [&>div]:size-3.5"
|
|
rate={reviews.rate}
|
|
/>
|
|
<a
|
|
href={reviews.link}
|
|
className="text-sm text-muted-foreground"
|
|
>
|
|
({reviews.totalReviewers})
|
|
</a>
|
|
</div>
|
|
<Price
|
|
onSale={sale != null}
|
|
className="mt-5 text-lg font-medium lg:text-xl"
|
|
>
|
|
<span
|
|
className={
|
|
sale != null
|
|
? "leading-tight text-muted-foreground line-through"
|
|
: "leading-tight text-foreground"
|
|
}
|
|
>
|
|
{formatCurrency(regular, currency)}
|
|
</span>
|
|
{sale != null && (
|
|
<span className="leading-tight text-foreground">
|
|
{formatCurrency(sale, currency)}
|
|
</span>
|
|
)}
|
|
</Price>
|
|
<Badge className="my-3 uppercase">
|
|
{stockStatusCode === STOCK_STATUS.IN_STOCK
|
|
? "موجود"
|
|
: "ناموجود"}
|
|
</Badge>
|
|
<SheetDescription className="text-base leading-relaxed">
|
|
{description}
|
|
</SheetDescription>
|
|
<Separator className="my-4" />
|
|
<div className="flex flex-col gap-5">
|
|
<ProductForm
|
|
selected={{
|
|
quantity: 1,
|
|
}}
|
|
hinges={hinges}
|
|
/>
|
|
<div>
|
|
<Separator className="my-4" />
|
|
<p className="text-sm">
|
|
دستهها:{" "}
|
|
{categories.map((item) => (
|
|
<a
|
|
href={item.link}
|
|
key={crypto.randomUUID()}
|
|
className="mx-0.5 underline-offset-3 hover:underline"
|
|
>
|
|
{item.text}
|
|
</a>
|
|
))}
|
|
</p>
|
|
<Separator className="my-4" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
};
|
|
|
|
const ProductImages = ({ images }: ProductImagesProps) => {
|
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
|
|
if (!images) return;
|
|
|
|
return (
|
|
<Carousel
|
|
opts={{
|
|
align: "start",
|
|
}}
|
|
orientation={isDesktop ? "vertical" : "horizontal"}
|
|
className="md:h-[calc(100dvh-3rem)] md:[&>div]:h-full"
|
|
>
|
|
<CarouselContent className="max-md:-ms-3 md:-mt-6 md:h-full">
|
|
{images.map((img) => (
|
|
<CarouselItem
|
|
className="basis-50 max-md:ps-3 md:basis-100 md:pt-6"
|
|
key={crypto.randomUUID()}
|
|
>
|
|
<AspectRatio ratio={0.75} className="overflow-hidden rounded-xl">
|
|
<img
|
|
src={img.src}
|
|
alt={img.alt}
|
|
className="block size-full object-cover object-center"
|
|
/>
|
|
</AspectRatio>
|
|
</CarouselItem>
|
|
))}
|
|
</CarouselContent>
|
|
</Carousel>
|
|
);
|
|
};
|
|
|
|
interface Hinges {
|
|
label: string;
|
|
id: string;
|
|
name: FieldName;
|
|
min?: number;
|
|
max?: number;
|
|
}
|
|
|
|
interface ProductFormProps {
|
|
hinges: Record<FieldName, Hinges>;
|
|
selected?: ProductFormType;
|
|
}
|
|
|
|
const productForm = z.object({
|
|
quantity: z.number(),
|
|
});
|
|
|
|
const ProductForm = ({ hinges, selected }: ProductFormProps) => {
|
|
const form = useForm({
|
|
resolver: zodResolver(productForm),
|
|
defaultValues: {
|
|
quantity: selected?.quantity ?? 1,
|
|
},
|
|
});
|
|
|
|
function onSubmit(values: ProductFormType) {
|
|
console.log(values);
|
|
}
|
|
|
|
const quantityHinges = hinges?.quantity;
|
|
|
|
return (
|
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
<div className="flex flex-col gap-5">
|
|
<Controller
|
|
control={form.control}
|
|
name={quantityHinges.name}
|
|
render={({ field }) => (
|
|
<Field className="gap-3">
|
|
<FieldLabel>{quantityHinges.label}</FieldLabel>
|
|
<Quantity
|
|
field={field}
|
|
min={quantityHinges?.min}
|
|
max={quantityHinges?.max}
|
|
/>
|
|
</Field>
|
|
)}
|
|
/>
|
|
<div className="space-y-3">
|
|
<div className="flex gap-3">
|
|
<Button className="flex-1 rounded-full">افزودن به سبد</Button>
|
|
<Button size="icon" variant="outline" className="rounded-full">
|
|
<Heart />
|
|
</Button>
|
|
<Button size="icon" variant="outline" className="rounded-full">
|
|
<GitCompareArrows />
|
|
</Button>
|
|
</div>
|
|
<Button variant="secondary" className="w-full rounded-full">
|
|
خرید فوری
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
const Quantity = ({ field, max, min }: QuantityProps) => {
|
|
return (
|
|
<div
|
|
className="flex h-9 w-full max-w-40 min-w-28 items-center overflow-hidden rounded-full border shadow-xs"
|
|
aria-label="تعداد"
|
|
>
|
|
<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 />
|
|
</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"
|
|
min={min ? min : 1}
|
|
max={max ? max : 99}
|
|
className="w-full min-w-10 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 />
|
|
</Button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export { ProductQuickView6 };
|