diff --git a/package-lock.json b/package-lock.json index da0fde6..8a8e6f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "0.1.0", "dependencies": { "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "^5.84.1", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", "iconsax-react": "^0.0.8", "lucide-react": "^0.536.0", "next": "15.4.5", @@ -2721,6 +2723,34 @@ "node": ">= 0.4" } }, + "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", diff --git a/package.json b/package.json index 085f4e3..6cabd37 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,12 @@ }, "dependencies": { "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "^5.84.1", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", "iconsax-react": "^0.0.8", "lucide-react": "^0.536.0", "next": "15.4.5", diff --git a/src/app/home/components/Banners3.tsx b/src/app/home/components/Banners3.tsx new file mode 100644 index 0000000..f0bfec8 --- /dev/null +++ b/src/app/home/components/Banners3.tsx @@ -0,0 +1,18 @@ +import { FC } from 'react' + +const Banners3: FC = () => { + return ( +
+
+
+
+
+
+
+
+
+
+ ) +} + +export default Banners3 \ No newline at end of file diff --git a/src/app/home/components/BlogCard.tsx b/src/app/home/components/BlogCard.tsx new file mode 100644 index 0000000..12c4f1d --- /dev/null +++ b/src/app/home/components/BlogCard.tsx @@ -0,0 +1,14 @@ +import { FC } from 'react' + +const BlogCard: FC = () => { + return ( +
+
+ بررسی + گوشی گیمینگ ایسوس +
+
+ ) +} + +export default BlogCard \ No newline at end of file diff --git a/src/app/home/components/Blogs.tsx b/src/app/home/components/Blogs.tsx new file mode 100644 index 0000000..e06ca45 --- /dev/null +++ b/src/app/home/components/Blogs.tsx @@ -0,0 +1,17 @@ +import { FC } from 'react' +import BlogCard from './BlogCard' +import GridWrapper from '@/components/GridWrapper' + +const Blogs: FC = () => { + return ( + + + + + + + + ) +} + +export default Blogs \ No newline at end of file diff --git a/src/app/home/components/Categories.tsx b/src/app/home/components/Categories.tsx new file mode 100644 index 0000000..11b9c88 --- /dev/null +++ b/src/app/home/components/Categories.tsx @@ -0,0 +1,28 @@ +'use client' +import React from 'react' +import CategoryItem from './CategoryItem' +import { useGetCategories } from '@/share/hooks/useShareData' +import { Category } from '@/share/types/SharedTypes' + +const Categories = () => { + + const { data } = useGetCategories() + const parentCategories = data?.results?.data?.filter(cat => !cat.parent) || [] + + return ( +
+ { + parentCategories?.map((item: Category) => { + return ( + + + ) + }) + } + + +
+ ) +} + +export default Categories \ No newline at end of file diff --git a/src/app/home/components/CategoryItem.tsx b/src/app/home/components/CategoryItem.tsx new file mode 100644 index 0000000..9e6ae9b --- /dev/null +++ b/src/app/home/components/CategoryItem.tsx @@ -0,0 +1,22 @@ +import Image from 'next/image'; +import React, { FC } from 'react' + +type Props = { + image: string; + title: string; +} + +const CategoryItem: FC = (props) => { + return ( +
+
+ {props.title} +
+
+ {props.title} +
+
+ ) +} + +export default CategoryItem \ No newline at end of file diff --git a/src/app/home/components/NewestProducts.tsx b/src/app/home/components/NewestProducts.tsx new file mode 100644 index 0000000..ca444ad --- /dev/null +++ b/src/app/home/components/NewestProducts.tsx @@ -0,0 +1,43 @@ +import { FC } from 'react' +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@/components/ui/carousel" +import ProductCard from '@/components/ProductCard' + + +const NewestProducts: FC = () => { + return ( +
+

جدیدترین محصولات

+
+ برای مشاهده ی جدیدترین محصولات اینجا کلیک کنید +
+ +
+ + + {Array.from({ length: 5 }).map((_, index) => ( + + + + ))} + + + + +
+
+ ) +} + +export default NewestProducts \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 0fc1dc8..3044078 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,6 +2,10 @@ import { NextPage } from 'next' import withLayout from '@/hoc/withLayout' import Carousel from './home/components/Carousel' import HotOffer from './home/components/HotOffer' +import Categories from './home/components/Categories' +import Banners3 from './home/components/Banners3' +import Blogs from './home/components/Blogs' +import NewestProducts from './home/components/NewestProducts' const Home: NextPage = () => { return ( @@ -9,6 +13,20 @@ const Home: NextPage = () => {
+
+ +
+
+ +
+
+ +
+
+ +
+
) diff --git a/src/components/GridWrapper.tsx b/src/components/GridWrapper.tsx new file mode 100644 index 0000000..da18013 --- /dev/null +++ b/src/components/GridWrapper.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { cn } from "@/lib/utils"; + +type GridWrapperProps = { + children: React.ReactNode; + desktop: number; // تعداد ستون‌ها در دسکتاپ + mobile: number; // تعداد ستون‌ها در موبایل + gapDesktop?: number; // فاصله بین آیتم‌ها در دسکتاپ (بر اساس scale پیش‌فرض Tailwind) + gapMobile?: number; // فاصله بین آیتم‌ها در موبایل + className?: string; +}; + +const GRID_COLS_CLASS: Record = { + 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 = { + 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 = { + 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 mapping (scale رایج Tailwind) +const GAP_CLASS: Record = { + 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 = { + 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; + const safe = clampToRange(value, 0, 12); + return GAP_CLASS[safe]; +} + +function getLgGapClass(value?: number): string | undefined { + if (value === undefined) return undefined; + const safe = clampToRange(value, 0, 12); + 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 ( +
+ {children} +
+ ); +} + + diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..a2df8dc --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx new file mode 100644 index 0000000..0e05a77 --- /dev/null +++ b/src/components/ui/carousel.tsx @@ -0,0 +1,241 @@ +"use client" + +import * as React from "react" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" +import { ArrowLeft, ArrowRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a ") + } + + return context +} + +function Carousel({ + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<"div"> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) return + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) return + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + +
+ {children} +
+
+ ) +} + +function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel() + + return ( +
+ ) +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/src/share/components/Menu.tsx b/src/share/components/Menu.tsx index 9e7b180..b201fbd 100644 --- a/src/share/components/Menu.tsx +++ b/src/share/components/Menu.tsx @@ -2,13 +2,13 @@ import { HambergerMenu, ArrowLeft2 } from 'iconsax-react' import { FC, useState, useRef, useEffect } from 'react' import Image from 'next/image' -import { useCategories } from '../hooks/useShareData' +import { useGetCategories } from '../hooks/useShareData' import { Category } from '../types/SharedTypes' const Menu: FC = () => { const [isOpen, setIsOpen] = useState(false) const [hoveredPath, setHoveredPath] = useState([]) - const { data, isLoading } = useCategories() + const { data, isLoading } = useGetCategories() const menuRef = useRef(null) useEffect(() => { diff --git a/src/share/hooks/useShareData.ts b/src/share/hooks/useShareData.ts index a28a450..c7fa513 100644 --- a/src/share/hooks/useShareData.ts +++ b/src/share/hooks/useShareData.ts @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { getCategories } from "../service/ShareService"; -export const useCategories = () => { +export const useGetCategories = () => { return useQuery({ queryKey: ["categories"], queryFn: getCategories,