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

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 };