593 lines
16 KiB
TypeScript
593 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import clsx from "clsx";
|
|
import { ArrowRight, Minus, Plus, ShoppingBag, X } from "lucide-react";
|
|
import { useMemo } from "react";
|
|
import type { ControllerRenderProps } from "react-hook-form";
|
|
import { Controller, useForm } from "react-hook-form";
|
|
import z from "zod";
|
|
|
|
import { Price } from "@/components/shadcnblocks/price";
|
|
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Carousel,
|
|
CarouselContent,
|
|
CarouselItem,
|
|
CarouselNext,
|
|
CarouselPrevious,
|
|
} from "@/components/ui/carousel";
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Separator } from "@/components/ui/separator";
|
|
|
|
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 ProductPrice {
|
|
regular: number;
|
|
sale?: number;
|
|
currency: string;
|
|
}
|
|
|
|
type Option = {
|
|
id: string;
|
|
value: string;
|
|
label: string;
|
|
thumbnail?: string;
|
|
stockStatusCode?: StockStatusCode;
|
|
};
|
|
|
|
interface Hinges {
|
|
label: string;
|
|
id: string;
|
|
min?: number;
|
|
max?: number;
|
|
name: FieldName;
|
|
options?: Option[];
|
|
}
|
|
|
|
type Product = {
|
|
images: Image[];
|
|
name: string;
|
|
link: string;
|
|
price: ProductPrice;
|
|
badges: string[];
|
|
hinges: Record<FieldName, Hinges>;
|
|
variant: {
|
|
color: string;
|
|
size: string;
|
|
};
|
|
};
|
|
|
|
type FormType = z.infer<typeof formSchema>;
|
|
type FieldName = keyof FormType;
|
|
|
|
interface ProductImagesProps {
|
|
images: Image[];
|
|
}
|
|
|
|
interface ProductFormProps {
|
|
hinges: Record<FieldName, Hinges>;
|
|
selected: FormType;
|
|
}
|
|
|
|
interface RadioGroupProps {
|
|
options?: Array<Option>;
|
|
field: ControllerRenderProps<FormType>;
|
|
}
|
|
|
|
type SizeOptionProps = Option;
|
|
|
|
interface QuantityProps {
|
|
field: ControllerRenderProps<FormType>;
|
|
max?: number;
|
|
min?: number;
|
|
}
|
|
|
|
const formatCurrency = (
|
|
value: number,
|
|
currency: string = "ریال",
|
|
locale: string = "fa-IR",
|
|
) => {
|
|
return `${value.toLocaleString(locale)} ${currency}`;
|
|
};
|
|
|
|
const PRODUCT_DETAILS: Product = {
|
|
name: "کلاه فدورا قهوهای روشن شیک",
|
|
link: "#",
|
|
images: [
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/accessories/Stylish-Hat-and-Sunglasses-2.png",
|
|
alt: "کلاه فدورا قهوهای با عینک آفتابی",
|
|
},
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/accessories/Stylish-Portrait-hat-2.png",
|
|
alt: "پرتره با کلاه فدورا شیک",
|
|
},
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/accessories/Stylish-Modern-Look-2.png",
|
|
alt: "استایل مدرن با کلاه فدورا",
|
|
},
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/accessories/Fashionable-Pose-2.png",
|
|
alt: "پوز مد با کلاه فدورا",
|
|
},
|
|
],
|
|
price: {
|
|
regular: 499000.0,
|
|
sale: 389000.0,
|
|
currency: "ریال",
|
|
},
|
|
badges: ["حراج", "جدید"],
|
|
variant: {
|
|
color: "light-brown",
|
|
size: "size-1",
|
|
},
|
|
hinges: {
|
|
color: {
|
|
label: "رنگ",
|
|
id: "color",
|
|
name: "color",
|
|
options: [
|
|
{
|
|
id: "light-brown",
|
|
value: "light-brown",
|
|
label: "قهوهای روشن",
|
|
thumbnail:
|
|
"/block-media/shadcnblocks/block/ecommerce/accessories/Stylish-Beige-Fedora-1.png",
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
{
|
|
id: "dark-brown",
|
|
value: "dark-brown",
|
|
label: "قهوهای تیره",
|
|
thumbnail:
|
|
"/block-media/shadcnblocks/block/ecommerce/accessories/Classic-Fedora-Hat-1.png",
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
{
|
|
id: "black",
|
|
value: "black",
|
|
label: "مشکی",
|
|
thumbnail:
|
|
"/block-media/shadcnblocks/block/ecommerce/accessories/Classic-Black-Fedora-Hat-1.png",
|
|
stockStatusCode: "OUT_OF_STOCK",
|
|
},
|
|
],
|
|
},
|
|
size: {
|
|
label: "سایز کلاه",
|
|
id: "size",
|
|
name: "size",
|
|
options: [
|
|
{
|
|
id: "size-1",
|
|
value: "size-1",
|
|
label: "6⅝",
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
{
|
|
id: "size-2",
|
|
value: "size-2",
|
|
label: "6¾",
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
{
|
|
id: "size-3",
|
|
value: "size-3",
|
|
label: "6⅞",
|
|
stockStatusCode: "OUT_OF_STOCK",
|
|
},
|
|
{
|
|
id: "size-4",
|
|
value: "size-4",
|
|
label: "7",
|
|
stockStatusCode: "OUT_OF_STOCK",
|
|
},
|
|
{
|
|
id: "size-5",
|
|
value: "size-5",
|
|
label: "7⅛",
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
{
|
|
id: "size-6",
|
|
value: "size-6",
|
|
label: "7¼",
|
|
stockStatusCode: "OUT_OF_STOCK",
|
|
},
|
|
{
|
|
id: "size-7",
|
|
value: "size-7",
|
|
label: "7⅜",
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
],
|
|
},
|
|
quantity: {
|
|
label: "تعداد",
|
|
id: "quantity",
|
|
name: "quantity",
|
|
min: 1,
|
|
max: 90,
|
|
},
|
|
},
|
|
};
|
|
|
|
const ProductQuickView7 = ({
|
|
images = PRODUCT_DETAILS.images,
|
|
name = PRODUCT_DETAILS.name,
|
|
link = PRODUCT_DETAILS.link,
|
|
price = PRODUCT_DETAILS.price,
|
|
badges = PRODUCT_DETAILS.badges,
|
|
hinges = PRODUCT_DETAILS.hinges,
|
|
variant = PRODUCT_DETAILS.variant,
|
|
}) => {
|
|
const { regular, sale, currency } = price;
|
|
|
|
return (
|
|
<Dialog defaultOpen>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className="h-[calc(100dvh-5rem)] max-h-195 max-w-[calc(100dvw-2rem)] rounded-xl p-0 sm:max-w-137.5"
|
|
>
|
|
<div className="absolute top-5 end-5 z-10">
|
|
<DialogClose asChild>
|
|
<Button size="icon-lg" className="rounded-full" variant="secondary">
|
|
<X />
|
|
</Button>
|
|
</DialogClose>
|
|
</div>
|
|
<div className="hide-scrollbar overflow-auto p-4">
|
|
<div className="space-y-3">
|
|
<div className="space-y-9">
|
|
<ProductImages images={images} />
|
|
<div className="space-y-2.5">
|
|
<DialogTitle className="text-xl font-medium">
|
|
<a href={link}>{name}</a>
|
|
</DialogTitle>
|
|
<div className="flex flex-wrap items-center gap-2.5">
|
|
<Price
|
|
onSale={sale != null}
|
|
className="items-end gap-x-1 text-xl font-medium"
|
|
>
|
|
{sale != null && (
|
|
<span className="leading-tight text-foreground">
|
|
{formatCurrency(sale, currency)}
|
|
</span>
|
|
)}
|
|
<span
|
|
className={clsx(
|
|
"leading-tight",
|
|
sale != null
|
|
? "text-base text-muted-foreground line-through"
|
|
: "text-xl text-foreground",
|
|
)}
|
|
>
|
|
{formatCurrency(regular, currency)}
|
|
</span>
|
|
</Price>
|
|
{badges?.map((text) => (
|
|
<Badge key={crypto.randomUUID()}>{text}</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ProductForm
|
|
hinges={hinges}
|
|
selected={{
|
|
quantity: 1,
|
|
color: variant.color,
|
|
size: variant.size,
|
|
}}
|
|
/>
|
|
<Button variant="ghost" asChild>
|
|
<a href={link}>
|
|
مشاهده جزئیات کامل
|
|
<ArrowRight className="ms-2 size-4 rtl:rotate-180" />
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
const ProductImages = ({ images }: ProductImagesProps) => {
|
|
if (!images) return;
|
|
|
|
return (
|
|
<Carousel
|
|
opts={{
|
|
align: "start",
|
|
slidesToScroll: 2,
|
|
}}
|
|
>
|
|
<CarouselContent className="-ms-5">
|
|
{images.map((img) => (
|
|
<CarouselItem className="basis-1/2 ps-5" key={crypto.randomUUID()}>
|
|
<AspectRatio ratio={0.83} className="overflow-hidden rounded-xl">
|
|
<img
|
|
src={img.src}
|
|
alt={img.alt}
|
|
className="block size-full object-cover object-center"
|
|
/>
|
|
</AspectRatio>
|
|
</CarouselItem>
|
|
))}
|
|
</CarouselContent>
|
|
<CarouselPrevious className="start-1.5" />
|
|
<CarouselNext className="end-1.5" />
|
|
</Carousel>
|
|
);
|
|
};
|
|
|
|
const formSchema = z.object({
|
|
color: z.string(),
|
|
size: z.string(),
|
|
quantity: z.number(),
|
|
});
|
|
|
|
const ProductForm = ({ hinges, selected }: ProductFormProps) => {
|
|
const form = useForm({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: {
|
|
color: selected.color,
|
|
size: selected.size,
|
|
quantity: selected.quantity,
|
|
},
|
|
});
|
|
|
|
function onSubmit(values: FormType) {
|
|
console.log(values);
|
|
}
|
|
|
|
const colorHinges = hinges?.color;
|
|
const sizeHinges = hinges?.size;
|
|
const quantityHinges = hinges?.quantity;
|
|
|
|
const selectedColorValue = form.watch("color");
|
|
const selectedSizeValue = form.watch("size");
|
|
|
|
const currentColor = useMemo(
|
|
() =>
|
|
colorHinges?.options?.find((item) => item.value === selectedColorValue)
|
|
?.label ?? "",
|
|
[selectedColorValue, colorHinges],
|
|
);
|
|
|
|
const currentSize = useMemo(
|
|
() =>
|
|
sizeHinges?.options?.find((item) => item.value === selectedSizeValue)
|
|
?.label ?? "",
|
|
[selectedSizeValue, sizeHinges],
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<Separator />
|
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
<div className="space-y-4">
|
|
{colorHinges && (
|
|
<Controller
|
|
control={form.control}
|
|
name={colorHinges.name}
|
|
render={({ field }) => (
|
|
<fieldset className="flex flex-col gap-2">
|
|
<legend className="mb-2 text-sm leading-normal font-medium">
|
|
{colorHinges.label}:{" "}
|
|
<span className="font-normal text-muted-foreground">
|
|
{currentColor}
|
|
</span>
|
|
</legend>
|
|
<ColorRadioGroup
|
|
field={field}
|
|
options={colorHinges.options}
|
|
/>
|
|
</fieldset>
|
|
)}
|
|
/>
|
|
)}
|
|
{sizeHinges && (
|
|
<Controller
|
|
control={form.control}
|
|
name={sizeHinges.name}
|
|
render={({ field }) => (
|
|
<fieldset className="flex flex-col gap-2">
|
|
<legend className="mb-2 text-sm leading-normal font-medium">
|
|
{sizeHinges.label}:{" "}
|
|
<span className="font-normal text-muted-foreground">
|
|
{currentSize}
|
|
</span>
|
|
</legend>
|
|
<SizeRadioGroup field={field} options={sizeHinges.options} />
|
|
</fieldset>
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
<Separator className="my-4" />
|
|
<div className="space-y-2.5">
|
|
<div className="flex flex-col gap-2 md:flex-row">
|
|
<Controller
|
|
control={form.control}
|
|
name={quantityHinges.name}
|
|
render={({ field }) => (
|
|
<Quantity
|
|
field={field}
|
|
min={quantityHinges?.min}
|
|
max={quantityHinges?.max}
|
|
/>
|
|
)}
|
|
/>
|
|
<Button className="w-full rounded-full md:flex-1">
|
|
افزودن به سبد
|
|
<ShoppingBag />
|
|
</Button>
|
|
</div>
|
|
<Button className="w-full rounded-full" variant="outline">
|
|
خرید فوری
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</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 flex-wrap items-center gap-3"
|
|
>
|
|
{options.map((item) => (
|
|
<label
|
|
key={item.id}
|
|
htmlFor={item.id}
|
|
className="relative flex size-10 cursor-pointer overflow-hidden rounded-md 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}
|
|
/>
|
|
<div
|
|
style={{
|
|
backgroundImage: `url(${item.thumbnail})`,
|
|
}}
|
|
className="size-full overflow-hidden rounded-sm bg-cover bg-center bg-no-repeat"
|
|
></div>
|
|
</label>
|
|
))}
|
|
</RadioGroup>
|
|
);
|
|
};
|
|
|
|
const SizeRadioGroup = ({ 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 justify-start gap-2"
|
|
>
|
|
{options &&
|
|
options.map((item) => (
|
|
<SizeOption
|
|
key={item.id}
|
|
stockStatusCode={item.stockStatusCode}
|
|
id={item.id}
|
|
label={item.label}
|
|
value={item.value}
|
|
/>
|
|
))}
|
|
</RadioGroup>
|
|
);
|
|
};
|
|
|
|
const SizeOption = ({ id, label, stockStatusCode, value }: SizeOptionProps) => {
|
|
return (
|
|
<label
|
|
htmlFor={id}
|
|
className="relative flex h-10 min-w-10 shrink-0 cursor-pointer items-center justify-center rounded-md border px-5 py-2.5 text-center text-sm leading-none uppercase not-has-disabled:hover:ring has-checked:bg-primary has-checked:text-primary-foreground has-disabled:cursor-not-allowed has-disabled:bg-muted has-disabled:text-muted-foreground has-disabled:line-through"
|
|
>
|
|
<RadioGroupItem
|
|
id={id}
|
|
className="absolute size-px overflow-hidden opacity-0"
|
|
value={value}
|
|
aria-label={`انتخاب ${label}`}
|
|
disabled={stockStatusCode === STOCK_STATUS.OUT_OF_STOCK}
|
|
/>
|
|
{label}
|
|
</label>
|
|
);
|
|
};
|
|
|
|
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 { ProductQuickView7 };
|