Compare commits

..

2 Commits

Author SHA1 Message Date
hamid zarghami 6f22885ae9 category page 2026-07-21 10:15:40 +03:30
hamid zarghami fe1e545aaf category detail 2026-07-21 09:48:39 +03:30
10 changed files with 252 additions and 14 deletions
@@ -0,0 +1,84 @@
"use client";
import { cn } from "@/app/lib/cn";
import { AddSquare, MinusSquare } from "iconsax-reactjs";
import { useState, type FC } from "react";
import { FILTER_SECTIONS, type FilterSection } from "../constants";
const CategoryFilters: FC = () => {
const [openSections, setOpenSections] = useState<Record<string, boolean>>(() => Object.fromEntries(FILTER_SECTIONS.map((section) => [section.id, Boolean(section.defaultOpen)])));
const [selected, setSelected] = useState<string[]>([]);
const toggleSection = (id: string) => {
setOpenSections((prev) => ({ ...prev, [id]: !prev[id] }));
};
const toggleOption = (option: string) => {
setSelected((prev) => (prev.includes(option) ? prev.filter((item) => item !== option) : [...prev, option]));
};
return (
<div className="w-full shrink-0 lg:w-70 xl:w-75">
<aside className="rounded-2xl bg-white p-4 shadow-[0_0_6px_rgba(50,103,137,0.18)] sm:rounded-3xl sm:p-5">
<h2 className="mb-4 text-center text-lg font-bold text-[#0A1B2C]">فیلترها</h2>
<div className="flex flex-col gap-3">
{FILTER_SECTIONS.map((section) => (
<FilterAccordion
key={section.id}
section={section}
isOpen={Boolean(openSections[section.id])}
selected={selected}
onToggle={() => toggleSection(section.id)}
onToggleOption={toggleOption}
/>
))}
</div>
</aside>
</div>
);
};
type FilterAccordionProps = {
section: FilterSection;
isOpen: boolean;
selected: string[];
onToggle: () => void;
onToggleOption: (option: string) => void;
};
const FilterAccordion: FC<FilterAccordionProps> = ({ section, isOpen, selected, onToggle, onToggleOption }) => {
const Icon = isOpen ? MinusSquare : AddSquare;
return (
<div className="rounded-xl bg-[rgba(233,238,242,0.35)] p-3">
<button type="button" className="flex w-full items-center justify-between gap-2" onClick={onToggle} aria-expanded={isOpen}>
<span className="text-sm text-[#0A1B2C] sm:text-base">{section.title}</span>
<Icon size={16} color="currentColor" className="shrink-0 text-primary" />
</button>
{isOpen && section.options && (
<ul className="mt-3 flex flex-col gap-2.5">
{section.options.map((option) => {
const checked = selected.includes(option);
return (
<li key={option}>
<button type="button" className="flex w-full items-center gap-2" onClick={() => onToggleOption(option)}>
<span
role="checkbox"
aria-checked={checked}
className={cn("size-4 shrink-0 rounded border-2 transition-colors", checked ? "border-primary bg-primary" : "border-[rgba(50,103,137,0.29)] bg-transparent")}
/>
<span className="text-right text-sm text-[#53606B]">{option}</span>
</button>
</li>
);
})}
</ul>
)}
</div>
);
};
export default CategoryFilters;
+47
View File
@@ -0,0 +1,47 @@
import heroImage from "@/assets/images/category/hero.jpg";
import { ArrowLeft2 } from "iconsax-reactjs";
import Image from "next/image";
import Link from "next/link";
import { type FC } from "react";
import { CATEGORY_DESCRIPTION, CATEGORY_TITLE } from "../constants";
const CategoryHero: FC = () => {
return (
<>
<section
className="relative overflow-x-clip px-4 sm:px-8 lg:px-30 py-6 sm:py-8 lg:py-10"
style={{
backgroundImage: "linear-gradient(108.5deg, rgba(33, 88, 140, 0.1) 89.65%, rgba(237, 243, 248, 0.3) 100%)",
}}
>
<div className="flex flex-col lg:flex-row lg:items-center gap-6 lg:gap-10">
<div className="flex-1 space-y-3 sm:space-y-4">
<h1 className="text-[#0A1B2C] text-2xl sm:text-3xl lg:text-[32px] font-bold">{CATEGORY_TITLE}</h1>
<p className="text-sm text-[#0A1B2C]/80 leading-7 max-w-140">{CATEGORY_DESCRIPTION}</p>
</div>
<div className="relative z-10 mx-auto shrink-0 lg:mx-0 lg:-mb-28">
<Image
src={heroImage}
alt={CATEGORY_TITLE}
width={360}
height={360}
priority
className="aspect-square w-55 sm:w-70 lg:w-[320px] rounded-2xl sm:rounded-3xl object-cover shadow-[0px_-4px_12px_0px_rgba(0,0,0,0.12)]"
/>
</div>
</div>
</section>
<nav aria-label="مسیر صفحه" className="relative z-0 flex items-center justify-start gap-2 bg-secondary px-4 sm:px-8 lg:px-30 py-3 text-sm text-[#3F4D5A]">
<Link href="/" className="transition-colors hover:text-primary">
صفحه اصلی
</Link>
<ArrowLeft2 size={14} color="currentColor" className="shrink-0" />
<span className="font-bold">{CATEGORY_TITLE}</span>
</nav>
</>
);
};
export default CategoryHero;
@@ -0,0 +1,16 @@
import GridWrapper from "@/app/components/GridWrapper";
import ProductCard from "@/app/home/components/ProductCard";
import { type FC } from "react";
import { CATEGORY_PRODUCTS } from "../constants";
const CategoryProducts: FC = () => {
return (
<GridWrapper desktop={4} mobile={2} gapDesktop={24} gapMobile={12}>
{CATEGORY_PRODUCTS.map((title, index) => (
<ProductCard key={`${title}-${index}`} title={title} />
))}
</GridWrapper>
);
};
export default CategoryProducts;
@@ -0,0 +1,20 @@
import Input from "@/app/components/Input";
import Select from "@/app/components/Select";
import { type FC } from "react";
import { SORT_OPTIONS } from "../constants";
const CategoryToolbar: FC = () => {
return (
<div className="flex items-center gap-4">
<div className="max-w-50">
<Input variant="search" placeholder="جستجو..." className="rounded-xl border-[#E9EFF4]" />
</div>
<div className="max-w-50">
<Select options={SORT_OPTIONS} placeholder="مرتب‌سازی" className="min-w-50 rounded-xl border-[#E9EFF4]" />
</div>
</div>
);
};
export default CategoryToolbar;
+31
View File
@@ -0,0 +1,31 @@
export const CATEGORY_TITLE = "جعبه";
export const CATEGORY_DESCRIPTION =
"جعبه محصول فقط یک محافظ نیست؛ اولین تجربه مشتری از برند شماست. با چاپ اختصاصی، رنگ‌ها و طراحی منحصربه‌فرد، بسته‌بندی‌ای خلق کنید که داستان برندتان را پیش از باز شدن جعبه روایت کند. جزئیات دقیق، چاپ باکیفیت و طراحی چشم‌نواز، تجربه‌ای ماندگار برای مشتریان شما می‌سازد.";
export const SORT_OPTIONS = [
{ label: "پرفروش‌ترین", value: "bestseller" },
{ label: "جدیدترین", value: "newest" },
{ label: "ارزان‌ترین", value: "cheapest" },
{ label: "گران‌ترین", value: "expensive" },
] as const;
export type FilterSection = {
id: string;
title: string;
options?: readonly string[];
defaultOpen?: boolean;
};
export const FILTER_SECTIONS: readonly FilterSection[] = [
{ id: "categories", title: "دسته بندی ها" },
{
id: "paper",
title: "جنس ورق",
defaultOpen: true,
options: ["سه لا E فلوت کرافت", "سه لا یک رو سفید", "سه لا E فلوت سفید"],
},
{ id: "price", title: "بازه قیمتی" },
];
export const CATEGORY_PRODUCTS = ["جعبه گل", "جعبه هدیه", "جعبه کفش", "جعبه کیبوردی", "جعبه گل", "جعبه هدیه", "جعبه کفش", "جعبه کیبوردی"] as const;
+30
View File
@@ -0,0 +1,30 @@
import ContactCtaSection from "@/app/home/components/ContactCtaSection";
import CategoryFilters from "./components/CategoryFilters";
import CategoryHero from "./components/CategoryHero";
import CategoryProducts from "./components/CategoryProducts";
import CategoryToolbar from "./components/CategoryToolbar";
const CategoryDetailPage = () => {
return (
<div>
<CategoryHero />
<div className="space-y-4 px-4 mt-10 sm:space-y-5 sm:px-8 lg:px-30 pt-8 sm:pt-10 pb-8 sm:pb-10">
<div className="lg:grid lg:gap-x-6">
<CategoryToolbar />
</div>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:gap-6">
<CategoryFilters />
<div className="min-w-0 flex-1">
<CategoryProducts />
</div>
</div>
</div>
<ContactCtaSection />
</div>
);
};
export default CategoryDetailPage;
+14 -8
View File
@@ -7,22 +7,28 @@ export type InputVariant = "primary" | "search";
type Props = {
variant?: InputVariant;
className?: string;
iconClassName?: string;
} & React.InputHTMLAttributes<HTMLInputElement>;
const Input: FC<Props> = (props) => {
const { variant = "primary", className, ...rest } = props;
const Input: FC<Props> = ({ variant = "primary", className, iconClassName, ...rest }) => {
return (
<div className="relative w-full" dir="rtl">
{variant === "search" && <SearchNormal aria-hidden="true" className="pointer-events-none absolute top-1/2 right-5 -translate-y-1/2 text-primary" color="currentColor" size={24} />}
{variant === "search" && (
<SearchNormal
aria-hidden="true"
className={cn("pointer-events-none absolute top-1/2 right-4 -translate-y-1/2 text-primary", iconClassName)}
color="currentColor"
size={20}
/>
)}
<input
type={variant === "search" ? "search" : "text"}
type={variant === "search" ? "search" : rest.type}
className={cn(
"h-10 w-full rounded-2xl border border-secondary bg-white px-5 text-right text-sm text-[#3A4147] placeholder:text-[#D7E0E8] transition-colors focus:border-primary",
variant === "search" && "pr-13",
"h-10 w-full rounded-2xl border border-secondary bg-white px-4 text-right text-sm text-[#3A4147] placeholder:text-[#D7E0E8] transition-colors focus:border-primary",
variant === "search" && "pr-11",
className,
)}
{...props}
{...rest}
/>
</div>
);
+2 -2
View File
@@ -8,7 +8,7 @@ export type SelectOption = {
};
type Props = {
options?: SelectOption[];
options?: readonly SelectOption[];
className?: string;
placeholder?: string;
} & Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "placeholder">;
@@ -18,7 +18,7 @@ const Select: FC<Props> = ({ options, className, placeholder, children, ...rest
<div className="relative w-full" dir="rtl">
<select
className={cn(
"h-10 w-full appearance-none rounded-2xl border border-secondary bg-white px-4 pl-13 text-right text-sm transition-colors focus:border-primary disabled:pointer-events-none disabled:opacity-50",
"h-10 w-full appearance-none rounded-2xl border border-secondary bg-white px-4 pl-11 text-right text-sm transition-colors focus:border-primary disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...rest}
+8 -4
View File
@@ -5,14 +5,18 @@ import Image from "next/image";
import { type FC } from "react";
import { Rating } from "react-simple-star-rating";
const ProductCard: FC = () => {
type ProductCardProps = {
title?: string;
};
const ProductCard: FC<ProductCardProps> = ({ title = "جعبه کیبوردی" }) => {
return (
<div>
<div className="rounded-xl overflow-hidden">
<Image src={ProductImage} alt="product-card" width={500} height={500} className="w-full aspect-square object-cover" />
<Image src={ProductImage} alt={title} width={500} height={500} className="w-full aspect-square object-cover" />
</div>
<div className="relative bg-white p-4 rounded-xl -mt-6 shadow-[0_4px_20px_rgba(0,0,0,0.1)]">
<div className="text-[#2A3950] font-bold text-center">جعبه کیبوردی</div>
<div className="text-[#2A3950] font-bold text-center">{title}</div>
<div className="mt-2 flex justify-center items-center gap-1.5">
<Rating initialValue={4.5} size={20} readonly rtl allowFraction SVGstyle={{ display: "inline-block" }} />
<div className="text-xs mt-1">4.5</div>
@@ -23,7 +27,7 @@ const ProductCard: FC = () => {
<div className="text-sm">1,000,000 تومان</div>
</div>
<div className="mt-3 px-4">
<Button variant="outline" className="h-8! w-full">
<Button variant="outline" className="h-8 w-full">
جزییات
</Button>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 775 KiB