carousel category section

This commit is contained in:
hamid zarghami
2026-07-18 11:23:53 +03:30
parent a3626a5684
commit f9f828097b
6 changed files with 305 additions and 9 deletions
+161
View File
@@ -0,0 +1,161 @@
import React from "react";
import { cn } from "../lib/cn";
type GridWrapperProps = {
children: React.ReactNode;
desktop: number; // تعداد ستون‌ها در دسکتاپ
mobile: number; // تعداد ستون‌ها در موبایل
gapDesktop?: number; // فاصله بین آیتم‌ها در دسکتاپ. اگر مقدار > 12 باشد به عنوان px تفسیر می‌شود (مثلاً 24 => 24px)
gapMobile?: number; // فاصله بین آیتم‌ها در موبایل. اگر مقدار > 12 باشد به عنوان px تفسیر می‌شود
className?: string;
};
const GRID_COLS_CLASS: Record<number, string> = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
9: "grid-cols-9",
10: "grid-cols-10",
11: "grid-cols-11",
12: "grid-cols-12",
};
const MD_GRID_COLS_CLASS: Record<number, string> = {
1: "md:grid-cols-1",
2: "md:grid-cols-2",
3: "md:grid-cols-3",
4: "md:grid-cols-4",
5: "md:grid-cols-5",
6: "md:grid-cols-6",
7: "md:grid-cols-7",
8: "md:grid-cols-8",
9: "md:grid-cols-9",
10: "md:grid-cols-10",
11: "md:grid-cols-11",
12: "md:grid-cols-12",
};
// نسخه‌ی دسکتاپ با پیشوند lg: به صورت literal تا با purge مشکلی نداشته باشد
const LG_GRID_COLS_CLASS: Record<number, string> = {
1: "lg:grid-cols-1",
2: "lg:grid-cols-2",
3: "lg:grid-cols-3",
4: "lg:grid-cols-4",
5: "lg:grid-cols-5",
6: "lg:grid-cols-6",
7: "lg:grid-cols-7",
8: "lg:grid-cols-8",
9: "lg:grid-cols-9",
10: "lg:grid-cols-10",
11: "lg:grid-cols-11",
12: "lg:grid-cols-12",
};
// نگاشت Gap بر اساس scale رایج Tailwind: gap-N که هر N معادل 4px است (N * 4px)
const GAP_CLASS: Record<number, string> = {
0: "gap-0",
1: "gap-1",
2: "gap-2",
3: "gap-3",
4: "gap-4",
5: "gap-5",
6: "gap-6",
7: "gap-7",
8: "gap-8",
9: "gap-9",
10: "gap-10",
11: "gap-11",
12: "gap-12",
13: "gap-13",
14: "gap-14",
15: "gap-15",
16: "gap-16",
17: "gap-17",
18: "gap-18",
19: "gap-19",
20: "gap-20",
21: "gap-21",
22: "gap-22",
23: "gap-23",
24: "gap-24",
};
const LG_GAP_CLASS: Record<number, string> = {
0: "lg:gap-0",
1: "lg:gap-1",
2: "lg:gap-2",
3: "lg:gap-3",
4: "lg:gap-4",
5: "lg:gap-5",
6: "lg:gap-6",
7: "lg:gap-7",
8: "lg:gap-8",
9: "lg:gap-9",
10: "lg:gap-10",
11: "lg:gap-11",
12: "lg:gap-12",
13: "lg:gap-13",
14: "lg:gap-14",
15: "lg:gap-15",
16: "lg:gap-16",
17: "lg:gap-17",
18: "lg:gap-18",
19: "lg:gap-19",
20: "lg:gap-20",
21: "lg:gap-21",
22: "lg:gap-22",
23: "lg:gap-23",
24: "lg:gap-24",
};
function clampToRange(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function getGridColsClass(count: number): string {
const safe = clampToRange(count, 1, 12);
return GRID_COLS_CLASS[safe];
}
function getLgGridColsClass(count: number): string {
const safe = clampToRange(count, 1, 12);
return LG_GRID_COLS_CLASS[safe];
}
function getMdGridColsClass(count: number): string {
const safe = clampToRange(count, 1, 12);
return MD_GRID_COLS_CLASS[safe];
}
function getGapClass(value?: number): string | undefined {
if (value === undefined) return undefined;
// اگر کاربر مقدار را به صورت px فرستاده باشد (بزرگ‌تر از 12)، آن را به scale تبدیل می‌کنیم: N = round(px/4)
const interpretedAsScale = value > 12 ? Math.round(value / 4) : value;
const safe = clampToRange(interpretedAsScale, 0, 24);
return GAP_CLASS[safe];
}
function getLgGapClass(value?: number): string | undefined {
if (value === undefined) return undefined;
const interpretedAsScale = value > 12 ? Math.round(value / 4) : value;
const safe = clampToRange(interpretedAsScale, 0, 24);
return LG_GAP_CLASS[safe];
}
export default function GridWrapper(props: GridWrapperProps) {
const { children, desktop, mobile, className, gapDesktop = 4, gapMobile = 4 } = props;
const mobileCols = getGridColsClass(mobile);
const desktopColsMd = getMdGridColsClass(desktop);
const desktopColsLg = getLgGridColsClass(desktop);
const mobileGap = getGapClass(gapMobile);
const desktopGap = getLgGapClass(gapDesktop);
return <div className={cn("grid", mobileCols, desktopColsMd, desktopColsLg, mobileGap, desktopGap, className)}>{children}</div>;
}
+26
View File
@@ -0,0 +1,26 @@
import CategoryImage from "@/assets/images/category.jpg";
import { ArrowLeft } from "iconsax-reactjs";
import Image from "next/image";
import { type FC } from "react";
type CategoryCardProps = {
title: string;
};
const CategoryCard: FC<CategoryCardProps> = ({ title }) => {
return (
<div className="w-full bg-[#D4E3F154]/33 rounded-[40px] p-6">
<div className="flex justify-between items-center">
<div className="text-[#21588C] font-bold">{title}</div>
<div className="bg-white/40 flex justify-center items-center">
<ArrowLeft size={20} color="currentColor" className="text-primary rotate-45" />
</div>
</div>
<div className="mt-4">
<Image src={CategoryImage} alt={title} width={300} height={300} className="w-full h-auto rounded-3xl" />
</div>
</div>
);
};
export default CategoryCard;
+74 -5
View File
@@ -1,21 +1,90 @@
"use client";
import { ArrowLeft, ArrowRight } from "iconsax-reactjs";
import useEmblaCarousel from "embla-carousel-react";
import { useEffect, useState } from "react";
import CategoryCard from "./CategoryCard";
const categories = [
"هدایای تبلیغاتی",
"کارت ویزیت",
"بروشور و کاتالوگ",
"استیکر و لیبل",
"پاکت و جعبه",
"سربرگ و اوراق اداری",
"بنر و فلکس",
"تقویم و سررسید",
"چاپ روی پارچه",
"لوازم نمایشگاهی",
];
const CategorySection = () => {
const [emblaRef, emblaApi] = useEmblaCarousel({
align: "start",
direction: "rtl",
slidesToScroll: 5,
containScroll: "trimSnaps",
});
const [canScrollPrev, setCanScrollPrev] = useState(false);
const [canScrollNext, setCanScrollNext] = useState(false);
useEffect(() => {
if (!emblaApi) return;
const updateButtons = () => {
setCanScrollPrev(emblaApi.canScrollPrev());
setCanScrollNext(emblaApi.canScrollNext());
};
updateButtons();
emblaApi.on("select", updateButtons);
emblaApi.on("reInit", updateButtons);
return () => {
emblaApi.off("select", updateButtons);
emblaApi.off("reInit", updateButtons);
};
}, [emblaApi]);
return (
<div className="mt-[120px] px-[120px]">
<div className="flex justify-between items-center">
<div className="font-bold text-2xl">دسته بندی های محصولات</div>
<div className="flex gap-2">
<div className="size-8 bg-primary rounded-full flex justify-center items-center">
<button
type="button"
aria-label="دسته‌بندی‌های قبلی"
disabled={!canScrollPrev}
onClick={() => emblaApi?.scrollPrev()}
className="size-8 bg-primary rounded-full flex justify-center items-center disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
>
<ArrowRight size={20} color="white" />
</div>
<div className="size-8 bg-primary rounded-full flex justify-center items-center">
</button>
<button
type="button"
aria-label="دسته‌بندی‌های بعدی"
disabled={!canScrollNext}
onClick={() => emblaApi?.scrollNext()}
className="size-8 bg-primary rounded-full flex justify-center items-center disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
>
<ArrowLeft size={20} color="white" />
</div>
</button>
</div>
</div>
<div className="mt-12"></div>
<div className="mt-12 overflow-hidden" ref={emblaRef}>
<div className="flex touch-pan-y gap-10">
{categories.map((title) => (
<div
key={title}
className="min-w-0 shrink-0 grow-0 basis-[calc((100%-10rem)/5)]"
>
<CategoryCard title={title} />
</div>
))}
</div>
</div>
</div>
);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

+43 -4
View File
@@ -8,6 +8,7 @@
"name": "mehraein",
"version": "0.1.0",
"dependencies": {
"embla-carousel-react": "^8.6.0",
"iconsax-reactjs": "^0.0.8",
"next": "16.2.10",
"react": "19.2.4",
@@ -2231,6 +2232,20 @@
"linux"
]
},
"node_modules/@unrs/resolver-binding-openharmony-arm64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz",
"integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@unrs/resolver-binding-wasm32-wasi": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz",
@@ -2992,6 +3007,34 @@
"dev": true,
"license": "ISC"
},
"node_modules/embla-carousel": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT"
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
"license": "MIT",
"dependencies": {
"embla-carousel": "8.6.0",
"embla-carousel-reactive-utils": "8.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/embla-carousel-reactive-utils": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
"license": "MIT",
"peerDependencies": {
"embla-carousel": "8.6.0"
}
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -6611,10 +6654,6 @@
"@unrs/resolver-binding-win32-x64-msvc": "1.12.2"
}
},
"node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-openharmony-arm64": {
"dev": true,
"optional": true
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+1
View File
@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"embla-carousel-react": "^8.6.0",
"iconsax-reactjs": "^0.0.8",
"next": "16.2.10",
"react": "19.2.4",