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

729 lines
20 KiB
TypeScript

"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { ControllerRenderProps, UseFormReturn } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import z from "zod";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { CarouselApi } from "@/components/ui/carousel";
import {
Carousel,
CarouselContent,
CarouselItem,
} 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;
interface ListCarouselProps {
productsList: Array<ProductCard>;
}
type Image = {
src: string;
alt: string;
};
type Option = {
id: string;
label: string;
color: string;
value: string;
image: Image;
};
interface Product {
name: string;
collection: {
label: string;
link: string;
};
variantId: string;
image: {
src: string;
alt: string;
};
link: string;
price: ProductPrice;
quickAdd?: boolean;
variants?: {
name: FieldName;
options: Array<Option>;
};
stockStatusCode: StockStatusCode;
}
type ProductCard = Product;
type ProductCardProps = ProductCard;
interface ProductPrice {
regular: number;
sale?: number;
currency: string;
}
type FieldName = keyof FormType;
type FormType = z.infer<typeof formSchema>;
interface ProductFormProps {
colorHinges?: {
name: FieldName;
options: Omit<Option, "image">[];
};
form: UseFormReturn<FormType>;
}
interface RadioGroupProps {
options?: Omit<Option, "image">[];
field: ControllerRenderProps<FormType>;
}
type ColorOptionProps = Omit<Option, "image">;
type ProductList = Array<Product>;
const PRODUCTS_LIST: ProductList = [
{
collection: {
label: "رنگ‌های بهاری",
link: "#",
},
name: "کاور مینت گارد",
variantId: "mint_guard",
link: "#",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung--uF6u5Cmnsw-unsplash-3.jpg",
alt: "کاور گوشی با طرح سبز نعنایی",
},
price: {
regular: 590000.0,
currency: "ریال",
},
quickAdd: true,
stockStatusCode: "OUT_OF_STOCK",
},
{
collection: {
label: "رنگ‌های انتزاعی",
link: "#",
},
name: "کاور خط نیمه‌شب",
variantId: "midnight_black-1",
link: "#",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-CubSQS4iYEE-unsplash-3.jpg",
alt: "کاور گوشی با طرح خطی تیره",
},
price: {
regular: 590000.0,
sale: 490000.0,
currency: "ریال",
},
variants: {
name: "color",
options: [
{
id: "midnight_black-1",
label: "مشکی نیمه‌شب",
color: "#0B0B0B",
value: "midnight_black",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-CubSQS4iYEE-unsplash-3.jpg",
alt: "کاور گوشی مشکی",
},
},
{
id: "royal_indigo-1",
label: "نیلی سلطنتی",
color: "#3B0A45",
value: "royal_indigo",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-wyEh0ZnwUFM-unsplash-3.jpg",
alt: "کاور گوشی نیلی",
},
},
{
id: "vibrant_tangerine-1",
label: "نارنجی درخشان",
color: "#FF6400",
value: "vibrant_tangerine",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-kfv-hU9ZVkk-unsplash-3.jpg",
alt: "کاور گوشی نارنجی",
},
},
],
},
quickAdd: true,
stockStatusCode: "IN_STOCK",
},
{
collection: {
label: "سایه‌های آرامش",
link: "#",
},
name: "کاور درخشش آمتیست",
variantId: "pastel_lavender",
link: "#",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-EVL1nwmdg_g-unsplash-3.jpg",
alt: "کاور گوشی با طرح بنفش ملایم",
},
price: {
regular: 590000.0,
currency: "ریال",
},
variants: {
name: "color",
options: [
{
id: "pastel_charcoal-1",
label: "خاکستری زغالی",
color: "#4A4A4A",
value: "pastel_charcoal",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-h_t-VwFFLAY-unsplash-3.jpg",
alt: "کاور گوشی خاکستری",
},
},
{
id: "pastel_lavender-1",
label: "اسطوخودوس",
color: "#CBA4DB",
value: "pastel_lavender",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-EVL1nwmdg_g-unsplash-3.jpg",
alt: "کاور گوشی یاسی",
},
},
{
id: "pastel_peach-1",
label: "هلویی",
color: "#FFDAB9",
value: "pastel_peach",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-f-scc2htKNQ-unsplash-3.jpg",
alt: "کاور گوشی هلویی",
},
},
],
},
quickAdd: true,
stockStatusCode: "IN_STOCK",
},
{
collection: {
label: "هنر در حرکت",
link: "#",
},
name: "کاور هجوم رنگ",
variantId: "color_rush",
link: "#",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-f-scc2htKNQ-unsplash-3.jpg",
alt: "کاور گوشی با طرح رنگی پویا",
},
price: {
regular: 590000.0,
currency: "ریال",
},
quickAdd: true,
stockStatusCode: "IN_STOCK",
},
{
collection: {
label: "افق‌های مرتفع",
link: "#",
},
name: "کاور قله‌های تک‌رنگ",
variantId: "monochrome_peaks",
link: "#",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-fCO3tBcnkhg-unsplash-3.jpg",
alt: "کاور گوشی با طرح کوهستان تک‌رنگ",
},
price: {
regular: 590000.0,
currency: "ریال",
},
quickAdd: true,
stockStatusCode: "IN_STOCK",
},
{
collection: {
label: "ریمیکس رترو",
link: "#",
},
name: "کاور پیچ و تورفتگی",
variantId: "twist_&_grid",
link: "#",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-h_t-VwFFLAY-unsplash-3.jpg",
alt: "کاور گوشی با طرح شبکه‌ای رترو",
},
price: {
regular: 590000.0,
currency: "ریال",
},
quickAdd: true,
stockStatusCode: "IN_STOCK",
},
{
collection: {
label: "بیان‌های جسورانه",
link: "#",
},
name: "کاور کلمات مرکباتی",
link: "#",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-kfv-hU9ZVkk-unsplash-3.jpg",
alt: "کاور گوشی با طرح تایپوگرافی",
},
price: {
regular: 590000.0,
currency: "ریال",
},
variants: {
name: "color",
options: [
{
id: "midnight_black-2",
label: "مشکی نیمه‌شب",
color: "#0B0B0B",
value: "midnight_black",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-OXW7AuNsKVI-unsplash-3.jpg",
alt: "کاور گوشی مشکی",
},
},
{
id: "royal_indigo-2",
label: "نیلی سلطنتی",
color: "#3B0A45",
value: "royal_indigo",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-wyEh0ZnwUFM-unsplash-3.jpg",
alt: "کاور گوشی نیلی",
},
},
{
id: "vibrant_tangerine-2",
label: "نارنجی درخشان",
color: "#FF6400",
value: "vibrant_tangerine",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-kfv-hU9ZVkk-unsplash-3.jpg",
alt: "کاور گوشی نارنجی",
},
},
],
},
variantId: "vibrant_tangerine-2",
quickAdd: true,
stockStatusCode: "IN_STOCK",
},
{
collection: {
label: "فراتر از افق",
link: "#",
},
name: "کاور سوار بر آسمان",
variantId: "sky_drift",
link: "#",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-mAUQI0qnKGw-unsplash-3.jpg",
alt: "کاور گوشی با طرح آسمان",
},
price: {
regular: 590000.0,
currency: "ریال",
},
quickAdd: true,
stockStatusCode: "IN_STOCK",
},
{
collection: {
label: "سری آسمانی",
link: "#",
},
name: "کاور پرده نیمه‌شب",
variantId: "midnight_veil",
link: "#",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-OXW7AuNsKVI-unsplash-3.jpg",
alt: "کاور گوشی با طرح شبانه",
},
price: {
regular: 590000.0,
currency: "ریال",
},
quickAdd: true,
stockStatusCode: "IN_STOCK",
},
{
collection: {
label: "رویاهای ساحلی",
link: "#",
},
name: "کاور زمزمه جزر و مد",
variantId: "tide_whisper",
link: "#",
image: {
src: "/block-media/shadcnblocks/block/ecommerce/other/jascent-leung-wyEh0ZnwUFM-unsplash-3.jpg",
alt: "کاور گوشی با طرح ساحلی",
},
price: {
regular: 590000.0,
currency: "ریال",
},
quickAdd: true,
stockStatusCode: "IN_STOCK",
},
];
interface ProductList6Props {
className?: string;
}
const ProductList6 = ({ className }: ProductList6Props) => {
return (
<section className={cn("py-32", className)}>
<div className="container">
<ListCarousel productsList={PRODUCTS_LIST} />
</div>
</section>
);
};
const ListCarousel = ({ productsList }: ListCarouselProps) => {
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0);
const [canScrollPrev, setCanScrollPrev] = useState(false);
const [canScrollNext, setCanScrollNext] = useState(false);
const onSelect = useCallback((carouselApi: CarouselApi) => {
if (!carouselApi) return;
setCanScrollPrev(carouselApi.canScrollPrev());
setCanScrollNext(carouselApi.canScrollNext());
setCurrent(carouselApi.selectedScrollSnap() + 1);
}, []);
useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api.off("select", onSelect);
};
}, [api, onSelect]);
if (!productsList) return;
return (
<Carousel
dir="rtl"
opts={{
align: "start",
direction: "rtl",
}}
setApi={setApi}
>
<div className="flex items-center justify-between pb-6">
<div className="flex flex-wrap items-end gap-2">
<h2 className="text-lg leading-tight font-black uppercase xl:text-xl">
محبوب‌ترین
</h2>
<p className="text-sm font-medium text-muted-foreground xl:text-base">
تا ۳۰٪ تخفیف
</p>
</div>
<div className="hidden items-center gap-2 xl:flex">
<Button
type="button"
variant="outline"
size="icon-sm"
className="static size-8 shrink-0 translate-0 touch-manipulation"
disabled={!canScrollPrev}
onClick={() => api?.scrollPrev()}
aria-label="اسلاید قبلی"
>
<ChevronRight className="size-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon-sm"
className="static size-8 shrink-0 translate-0 touch-manipulation"
disabled={!canScrollNext}
onClick={() => api?.scrollNext()}
aria-label="اسلاید بعدی"
>
<ChevronLeft className="size-4" />
</Button>
</div>
</div>
<CarouselContent className="ms-0 -me-4">
{productsList.map((product, index) => (
<CarouselItem
key={`product-list-6-item-${index}`}
className="ps-0 pe-4 sm:basis-1/2 xl:basis-1/4"
>
<ProductCard {...product} />
</CarouselItem>
))}
</CarouselContent>
<div className="flex items-center justify-center pt-5 xl:hidden">
{productsList.map((_, index) => (
<button
onClick={() => api?.scrollTo(index)}
data-active={current === index + 1}
className="flex size-6 after:m-auto after:h-1 after:w-5 after:bg-muted-foreground/50 after:transition-colors data-[active=true]:after:bg-primary sm:last:hidden"
key={`product-list-6-pagination-bullets-${index}`}
></button>
))}
</div>
</Carousel>
);
};
const formSchema = z.object({
color: z.string(),
});
const ProductCard = ({
name,
image,
stockStatusCode,
price,
link,
collection,
quickAdd,
variants,
variantId,
}: ProductCardProps) => {
const form = useForm<FormType>({
resolver: zodResolver(formSchema),
defaultValues: {
color: variantId,
},
});
const selectedVariant = form.watch("color");
const currentVariantImage =
variants?.options.find((item) => item.value === selectedVariant)?.image ||
image;
const { regular, sale, currency } = price;
function onSubmit(values: FormType) {
console.log(values);
}
return (
<Card className="group gap-2 rounded-none border-none bg-background p-0 shadow-none">
<CardHeader className="gap-0 p-0">
<AspectRatio ratio={0.76984127} className="overflow-hidden">
<a href={link} className="block size-full">
{currentVariantImage && (
<img
src={currentVariantImage?.src}
alt={currentVariantImage?.alt}
className="block size-full object-cover object-center transition-transform duration-500 group-hover:scale-110"
/>
)}
</a>
{quickAdd && stockStatusCode === STOCK_STATUS.IN_STOCK && (
<div className="absolute inset-x-5 bottom-5 transition-opacity duration-400 group-hover:opacity-100 xl:opacity-0">
<Button
onClick={() => {
form.trigger().then((valid) => {
if (valid) {
form.handleSubmit(onSubmit)();
}
});
}}
className="w-full"
>
افزودن سریع
</Button>
</div>
)}
</AspectRatio>
</CardHeader>
<CardContent className="p-0">
{(stockStatusCode || price?.sale) && (
<div className="text-sm">
{stockStatusCode === STOCK_STATUS.OUT_OF_STOCK ? (
<span className="text-muted-foreground">فروخته شد</span>
) : (
price.sale && <span className="text-destructive">حراج</span>
)}
</div>
)}
<div
className={`pt-0.5 ${stockStatusCode === STOCK_STATUS.IN_STOCK && !price?.sale && "mt-5"}`}
>
<a className="text-sm font-bold" href={collection.link}>
{collection.label}
</a>
<div className="flex flex-wrap items-center gap-2 xl:flex-nowrap">
<CardTitle className="text-sm font-normal">{name}</CardTitle>
<div className="transition-opacity duration-400 group-hover:opacity-100 xl:opacity-0">
<ProductForm colorHinges={variants} form={form} />
</div>
</div>
</div>
</CardContent>
<CardFooter className="relative mt-1 flex-col items-start gap-3 p-0 xl:flex-row">
<div className="transition-opacity duration-400 xl:group-hover:opacity-0">
<ProductCardPrice
regular={regular}
sale={sale}
currency={currency}
/>
</div>
<div className="absolute flex items-center gap-3 bg-background opacity-0 transition-opacity duration-400 group-hover:opacity-100">
<Button variant="link" className="h-fit p-0 underline">
همین حالا بخرید
</Button>
<Button
variant="link"
className="h-fit p-0 text-muted-foreground underline"
>
مشاهده سریع
</Button>
</div>
</CardFooter>
</Card>
);
};
const ProductForm = ({ colorHinges, form }: ProductFormProps) => {
if (!colorHinges) return;
return (
<form>
<Controller
control={form.control}
name={colorHinges.name}
render={({ field }) => (
<Field>
<ColorRadioGroup field={field} options={colorHinges.options} />
</Field>
)}
/>
</form>
);
};
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 gap-0"
>
{options &&
options.map((item, index) => (
<Field key={`product-list-6-color-input-${index}`}>
<ColorOption
label={item.label}
id={item.id}
color={item.color}
value={item.value}
/>
</Field>
))}
</RadioGroup>
);
};
const ColorOption = ({ id, value, color, label }: ColorOptionProps) => {
return (
<FieldLabel
htmlFor={id}
aria-label={label}
className="relative flex size-5 cursor-pointer after:pointer-events-none after:absolute after:inset-1/2 after:size-1 after:-translate-1/2 after:rounded-full after:bg-white after:opacity-0 after:transition-opacity has-checked:after:opacity-100"
>
<RadioGroupItem id={id} className="sr-only" value={value} />
<div
style={{
backgroundColor: color,
}}
className="m-auto size-2.5 rounded-full"
></div>
</FieldLabel>
);
};
const formatCurrency = (
value: number,
currency: string = "ریال",
locale: string = "fa-IR",
) => {
return `${value.toLocaleString(locale)} ${currency}`;
};
const ProductCardPrice = ({
regular,
sale,
currency,
}: {
regular: number;
sale?: number;
currency: string;
}) => {
const onSale = sale != null;
return (
<div className="flex flex-wrap items-center gap-x-2 text-sm font-medium">
{onSale ? (
<span className="leading-tight text-foreground">
{formatCurrency(sale, currency)}
</span>
) : null}
<span
className={cn(
"leading-tight",
onSale
? "text-muted-foreground line-through"
: "text-foreground",
)}
>
{formatCurrency(regular, currency)}
</span>
</div>
);
};
export { ProductList6 };