Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f22885ae9 | |||
| fe1e545aaf |
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user