Files
reui-blocks/blocks/product-card8.tsx
T

562 lines
17 KiB
TypeScript

"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Minus, Plus, ShoppingBag, Star, X } from "lucide-react";
import { useMemo, useState } from "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, PriceValue } from "@/components/shadcnblocks/price";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ScrollArea } from "@/components/ui/scroll-area";
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 FieldName = keyof FormType;
type Option = {
id: string;
label: string;
value: string;
stockStatusCode: StockStatusCode;
color?: string;
};
interface Hinges {
label: string;
id: string;
name: FieldName;
options?: Option[];
min?: number;
max?: number;
}
interface ProductPrice {
regular: number;
sale?: number;
currency: string;
discount?: string;
}
interface ProductFormProps {
hinges: Record<FieldName, Hinges>;
onOpenChange: () => void;
}
interface QuantityProps {
field: ControllerRenderProps<FormType>;
max?: number;
min?: number;
}
type Image = {
srcset: string;
sizes: string;
alt: string;
};
interface RadioGroupProps {
options?: Array<Option>;
id: string;
field: ControllerRenderProps<FormType>;
}
interface Product {
name: string;
price: ProductPrice;
rate: number;
images: Image[];
link: string;
hinges?: Record<FieldName, Hinges>;
}
const PRODUCT_CARD: Product = {
name: "پیراهن میدی راه‌راه عمودی",
price: {
regular: 990000.0,
sale: 790000.0,
currency: "ریال",
discount: "-۲۰٪",
},
rate: 5,
link: "#",
images: [
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/clothes/pexels-429124762-15555009-3.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/clothes/pexels-429124762-15555009-2.jpg 1280w, /block-media/shadcnblocks/block/ecommerce/clothes/pexels-429124762-15555009-1.jpg 640w",
alt: "پیراهن میدی راه‌راه عمودی",
sizes: "(min-width: 1920px) 1920px, (min-width: 1280px) 1280px, 100vw",
},
{
srcset:
"/block-media/shadcnblocks/block/ecommerce/clothes/pexels-429124762-15555003-3.jpg 1920w, /block-media/shadcnblocks/block/ecommerce/clothes/pexels-429124762-15555003-2.jpg 1280w, /block-media/shadcnblocks/block/ecommerce/clothes/pexels-429124762-15555003-1.jpg 640w",
alt: "پیراهن میدی راه‌راه عمودی نمای دوم",
sizes: "(min-width: 1920px) 1920px, (min-width: 1280px) 1280px, 100vw",
},
],
hinges: {
size: {
label: "سایز",
id: "size",
name: "size",
options: [
{
id: "xs-1",
value: "xs",
label: "xs",
stockStatusCode: "OUT_OF_STOCK",
},
{
id: "s-1",
value: "s",
label: "s",
stockStatusCode: "OUT_OF_STOCK",
},
{
id: "m-1",
value: "m",
label: "m",
stockStatusCode: "IN_STOCK",
},
{
id: "l-1",
value: "l",
label: "l",
stockStatusCode: "IN_STOCK",
},
{
id: "xl-1",
value: "xl",
label: "xl",
stockStatusCode: "IN_STOCK",
},
{
id: "xxl-1",
value: "xxl",
label: "xxl",
stockStatusCode: "IN_STOCK",
},
{
id: "3xl-1",
value: "3xl",
label: "3xl",
stockStatusCode: "IN_STOCK",
},
{
id: "4xl-1",
value: "4xl",
label: "4xl",
stockStatusCode: "IN_STOCK",
},
],
},
color: {
label: "رنگ",
id: "color",
name: "color",
options: [
{
id: "black-1",
label: "مشکی",
value: "black",
color: "#000000",
stockStatusCode: "IN_STOCK",
},
{
id: "blue-1",
label: "آبی",
value: "blue",
color: "#607FC7",
stockStatusCode: "IN_STOCK",
},
{
id: "red-1",
label: "قرمز",
value: "red",
color: "#97012e",
stockStatusCode: "IN_STOCK",
},
],
},
quantity: {
label: "تعداد",
id: "quantity-1",
name: "quantity",
min: 1,
max: 90,
},
},
};
interface ProductCard8Props {
className?: string;
}
const ProductCard8 = ({ className }: ProductCard8Props) => {
const [open, setOpen] = useState(false);
const { regular, sale, currency } = PRODUCT_CARD.price;
const onOpenChange = () => {
if (PRODUCT_CARD.hinges) {
setOpen(!open);
}
};
return (
<div className={cn("flex w-full justify-center", className)}>
<Card className="group relative block w-full max-w-64 rounded-none border-none shadow-none ring ring-border">
<a
href={PRODUCT_CARD.link}
className="absolute inset-0 z-50 size-full"
></a>
<CardContent>
<div className="relative overflow-hidden">
<AspectRatio
ratio={1}
className="flex items-center justify-center overflow-hidden rounded-[1.25rem] bg-muted/20"
>
{PRODUCT_CARD.images.map((img, index) => (
<div
className="absolute inset-0 flex size-full items-center justify-center transition-opacity duration-350 first:z-20 group-hover:first:opacity-0 last:z-10"
key={`product-card-8-card-img-${index}`}
>
<img
srcSet={img.srcset}
alt={img.alt}
sizes={img.sizes}
className="size-full object-contain object-center transition-transform duration-350"
/>
</div>
))}
</AspectRatio>
<Badge
variant="outline"
className="absolute start-4 top-4 z-30 rounded-full bg-background font-bold opacity-0 transition-opacity duration-350 group-hover:opacity-100"
>
<Star className="fill-inherit" />
{PRODUCT_CARD.rate}
</Badge>
{PRODUCT_CARD.price.discount && (
<Badge
variant="outline"
className="absolute end-4 top-4 z-30 rounded-full bg-background font-bold text-red-600 transition-opacity duration-350"
>
{PRODUCT_CARD.price.discount}
</Badge>
)}
<Button
size="icon"
onClick={onOpenChange}
className="absolute end-4 bottom-4 z-60 rounded-full lg:translate-y-[calc(100%+1rem)] lg:group-hover:translate-y-0"
>
<ShoppingBag />
</Button>
</div>
<div className="min-h-44 space-y-2 px-5 pb-8 pt-6 lg:px-7 lg:pb-8">
<CardTitle className="text-xl leading-none font-medium">
{PRODUCT_CARD.name}
</CardTitle>
<Price onSale={sale != null}>
<PriceValue price={sale} currency={currency} variant="sale" />
<PriceValue price={regular} currency={currency} variant="regular" />
</Price>
</div>
</CardContent>
{PRODUCT_CARD.hinges && (
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleContent className="absolute inset-0 z-70 size-full bg-background">
<ProductForm
onOpenChange={onOpenChange}
hinges={PRODUCT_CARD.hinges}
/>
</CollapsibleContent>
</Collapsible>
)}
</Card>
</div>
);
};
const formSchema = z.object({
size: z.string(),
color: z.string(),
quantity: z.number(),
});
const ProductForm = ({ onOpenChange, hinges }: ProductFormProps) => {
const form = useForm<FormType>({
resolver: zodResolver(formSchema),
defaultValues: {
color: "",
size: "",
quantity: 1,
},
});
function onSubmit(values: FormType) {
console.log(values);
}
const sizeHinges = hinges?.size;
const colorHinges = hinges?.color;
const quantityHinges = hinges?.quantity;
const currentColorId = form.watch("color");
const currentColor = useMemo(
() =>
colorHinges?.options?.find((item) => item.value === currentColorId)
?.label ?? "",
[currentColorId, colorHinges],
);
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="h-full">
<div className="relative h-full">
<ScrollArea className="h-full pb-30 lg:pb-17">
<div className="flex size-full flex-col gap-6">
<div className="relative space-y-5 p-4">
<Button
size="icon"
variant="ghost"
type="button"
onClick={onOpenChange}
className="absolute end-2 top-2 rounded-full"
>
<X />
</Button>
{sizeHinges && (
<Controller
control={form.control}
name={sizeHinges.name}
render={({ field }) => (
<Field>
<FieldLabel
className="text-base leading-normal font-bold"
asChild
>
<h2>{sizeHinges.label}</h2>
</FieldLabel>
<div className="relative -mr-4 overflow-hidden after:pointer-events-none after:absolute after:end-0 after:top-0 after:block after:h-full after:w-10.5 after:bg-gradient-to-r after:from-transparent after:to-background after:content-['']">
<SizeRadioGroup
field={field}
options={sizeHinges.options}
id={sizeHinges.id}
/>
</div>
</Field>
)}
/>
)}
{colorHinges && (
<Controller
control={form.control}
name={colorHinges.name}
render={({ field }) => (
<Field>
<FieldLabel
className="text-base leading-normal font-bold"
asChild
>
<h2>{colorHinges.label}</h2>
</FieldLabel>
<ColorRadioGroup
field={field}
options={colorHinges.options}
id={colorHinges.id}
/>
<p className="text-xs text-muted-foreground">
{currentColor
? currentColor
: `${colorHinges.options?.length} رنگ موجود`}
</p>
</Field>
)}
/>
)}
</div>
</div>
</ScrollArea>
<div className="absolute inset-x-0 bottom-0">
<div className="flex flex-col items-center gap-4 bg-background p-4 lg:flex-row">
{quantityHinges && (
<Controller
control={form.control}
name={quantityHinges.name}
render={({ field }) => (
<Field className="self-stretch">
<Quantity
field={field}
min={quantityHinges?.min}
max={quantityHinges?.max}
/>
</Field>
)}
/>
)}
<div className="flex-1 self-stretch">
<Button
disabled={!form.getValues("color") && !form.getValues("size")}
className="w-full rounded-full"
>
افزودن به سبد
</Button>
</div>
</div>
</div>
</div>
</form>
);
};
const SizeRadioGroup = ({ options, id, field }: RadioGroupProps) => {
if (!options) return;
return (
<RadioGroup
{...field}
value={`${field.value}`}
id={id}
onValueChange={(value) => {
if (value != field.value && value) {
field.onChange(value);
}
}}
className="flex w-full items-center gap-2 overflow-x-auto p-px [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{options.map((item, index) => (
<Field
className="shrink-0 last:pr-14.5"
key={`product-card-8-size-${index}`}
>
<FieldLabel
htmlFor={item.id}
className="h-9 cursor-pointer overflow-hidden rounded-full border px-4.5 py-3 text-center leading-none font-normal uppercase ring-primary duration-400 not-has-disabled:hover:ring-1 has-checked:ring has-checked:hover:ring has-disabled:opacity-25"
>
<RadioGroupItem
id={item.id}
className="sr-only"
value={item.value}
aria-label={`انتخاب ${item.label}`}
disabled={item.stockStatusCode === STOCK_STATUS.OUT_OF_STOCK}
/>
{item.label}
</FieldLabel>
</Field>
))}
</RadioGroup>
);
};
const ColorRadioGroup = ({ options, id, field }: RadioGroupProps) => {
if (!options) return;
return (
<RadioGroup
{...field}
value={`${field.value}`}
id={id}
onValueChange={(value) => {
if (value != field.value && value) {
field.onChange(value);
}
}}
className="flex w-full flex-wrap items-center gap-2 overflow-x-auto p-px [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{options.map((item, index) => (
<Field key={`product-card-8-color-${index}`}>
<FieldLabel
htmlFor={item.id}
className="relative flex size-9 cursor-pointer overflow-hidden rounded-full border ring-primary duration-400 not-has-disabled:hover:ring has-checked:ring has-disabled:opacity-25"
>
<RadioGroupItem
id={item.id}
className="sr-only"
value={item.value}
aria-label={`انتخاب ${item.label}`}
disabled={item.stockStatusCode === STOCK_STATUS.OUT_OF_STOCK}
/>
<div
style={{
backgroundColor: item.color,
}}
className="m-auto size-7.5 rounded-full"
></div>
{item.stockStatusCode === STOCK_STATUS.OUT_OF_STOCK && (
<span className="absolute top-0 bottom-0 left-1/2 z-10 block h-full w-0 rotate-45 border-l"></span>
)}
</FieldLabel>
</Field>
))}
</RadioGroup>
);
};
const Quantity = ({ field, max, min }: QuantityProps) => {
return (
<div
className="flex h-9 w-full min-w-34 items-center overflow-hidden rounded-full border shadow-xs"
aria-label="quantity"
>
<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={Math.max(min ?? 1, 1)}
max={max ?? 99}
className="h-full w-full min-w-10 flex-1 [appearance:textfield] rounded-none border-0 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 { ProductCard8 };