491 lines
13 KiB
TypeScript
491 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { Heart } 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 { Button } from "@/components/ui/button";
|
|
import {
|
|
Carousel,
|
|
CarouselContent,
|
|
CarouselItem,
|
|
CarouselNext,
|
|
CarouselPrevious,
|
|
} from "@/components/ui/carousel";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { FieldLegend, FieldSet } from "@/components/ui/field";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
|
|
const STOCK_STATUS = {
|
|
IN_STOCK: "IN_STOCK",
|
|
OUT_OF_STOCK: "OUT_OF_STOCK",
|
|
} as const;
|
|
|
|
type StockStatusCode = keyof typeof STOCK_STATUS;
|
|
|
|
type FormType = z.infer<typeof formSchema>;
|
|
type FieldName = keyof FormType;
|
|
|
|
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;
|
|
description: string;
|
|
link: string;
|
|
price: ProductPrice;
|
|
hinges: Record<FieldName, Hinges>;
|
|
variant: {
|
|
color: string;
|
|
size: string;
|
|
};
|
|
};
|
|
|
|
interface ProductImagesProps {
|
|
images: Image[];
|
|
}
|
|
|
|
interface ProductFormProps {
|
|
hinges: Record<FieldName, Hinges>;
|
|
selected?: FormType;
|
|
}
|
|
|
|
interface RadioGroupProps {
|
|
options?: Array<Option>;
|
|
field: ControllerRenderProps<FormType>;
|
|
}
|
|
|
|
type SizeOptionProps = Option;
|
|
|
|
const formatCurrency = (
|
|
value: number,
|
|
currency: string = "ریال",
|
|
locale: string = "fa-IR",
|
|
) => {
|
|
return `${value.toLocaleString(locale)} ${currency}`;
|
|
};
|
|
|
|
const PRODUCT_DETAILS: Product = {
|
|
name: "کلاه فدورا قهوهای روشن شیک",
|
|
description:
|
|
"طراحی شیک و سبک برای استایل روزمره؛ راحتی و ظرافت در یک کلاه فدورا.",
|
|
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: "ریال",
|
|
},
|
|
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",
|
|
},
|
|
{
|
|
id: "size-8",
|
|
value: "size-8",
|
|
label: "7½",
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
const ProductQuickView4 = ({
|
|
images = PRODUCT_DETAILS.images,
|
|
name = PRODUCT_DETAILS.name,
|
|
description = PRODUCT_DETAILS.description,
|
|
link = PRODUCT_DETAILS.link,
|
|
price = PRODUCT_DETAILS.price,
|
|
hinges = PRODUCT_DETAILS.hinges,
|
|
variant = PRODUCT_DETAILS.variant,
|
|
}) => {
|
|
const { regular, sale, currency } = price;
|
|
|
|
return (
|
|
<Dialog defaultOpen>
|
|
<DialogContent
|
|
style={
|
|
{
|
|
"--dialog-height": "calc(100dvh - 2.5rem)",
|
|
"--dialog-max-height": "38.75rem",
|
|
} as React.CSSProperties
|
|
}
|
|
className="block h-dvh w-full max-w-240! rounded-none border-none p-0 md:h-[var(--dialog-height)] md:max-h-[var(--dialog-max-height)]"
|
|
>
|
|
<div className="max-md:hide-scrollbar grid overflow-auto max-md:h-full md:grid-cols-2 md:overflow-hidden">
|
|
<div>
|
|
<ProductImages images={images} />
|
|
</div>
|
|
<div>
|
|
<div className="hide-scrollbar h-full space-y-5 px-8 py-8 md:h-[var(--dialog-height)] md:max-h-[var(--dialog-max-height)] md:overflow-auto md:px-15 md:py-14">
|
|
<div className="space-y-2">
|
|
<DialogTitle className="text-xl leading-normal font-semibold">
|
|
{name}
|
|
</DialogTitle>
|
|
<DialogDescription>{description}</DialogDescription>
|
|
</div>
|
|
<Price
|
|
onSale={sale != null}
|
|
className="items-end text-xl font-semibold"
|
|
>
|
|
{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>
|
|
<ProductForm
|
|
hinges={hinges}
|
|
selected={{
|
|
color: variant?.color,
|
|
size: variant?.size,
|
|
}}
|
|
/>
|
|
<Button variant="link" className="px-0" asChild>
|
|
<a href={link}>مشاهده جزئیات محصول</a>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
const ProductImages = ({ images }: ProductImagesProps) => {
|
|
if (!images) return;
|
|
|
|
return (
|
|
<Carousel className="h-full md:[&>div]:h-full">
|
|
<CarouselContent className="-ms-0 md:h-full">
|
|
{images.map((img, index) => (
|
|
<CarouselItem
|
|
className="w-full ps-0 md:h-[var(--dialog-height)] md:max-h-[var(--dialog-max-height)]"
|
|
key={index}
|
|
>
|
|
<div className="size-full overflow-hidden max-md:aspect-square">
|
|
<img
|
|
src={img.src}
|
|
alt={img.alt}
|
|
className="block size-full object-cover object-center"
|
|
/>
|
|
</div>
|
|
</CarouselItem>
|
|
))}
|
|
</CarouselContent>
|
|
<CarouselPrevious className="start-1.5" />
|
|
<CarouselNext className="end-1.5" />
|
|
</Carousel>
|
|
);
|
|
};
|
|
|
|
const formSchema = z.object({
|
|
color: z.string(),
|
|
size: z.string(),
|
|
});
|
|
|
|
const ProductForm = ({ hinges, selected }: ProductFormProps) => {
|
|
const form = useForm({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: {
|
|
color: selected?.color ?? "",
|
|
size: selected?.size ?? "",
|
|
},
|
|
});
|
|
|
|
function onSubmit(values: FormType) {
|
|
console.log(values);
|
|
}
|
|
|
|
const colorHinges = hinges?.color;
|
|
const sizeHinges = hinges?.size;
|
|
|
|
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 (
|
|
<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">
|
|
<h2>
|
|
{colorHinges.label}:{" "}
|
|
<span className="text-sm font-normal text-muted-foreground">
|
|
{currentColor}
|
|
</span>
|
|
</h2>
|
|
</FieldLegend>
|
|
<ColorRadioGroup field={field} options={colorHinges.options} />
|
|
</FieldSet>
|
|
)}
|
|
/>
|
|
)}
|
|
{sizeHinges && (
|
|
<Controller
|
|
control={form.control}
|
|
name={sizeHinges.name}
|
|
render={({ field }) => (
|
|
<FieldSet className="gap-2">
|
|
<FieldLegend className="text-sm leading-normal">
|
|
<h2>
|
|
{sizeHinges.label}:{" "}
|
|
<span className="text-sm font-normal text-muted-foreground">
|
|
{currentSize}
|
|
</span>
|
|
</h2>
|
|
</FieldLegend>
|
|
<SizeRadioGroup field={field} options={sizeHinges.options} />
|
|
</FieldSet>
|
|
)}
|
|
/>
|
|
)}
|
|
<div className="flex gap-3">
|
|
<Button size="lg">افزودن به سبد</Button>
|
|
<Button size="icon-lg" variant="outline">
|
|
<Heart />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
const ColorRadioGroup = ({ options, field }: RadioGroupProps) => {
|
|
if (!options) return;
|
|
|
|
return (
|
|
<RadioGroup
|
|
{...field}
|
|
value={field.value}
|
|
onValueChange={field.onChange}
|
|
className="flex flex-wrap items-center gap-3"
|
|
>
|
|
{options.map((item, index) => (
|
|
<label
|
|
key={index}
|
|
htmlFor={item.id}
|
|
className="relative size-10 shrink-0 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={field.onChange}
|
|
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>
|
|
);
|
|
};
|
|
|
|
export { ProductQuickView4 };
|