451 lines
13 KiB
TypeScript
451 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { ArrowRight, Plus, X } from "lucide-react";
|
|
import { Fragment, useState } from "react";
|
|
import type { UseFormReturn } from "react-hook-form";
|
|
import { useForm } from "react-hook-form";
|
|
import { Controller } from "react-hook-form";
|
|
import z from "zod";
|
|
|
|
import { Price, PriceValue } from "@/components/shadcnblocks/price";
|
|
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import {
|
|
Carousel,
|
|
CarouselContent,
|
|
CarouselItem,
|
|
CarouselNext,
|
|
CarouselPrevious,
|
|
} from "@/components/ui/carousel";
|
|
import { Field, FieldLabel } from "@/components/ui/field";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
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 FormFieldName = keyof FormType;
|
|
|
|
type Image = {
|
|
src: string;
|
|
alt: string;
|
|
};
|
|
|
|
interface ProductPrice {
|
|
regular: number;
|
|
sale?: number;
|
|
currency: string;
|
|
}
|
|
|
|
interface Product {
|
|
name: string;
|
|
images: Image[];
|
|
link: string;
|
|
hinges: Record<FormFieldName, Hinge>;
|
|
variantId: string;
|
|
price: ProductPrice;
|
|
}
|
|
|
|
type Option = {
|
|
id: string;
|
|
value: string;
|
|
colors?: string[];
|
|
label?: string;
|
|
images?: Image[];
|
|
stockStatusCode?: StockStatusCode;
|
|
};
|
|
|
|
type Hinge = {
|
|
label?: string;
|
|
name: FormFieldName;
|
|
id: string;
|
|
options: Array<Option>;
|
|
};
|
|
|
|
interface RadioGroupProps {
|
|
form: UseFormReturn<FormType>;
|
|
hinges: Hinge;
|
|
}
|
|
|
|
const PRODUCT_CARD: Product = {
|
|
name: "هودی اسلیت اسنشال",
|
|
images: [
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Young-Man-in-Navy-and-Orange-Hoodie-2.png",
|
|
alt: "هودی اسلیت اسنشال",
|
|
},
|
|
],
|
|
link: "#",
|
|
price: {
|
|
regular: 490000.0,
|
|
sale: 380000.0,
|
|
currency: "ریال",
|
|
},
|
|
variantId: "product-1-3",
|
|
hinges: {
|
|
size: {
|
|
id: "size",
|
|
name: "size",
|
|
label: "افزودن سریع",
|
|
options: [
|
|
{
|
|
value: "xs",
|
|
id: "xs",
|
|
label: "XS",
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
{
|
|
value: "s",
|
|
id: "s",
|
|
label: "S",
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
{
|
|
value: "m",
|
|
id: "m",
|
|
label: "M",
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
{
|
|
value: "l",
|
|
id: "l",
|
|
label: "L",
|
|
stockStatusCode: "IN_STOCK",
|
|
},
|
|
{
|
|
value: "xl",
|
|
id: "xl",
|
|
label: "XL",
|
|
stockStatusCode: "OUT_OF_STOCK",
|
|
},
|
|
{
|
|
value: "2xl",
|
|
id: "2xl",
|
|
label: "2XL",
|
|
stockStatusCode: "OUT_OF_STOCK",
|
|
},
|
|
],
|
|
},
|
|
color: {
|
|
id: "color",
|
|
name: "color",
|
|
options: [
|
|
{
|
|
images: [
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Young-Man-in-Casual-Outfit-2.png",
|
|
alt: "هودی اسلیت اسنشال",
|
|
},
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Person-in-Green-Hoodie-2.png",
|
|
alt: "هودی اسلیت اسنشال",
|
|
},
|
|
],
|
|
id: "product-1-2",
|
|
value: "product-1-2",
|
|
label: "زیتونی",
|
|
colors: ["#52462C"],
|
|
},
|
|
{
|
|
images: [
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Young-Man-in-Navy-and-Orange-Hoodie-2.png",
|
|
alt: "هودی اسلیت اسنشال",
|
|
},
|
|
],
|
|
id: "product-1-3",
|
|
value: "product-1-3",
|
|
label: "سرمهای و نارنجی",
|
|
colors: ["#2B233A", "#F6862E"],
|
|
},
|
|
{
|
|
images: [
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Young-Man-in-Blue-Hoodie-2.png",
|
|
alt: "هودی اسلیت اسنشال",
|
|
},
|
|
],
|
|
id: "product-2-1",
|
|
value: "product-2-1",
|
|
label: "آبی",
|
|
colors: ["#233A4E", "#879698"],
|
|
},
|
|
{
|
|
images: [
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Young-Man-in-Beige-Hoodie-2.png",
|
|
alt: "هودی اسلیت اسنشال",
|
|
},
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Close-up-of-Beige-Hoodie-2.png",
|
|
alt: "هودی اسلیت اسنشال",
|
|
},
|
|
],
|
|
id: "product-2-2",
|
|
value: "product-2-2",
|
|
label: "بژ",
|
|
colors: ["#BDA994"],
|
|
},
|
|
{
|
|
images: [
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Young-Man-in-Maroon-Hoodie-2.png",
|
|
alt: "هودی اسلیت اسنشال",
|
|
},
|
|
],
|
|
id: "product-3-1",
|
|
value: "product-3-1",
|
|
label: "شرابی",
|
|
colors: ["#54272E"],
|
|
},
|
|
{
|
|
images: [
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Young-Person-in-Bright-Fashion-2.png",
|
|
alt: "هودی اسلیت اسنشال",
|
|
},
|
|
],
|
|
id: "product-3-2",
|
|
value: "product-3-2",
|
|
label: "نارنجی سوخته",
|
|
colors: ["#C75922"],
|
|
},
|
|
{
|
|
images: [
|
|
{
|
|
src: "/block-media/shadcnblocks/block/ecommerce/clothes/Young-Man-in-Green-Hoodie-2.png",
|
|
alt: "هودی اسلیت اسنشال",
|
|
},
|
|
],
|
|
id: "product-3-3",
|
|
value: "product-3-3",
|
|
label: "سبز لیمویی",
|
|
colors: ["#91A12E"],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
const formSchema = z.object({
|
|
size: z.string(),
|
|
color: z.string(),
|
|
});
|
|
|
|
interface ProductCard24Props {
|
|
className?: string;
|
|
}
|
|
|
|
const ProductCard24 = ({ className }: ProductCard24Props) => {
|
|
const [open, setOpen] = useState(false);
|
|
const form = useForm<FormType>({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: {
|
|
size: "",
|
|
color: PRODUCT_CARD.variantId ?? "",
|
|
},
|
|
});
|
|
|
|
const selectedColor = form.watch("color");
|
|
const images =
|
|
PRODUCT_CARD.hinges.color.options.find(
|
|
(variant) => variant.value === selectedColor,
|
|
)?.images ?? PRODUCT_CARD.images;
|
|
const { regular, sale, currency } = PRODUCT_CARD.price;
|
|
|
|
return (
|
|
<div className={cn("flex w-full justify-center", className)}>
|
|
<Card className="group w-full max-w-96 shrink-0 overflow-hidden rounded-none border-0 p-0 shadow-none ring ring-muted">
|
|
<CardContent className="p-0">
|
|
<form>
|
|
<div className="relative w-full">
|
|
<a href={PRODUCT_CARD.link}>
|
|
<Carousel
|
|
opts={{
|
|
loop: true,
|
|
}}
|
|
>
|
|
<CarouselContent className="ml-0">
|
|
{images.map((image, index) => (
|
|
<CarouselItem className="pl-0" key={`${index}`}>
|
|
<AspectRatio
|
|
ratio={1}
|
|
className="flex items-center justify-center overflow-hidden bg-muted/20"
|
|
>
|
|
<img
|
|
src={image.src}
|
|
alt={image.alt}
|
|
className="size-full object-contain object-center"
|
|
/>
|
|
</AspectRatio>
|
|
</CarouselItem>
|
|
))}
|
|
</CarouselContent>
|
|
{images.length > 1 && (
|
|
<Fragment>
|
|
<CarouselPrevious type="button" className="left-4" />
|
|
<CarouselNext type="button" className="right-4" />
|
|
</Fragment>
|
|
)}
|
|
</Carousel>
|
|
</a>
|
|
<div className="absolute inset-x-0 bottom-0">
|
|
<div
|
|
className={cn(
|
|
"bg-card py-3 ring ring-muted transition-opacity duration-350 md:opacity-0 md:group-hover:opacity-100",
|
|
open && "opacity-100",
|
|
!open && "opacity-0",
|
|
)}
|
|
>
|
|
<div className="flex w-full items-center justify-between gap-4 overflow-x-auto pr-1.5 pl-4 [-ms-overflow-style:none] [scrollbar-width:none] [::-webkit-scrollbar]:hidden">
|
|
<SizeRadioGroup
|
|
hinges={PRODUCT_CARD.hinges.size}
|
|
form={form}
|
|
/>
|
|
<div className="shrink-0">
|
|
<Button size="sm" variant="ghost">
|
|
مشاهده
|
|
<ArrowRight />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<CardHeader className="relative min-h-56 gap-4 space-y-4 px-6 pb-10 pt-8 lg:px-8 lg:pb-10">
|
|
<CardTitle className="text-xl leading-tight font-medium">
|
|
{PRODUCT_CARD.name}
|
|
</CardTitle>
|
|
<div className="flex min-h-10 w-[calc(100%-2rem)] items-center gap-6">
|
|
<div className="shrink-0">
|
|
<Price className="text-base" onSale={sale != null}>
|
|
<PriceValue price={sale} currency={currency} variant="sale" />
|
|
<PriceValue
|
|
price={regular}
|
|
currency={currency}
|
|
variant="regular"
|
|
/>
|
|
</Price>
|
|
</div>
|
|
<div className="relative">
|
|
<div
|
|
className={cn(
|
|
"bg-card transition-opacity duration-350 md:opacity-0 md:group-hover:opacity-100",
|
|
open && "opacity-100",
|
|
!open && "opacity-0",
|
|
)}
|
|
>
|
|
<ColorRadioGroup
|
|
hinges={PRODUCT_CARD.hinges.color}
|
|
form={form}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="absolute right-0 bottom-0 md:hidden">
|
|
<Button
|
|
variant="ghost"
|
|
className="rounded-none bg-background hover:bg-background"
|
|
size="icon"
|
|
type="button"
|
|
onClick={() => setOpen(!open)}
|
|
>
|
|
{open ? <X /> : <Plus />}
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const SizeRadioGroup = ({ form, hinges }: RadioGroupProps) => {
|
|
return (
|
|
<Controller
|
|
control={form.control}
|
|
name={hinges.name}
|
|
render={({ field }) => (
|
|
<Field className="flex min-h-10 shrink-0 items-center gap-1">
|
|
<FieldLabel className="shrink-0 text-sm">{hinges.label}:</FieldLabel>
|
|
<RadioGroup
|
|
onValueChange={field.onChange}
|
|
value={field.value}
|
|
className="w-auto grid-flow-col items-center gap-3 text-sm"
|
|
>
|
|
{hinges.options.map((option) => (
|
|
<Field key={option.id}>
|
|
<RadioGroupItem
|
|
id={option.id}
|
|
className="peer sr-only"
|
|
value={option.value}
|
|
disabled={
|
|
option.stockStatusCode === STOCK_STATUS.OUT_OF_STOCK
|
|
}
|
|
/>
|
|
<FieldLabel
|
|
htmlFor={option.id}
|
|
className="cursor-pointer font-normal underline-offset-3 peer-has-data-[state=checked]:underline"
|
|
>
|
|
{option.label}
|
|
</FieldLabel>
|
|
</Field>
|
|
))}
|
|
</RadioGroup>
|
|
</Field>
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const ColorRadioGroup = ({ form, hinges }: RadioGroupProps) => {
|
|
return (
|
|
<Controller
|
|
control={form.control}
|
|
name={hinges.name}
|
|
render={({ field }) => (
|
|
<Field>
|
|
<RadioGroup
|
|
onValueChange={field.onChange}
|
|
value={field.value}
|
|
className="flex shrink grow justify-end gap-3 overflow-x-auto px-1 py-2 [-ms-overflow-style:none] [scrollbar-width:none] [::-webkit-scrollbar]:hidden"
|
|
>
|
|
{hinges.options.map((option) => (
|
|
<Field key={option.id} className="last:pr-12">
|
|
<RadioGroupItem
|
|
id={option.id}
|
|
className="peer sr-only"
|
|
value={option.value}
|
|
/>
|
|
<FieldLabel
|
|
htmlFor={option.id}
|
|
style={
|
|
option.colors?.length === 1
|
|
? {
|
|
backgroundColor: option.colors[0],
|
|
}
|
|
: {
|
|
backgroundImage: `linear-gradient(45deg, ${option.colors?.[0]} 0%, ${option.colors?.[0]} 50%, ${option.colors?.[1]} 50%, ${option.colors?.[1]} 100%)`,
|
|
}
|
|
}
|
|
className="size-8 shrink-0 cursor-pointer rounded-full border-3 border-card font-normal peer-has-data-[state=checked]:ring-2"
|
|
></FieldLabel>
|
|
</Field>
|
|
))}
|
|
</RadioGroup>
|
|
</Field>
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export { ProductCard24 };
|