Compare commits

..

19 Commits

Author SHA1 Message Date
hamid zarghami 112aee1cdf responsive 2026-07-21 15:16:47 +03:30
hamid zarghami 14d1422970 Create skill for cursor 2026-07-21 15:01:39 +03:30
hamid zarghami 0e5fe6e932 fix problem 2026-07-21 11:37:19 +03:30
hamid zarghami 220f749598 ui product detail page 2026-07-21 11:36:50 +03:30
hamid zarghami ec39e4448d base product single page 2026-07-21 11:25:41 +03:30
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
hamid zarghami 6ae9a6a17f standalone 2026-07-19 16:46:28 +03:30
hamid zarghami 42af5a44a1 created docker file 2026-07-19 16:37:47 +03:30
hamid zarghami cd266c057c responsive home page and layout 2026-07-19 16:29:37 +03:30
hamid zarghami 2adaac8ec4 footer and first desktop version completed 2026-07-19 16:09:33 +03:30
hamid zarghami 2c9b43c84a contact section 2026-07-19 15:48:30 +03:30
hamid zarghami 36763aace0 home blog section 2026-07-19 15:35:37 +03:30
hamid zarghami 35beea16bd order-regiteration-steps 2026-07-19 15:18:24 +03:30
hamid zarghami 979281bfb1 best seller categories 2026-07-19 15:13:06 +03:30
hamid zarghami fcf89eb2a1 fix shadow 2026-07-19 12:35:46 +03:30
hamid zarghami ea7745179b orderby application section 2026-07-19 12:25:37 +03:30
hamid zarghami 9a58990cae product item + product section 2026-07-19 12:03:21 +03:30
hamid zarghami 7bb9240f21 carousel components 2026-07-18 11:25:53 +03:30
59 changed files with 1997 additions and 155 deletions
+187
View File
@@ -0,0 +1,187 @@
---
name: mehraein
description: Develop the Mehraein Persian RTL print/e-commerce Next.js app following project conventions. Use when working in this repository, adding pages or features, building UI components, or implementing product/category/home screens for the Mehraein storefront.
---
# Mehraein Project Skill
## Project Overview
Mehraein is a Persian (Farsi) RTL storefront for custom print, packaging, and promotional products (boxes, labels, business cards, etc.). UI copy is in Persian; layout is RTL throughout.
## Tech Stack
| Layer | Choice |
|-------|--------|
| Framework | Next.js 16 (App Router) |
| UI | React 19, TypeScript |
| Styling | Tailwind CSS v4 (`@import "tailwindcss"`) |
| Icons | `iconsax-reactjs` |
| Carousel | `embla-carousel-react` |
| Ratings | `react-simple-star-rating` |
| Deploy | Docker standalone (`output: "standalone"`) |
**Next.js 16:** This is NOT standard Next.js from training data. Before writing Next.js code, read the relevant guide in `node_modules/next/dist/docs/` and heed deprecation notices.
## Directory Structure
```
app/
├── components/ # Shared UI primitives (Button, Input, Carousel, GridWrapper, Select, Seprator)
├── lib/ # Utilities (cn)
├── shared/ # Layout shell (Header, Footer) + subcomponents
├── home/ # Home feature: Home.tsx + components/
├── product/ # Product feature: [id]/page.tsx + components/ + constants.ts
├── category/ # Category feature: page.tsx + components/ + constants.ts
├── layout.tsx # Root layout (Header + main + Footer)
└── globals.css # Tailwind theme tokens + RTL base styles
assets/ # Images, IRANYekan font files
public/ # Static assets
```
### Feature module pattern
Each route feature owns its folder:
1. **Page file** — thin wrapper that renders the feature root component.
2. **Feature root** — composes section components (e.g. `Home.tsx`, `ProductDetail.tsx`).
3. **`components/`** — feature-specific sections and widgets.
4. **`constants.ts`** — mock data, option lists, types, and formatters.
Do not put feature-specific logic in `app/components/`; only reusable primitives go there.
## Component Conventions
```tsx
"use client"; // only when state, events, or browser APIs are needed
import { cn } from "@/app/lib/cn";
import { type FC } from "react";
type Props = {
className?: string;
};
const MyComponent: FC<Props> = ({ className }) => {
return <div className={cn("base-classes", className)} />;
};
export default MyComponent;
```
- Default exports for components.
- Use `FC` and explicit `Props` types.
- Merge classes with `cn()` from `@/app/lib/cn`.
- Extend native HTML attribute types where appropriate (`React.ButtonHTMLAttributes`, etc.).
- Reuse existing primitives before creating new ones: `Button`, `Input`, `Carousel`, `GridWrapper`, `Select`.
## Styling & Design Tokens
Defined in `app/globals.css` via `@theme inline`:
| Token | Value | Usage |
|-------|-------|-------|
| `primary` | `#ff730b` | CTAs, accents, icons |
| `secondary` | `#f1f6fa` | Borders, backgrounds |
| `description` | `#888888` | Muted text |
| `maxWidth` | `1100px` | Content max width |
Common patterns:
- **Page horizontal padding:** `px-4 sm:px-8 lg:px-[120px]` or `lg:px-30`
- **Buttons:** `rounded-full`, height `h-10` (primary) or `h-8` (compact)
- **Cards:** `rounded-xl`, subtle shadows like `shadow-[0_4px_20px_rgba(0,0,0,0.1)]`
- **Brand blues:** `#194873`, `#2A3950`, `#3A4147`, `#6C7680`
Use Tailwind theme classes (`bg-primary`, `text-secondary`, `border-secondary`) instead of hardcoding token colors when possible.
## RTL & Persian Content
- Root layout: `<html lang="fa">`, global `direction: rtl`.
- All user-facing strings in Persian.
- Font: **IRANYekan** (loaded via `@/assets/iranyekan/fonts.css`).
- Numbers in prices use English digits via `toLocaleString("en-US")` — see `formatPrice` in `app/product/constants.ts`.
- Star ratings: pass `rtl` to `react-simple-star-rating`.
- Search inputs: icon on the right (`right-4`, `pr-11`), `text-right`, `dir="rtl"` on input wrapper.
## Images & Icons
```tsx
import logo from "@/assets/images/logo.png";
import Image from "next/image";
<Image src={logo} alt="توضیح فارسی" width={116} height={72} className="h-12 w-auto" />
```
- Static images live in `assets/images/`.
- Always provide meaningful Persian `alt` text.
- Icons from `iconsax-reactjs`; use `color="currentColor"` and size props (`size={20}`).
## Page Composition
Root layout wraps every page with `Header` and `Footer`. Pages only render `<main>` content.
Reuse cross-page sections:
- `ContactCtaSection` from `@/app/home/components/ContactCtaSection` — append at bottom of product/category pages.
Example page:
```tsx
import ProductDetail from "../components/ProductDetail";
const ProductPage = () => {
return <ProductDetail />;
};
export default ProductPage;
```
## Constants & Mock Data
Keep static content in feature `constants.ts` files:
```tsx
export const SORT_OPTIONS = [
{ label: "پرفروش‌ترین", value: "bestseller" },
] as const;
export type SortValue = (typeof SORT_OPTIONS)[number]["value"];
export function formatPrice(value: number): string {
return value.toLocaleString("en-US");
}
```
Use `as const` for option arrays. Export types derived from constants. No API layer yet — data is mocked locally.
## Path Aliases
`@/*` maps to project root. Prefer:
- `@/app/components/Button`
- `@/assets/images/...`
## Adding New Features Checklist
- [ ] Create feature folder under `app/` with `page.tsx`, root component, `components/`, and `constants.ts` if needed
- [ ] Keep page files thin; compose in feature root component
- [ ] Add `"use client"` only where interactivity is required
- [ ] Use existing shared components and design tokens
- [ ] Write UI text in Persian; respect RTL layout
- [ ] Use `next/image` for images, `iconsax-reactjs` for icons
- [ ] Match responsive spacing conventions (`px-4 sm:px-8 lg:px-[120px]`)
- [ ] Append `ContactCtaSection` on content pages when appropriate
- [ ] Run `npm run lint` after changes
## Commands
```bash
npm run dev # local development
npm run build # production build
npm run lint # ESLint
```
## Additional Resources
- For component inventory and file map, see [reference.md](reference.md)
+68
View File
@@ -0,0 +1,68 @@
# Mehraein Reference
## Shared Components (`app/components/`)
| Component | Purpose | Key props |
|-----------|---------|-----------|
| `Button` | Primary/outline CTA | `variant: "primary" \| "outline"` |
| `Input` | Text/search input | `variant: "primary" \| "search"` |
| `Carousel` | Embla image slider | — |
| `GridWrapper` | Responsive grid layout | — |
| `Select` | Dropdown select | — |
| `Seprator` | Visual divider | — |
## Layout Shell (`app/shared/`)
| File | Role |
|------|------|
| `Header.tsx` | Top bar, logo, search, nav, cart |
| `Footer.tsx` | Footer sections |
| `components/HeaderMenu.tsx` | Navigation menu |
| `components/AllProductsMenu.tsx` | Products mega-menu |
| `components/Footer*.tsx` | Footer sub-sections |
## Feature Routes
| Route | Entry | Root component |
|-------|-------|----------------|
| `/` | `app/page.tsx` | `app/home/Home.tsx` |
| `/category` | `app/category/page.tsx` | inline in page |
| `/product/[id]` | `app/product/[id]/page.tsx` | `ProductDetail` |
## Home Sections (`app/home/components/`)
`BannerSection`, `StepSection`, `CategorySection`, `ProductSection`, `OrderByApplication`, `BestSellerCategories`, `OrderRegistrationSteps`, `BlogsSection`, `ContactCtaSection`, `ProductCard`, `CategoryCard`, `BlogCard`
## Product Sections (`app/product/components/`)
`ProductDetail`, `ProductGallery`, `ProductPurchaseOptions`, `ProductOrderActions`, `ProductTabs`, `ProductRelated`, `ProductBreadcrumb`
## Category Sections (`app/category/components/`)
`CategoryHero`, `CategoryToolbar`, `CategoryFilters`, `CategoryProducts`
## Color Palette (beyond theme tokens)
| Hex | Typical use |
|-----|-------------|
| `#194873` | Body accent text |
| `#2A3950` | Card titles |
| `#3A4147` | Header/promo text |
| `#6C7680` | Secondary nav icons |
| `#D00003` | Strikethrough price |
| `#E9EEF2` | Card borders |
| `#004A9005` | Tinted info box background |
## Responsive Breakpoints
Follow Tailwind defaults (`sm`, `md`, `lg`). Common layout shifts:
- Mobile: stacked columns, compact padding
- `lg`: side-by-side layouts, wider padding (`lg:px-[120px]` or `lg:px-30`)
- Header: mobile cart/actions vs desktop full nav
## Docker Build Notes
- Multi-stage build with Liara npm mirror
- Standalone Next.js output
- Runs as non-root `appuser` on port 3000
+108
View File
@@ -0,0 +1,108 @@
# --------------------
# Base
# --------------------
FROM node:22-alpine AS base
# Change Alpine repo
RUN sed -i 's|https://dl-cdn.alpinelinux.org/alpine|https://mirror.de.velop.ir/alpine|g' /etc/apk/repositories
# Configure npm registry mirror (Liara)
ENV NPM_CONFIG_REGISTRY=https://package-mirror.liara.ir/repository/npm/
RUN npm config set registry https://package-mirror.liara.ir/repository/npm/
WORKDIR /app
# --------------------
# Dependencies
# --------------------
FROM base AS deps
# RUN apk add --no-cache libc6-compat git
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps --ignore-scripts --loglevel info
# --------------------
# Build
# --------------------
FROM base AS builder
WORKDIR /build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build && \
npm ci --omit=dev --legacy-peer-deps --ignore-scripts --loglevel info
# --------------------
# Runtime
# --------------------
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
RUN mkdir .next && chown appuser:appgroup .next
COPY --from=builder --chown=appuser:appgroup /build/.next/standalone ./
COPY --from=builder --chown=appuser:appgroup /build/.next/static ./.next/static
COPY --from=builder --chown=appuser:appgroup /build/public ./public
COPY --from=builder --chown=appuser:appgroup /build/package.json ./package.json
USER appuser
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
# FROM node:22-alpine AS base
# RUN apk add --no-cache tzdata && \
# cp /usr/share/zoneinfo/Asia/Tehran /etc/localtime && \
# echo "Asia/Tehran" > /etc/timezone
# RUN npm install -g corepack
# RUN corepack enable && corepack prepare pnpm@latest --activate
# WORKDIR /app
# FROM base AS deps
# RUN apk add --no-cache libc6-compat git
# COPY package*.json ./
# COPY pnpm-lock.yaml ./
# RUN pnpm install --frozen-lockfile --ignore-scripts
# FROM base AS builder
# WORKDIR /build
# COPY --from=deps /app/node_modules ./node_modules
# COPY . .
# RUN pnpm build && pnpm install --prod --frozen-lockfile --ignore-scripts
# FROM base AS runner
# WORKDIR /app
# ENV NODE_ENV=production
# ENV NEXT_TELEMETRY_DISABLED=1
# RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# RUN mkdir .next && chown appuser:appgroup .next
# COPY --from=builder --chown=appuser:appgroup /build/.next/standalone ./
# COPY --from=builder --chown=appuser:appgroup /build/.next/static ./.next/static
# COPY --from=builder --chown=appuser:appgroup /build/public ./public
# COPY --from=builder --chown=appuser:appgroup /build/package.json ./package.json
# USER appuser
# EXPOSE 3000
# ENV PORT=3000
# ENV HOSTNAME="0.0.0.0"
# ENV NEXT_PUBLIC_BASE_URL=https://api.danakcorp.com
# ENV NEXT_PUBLIC_TOKEN_NAME=dsc_refresh_token
# ENV NEXT_PUBLIC_TOKEN_NAME=dsc_token
# CMD ["node", "server.js"]
@@ -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 gap-6 lg:flex-row lg:items-center lg:gap-10">
<div className="min-w-0 flex-1 space-y-3 sm:space-y-4">
<h1 className="text-2xl font-bold text-[#0A1B2C] sm:text-3xl lg:text-[32px]">{CATEGORY_TITLE}</h1>
<p className="max-w-140 text-sm leading-7 text-[#0A1B2C]/80">{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-48 rounded-2xl object-cover shadow-[0px_-4px_12px_0px_rgba(0,0,0,0.12)] sm:w-60 sm:rounded-3xl lg:w-80"
/>
</div>
</div>
</section>
<nav aria-label="مسیر صفحه" className="relative z-0 flex items-center justify-start gap-2 overflow-x-auto bg-secondary px-4 py-3 text-sm whitespace-nowrap text-[#3F4D5A] sm:px-8 lg:px-30">
<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} className="sm:gap-4">
{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 flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div className="min-w-0 flex-1">
<Input variant="search" placeholder="جستجو..." className="rounded-xl border-[#E9EFF4]" />
</div>
<div className="min-w-0 w-full sm:w-auto sm:max-w-50">
<Select options={SORT_OPTIONS} placeholder="مرتب‌سازی" className="w-full rounded-xl border-[#E9EFF4] sm:min-w-50" />
</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;
+28
View File
@@ -0,0 +1,28 @@
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="px-4 pb-8 pt-8 sm:px-8 sm:pb-10 sm:pt-10 lg:px-30 lg:pb-10 lg:pt-16">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:gap-6">
<CategoryFilters />
<div className="min-w-0 flex-1 space-y-4 sm:space-y-5">
<CategoryToolbar />
<CategoryProducts />
</div>
</div>
</div>
<ContactCtaSection />
</div>
);
};
export default CategoryDetailPage;
+1 -1
View File
@@ -12,7 +12,7 @@ const Button: FC<Props> = ({ variant = "primary", className, children, ...rest }
return (
<button
className={cn(
"inline-flex h-12 items-center justify-center rounded-full px-6 text-sm transition-colors disabled:pointer-events-none disabled:opacity-50",
"inline-flex h-10 items-center justify-center rounded-full px-6 text-sm transition-colors disabled:pointer-events-none disabled:opacity-50",
variant === "primary" && "bg-primary text-white hover:bg-primary/90",
variant === "outline" && "border border-primary bg-transparent text-primary hover:bg-primary/10",
className,
+217
View File
@@ -0,0 +1,217 @@
"use client";
import { cn } from "@/app/lib/cn";
import useEmblaCarousel from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "iconsax-reactjs";
import {
createContext,
useContext,
useEffect,
useState,
type FC,
type HTMLAttributes,
type ReactNode,
} from "react";
type CarouselApi = {
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
slidesPerView: number;
gap: number;
};
type ViewportRef = (node: HTMLElement | null) => void;
const CarouselContext = createContext<CarouselApi | null>(null);
const CarouselViewportContext = createContext<ViewportRef | null>(null);
function useCarousel() {
const context = useContext(CarouselContext);
if (!context) {
throw new Error("Carousel components must be used within <Carousel>");
}
return context;
}
type ResponsiveNumber = number | { base: number; md?: number; lg?: number };
function useResponsiveNumber(value: ResponsiveNumber): number {
const base = typeof value === "number" ? value : value.base;
const md = typeof value === "number" ? undefined : value.md;
const lg = typeof value === "number" ? undefined : value.lg;
const resolve = () => {
if (typeof window === "undefined") return base;
if (window.matchMedia("(min-width: 1024px)").matches) return lg ?? md ?? base;
if (window.matchMedia("(min-width: 768px)").matches) return md ?? base;
return base;
};
const [resolved, setResolved] = useState(base);
useEffect(() => {
const update = () => setResolved(resolve());
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [base, md, lg]);
return resolved;
}
type CarouselProps = {
children: ReactNode;
slidesPerView?: ResponsiveNumber;
slidesToScroll?: number;
gap?: ResponsiveNumber;
direction?: "rtl" | "ltr";
className?: string;
};
const Carousel: FC<CarouselProps> = ({
children,
slidesPerView = 5,
slidesToScroll,
gap = 40,
direction = "rtl",
className,
}) => {
const resolvedSlidesPerView = useResponsiveNumber(slidesPerView);
const resolvedGap = useResponsiveNumber(gap);
const scrollBy = slidesToScroll ?? Math.max(1, Math.floor(resolvedSlidesPerView));
const [emblaRef, emblaApi] = useEmblaCarousel({
align: "start",
direction,
slidesToScroll: scrollBy,
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]);
useEffect(() => {
emblaApi?.reInit({ slidesToScroll: scrollBy });
}, [emblaApi, scrollBy, resolvedSlidesPerView, resolvedGap]);
return (
<CarouselContext.Provider
value={{
scrollPrev: () => emblaApi?.scrollPrev(),
scrollNext: () => emblaApi?.scrollNext(),
canScrollPrev,
canScrollNext,
slidesPerView: resolvedSlidesPerView,
gap: resolvedGap,
}}
>
<CarouselViewportContext.Provider value={emblaRef}>
<div className={className}>{children}</div>
</CarouselViewportContext.Provider>
</CarouselContext.Provider>
);
};
type CarouselControlsProps = {
className?: string;
prevLabel?: string;
nextLabel?: string;
};
const CarouselControls: FC<CarouselControlsProps> = ({
className,
prevLabel = "قبلی",
nextLabel = "بعدی",
}) => {
const { scrollPrev, scrollNext, canScrollPrev, canScrollNext } = useCarousel();
return (
<div className={cn("flex gap-2", className)}>
<button
type="button"
aria-label={prevLabel}
disabled={!canScrollPrev}
onClick={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" />
</button>
<button
type="button"
aria-label={nextLabel}
disabled={!canScrollNext}
onClick={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" />
</button>
</div>
);
};
type CarouselTrackProps = {
children: ReactNode;
className?: string;
};
const CarouselTrack: FC<CarouselTrackProps> = ({ children, className }) => {
const emblaRef = useContext(CarouselViewportContext);
const { gap } = useCarousel();
if (!emblaRef) {
throw new Error("CarouselTrack must be used within <Carousel>");
}
return (
<div className={cn("overflow-hidden", className)} ref={emblaRef}>
<div className="flex touch-pan-y" style={{ gap }}>
{children}
</div>
</div>
);
};
type CarouselSlideProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
};
const CarouselSlide: FC<CarouselSlideProps> = ({ children, className, style, ...rest }) => {
const { slidesPerView, gap } = useCarousel();
const totalGap = (slidesPerView - 1) * gap;
return (
<div
className={cn("min-w-0 shrink-0 grow-0", className)}
style={{
flexBasis: `calc((100% - ${totalGap}px) / ${slidesPerView})`,
...style,
}}
{...rest}
>
{children}
</div>
);
};
export { Carousel, CarouselControls, CarouselSlide, CarouselTrack };
export default Carousel;
+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-12 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>
);
+43
View File
@@ -0,0 +1,43 @@
import { cn } from "@/app/lib/cn";
import { ArrowDown2 } from "iconsax-reactjs";
import { FC } from "react";
export type SelectOption = {
label: string;
value: string;
};
type Props = {
options?: readonly SelectOption[];
className?: string;
placeholder?: string;
} & Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "placeholder">;
const Select: FC<Props> = ({ options, className, placeholder, children, ...rest }) => {
return (
<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-11 text-right text-sm transition-colors focus:border-primary disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...rest}
>
{placeholder && (
<option value="" disabled hidden>
{placeholder}
</option>
)}
{options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
{children}
</select>
<ArrowDown2 aria-hidden="true" className="pointer-events-none absolute top-1/2 left-4 -translate-y-1/2 text-black" color="currentColor" size={18} />
</div>
);
};
export default Select;
+23
View File
@@ -1,5 +1,11 @@
import BannerSection from "./components/BannerSection";
import BestSellerCategories from "./components/BestSellerCategories";
import BlogsSection from "./components/BlogsSection";
import CategorySection from "./components/CategorySection";
import ContactCtaSection from "./components/ContactCtaSection";
import OrderByApplication from "./components/OrderByApplication";
import OrderRegistrationSteps from "./components/OrderRegistrationSteps";
import ProductSection from "./components/ProductSection";
import StepSection from "./components/StepSection";
const Home = () => {
@@ -8,6 +14,23 @@ const Home = () => {
<BannerSection />
<StepSection />
<CategorySection />
<ProductSection />
<OrderByApplication />
<BestSellerCategories />
<OrderRegistrationSteps />
<BlogsSection />
<div className="mt-12 px-4 sm:mt-16 sm:px-8 lg:mt-[120px] lg:px-[120px]">
<div className="bg-[#004A9005] p-6 sm:p-10 lg:p-16 w-full rounded-3xl lg:rounded-4xl border border-[#E9EEF2]">
<div className="font-bold text-lg sm:text-xl lg:text-2xl">چاپ جعبه، بستهبندی و محصولات تبلیغاتی با طراحی اختصاصی</div>
<div className="mt-4 sm:mt-6 leading-6 font-bold text-[#194873] text-sm">
جعبه، اولین چیزی است که مشتری از برند شما میبیند. ما با ارائه خدمات چاپ جعبه، لیبل، استیکر، کارت ویزیت و سایر محصولات تبلیغاتی، به شما کمک میکنیم تا بستهبندی حرفهای و ماندگاری برای
محصولات خود داشته باشید. با استفاده از متریال باکیفیت، طراحی اختصاصی و چاپ دقیق، سفارش شما در کوتاهترین زمان آماده و ارسال میشود.
</div>
</div>
</div>
<ContactCtaSection />
</div>
);
};
+13 -7
View File
@@ -8,20 +8,26 @@ const BannerSection: FC = () => {
return (
<div className="w-full">
<div className="relative">
<Image src={banner} alt="banner" width={2000} height={574} className="w-full max-h-[574px] object-cover" />
<div className="absolute h-full right-[120px] top-0 bottom-0 m-auto flex flex-col justify-center">
<div className="text-base">چاپ و بسته بندی به روش هوشمندانه</div>
<div className="text-2xl font-black mt-4">مجــتمع تخصصــــــی</div>
<div className="text-2xl font-black mt-4">
<Image
src={banner}
alt="banner"
width={2000}
height={574}
className="w-full h-[420px] sm:h-[480px] lg:h-auto lg:max-h-[574px] object-cover"
/>
<div className="absolute inset-y-0 right-4 sm:right-8 lg:right-[120px] flex flex-col justify-center max-w-[min(100%,420px)] lg:max-w-none">
<div className="text-sm sm:text-base">چاپ و بسته بندی به روش هوشمندانه</div>
<div className="text-xl sm:text-2xl font-black mt-3 sm:mt-4">مجــتمع تخصصــــــی</div>
<div className="text-xl sm:text-2xl font-black mt-3 sm:mt-4">
چــــــاپ و بستــــه بندی <span className="text-primary">دستی</span> و <span className="text-primary">هوشمند</span>{" "}
</div>
<div className="mt-4 text-base flex flex-col gap-3">
<div className="mt-3 sm:mt-4 text-sm sm:text-base flex flex-col gap-2 sm:gap-3">
<div>قیمتهای عالی</div>
<div>خدمات مشتریان بینظیر</div>
<div>سیستم سفارشگیری ساده</div>
</div>
<div className="mt-4 flex gap-6 items-center">
<div className="mt-4 flex flex-wrap gap-3 sm:gap-6 items-center">
<Button>
<div className="flex gap-2.5">
<div>محصولات</div>
@@ -0,0 +1,70 @@
import { cn } from "@/app/lib/cn";
import bannersImage from "@/assets/images/best-sellers/banners.jpg";
import boxesImage from "@/assets/images/best-sellers/boxes.jpg";
import businessCardsImage from "@/assets/images/best-sellers/business-cards.jpg";
import calendarsImage from "@/assets/images/best-sellers/calendars.jpg";
import catalogsImage from "@/assets/images/best-sellers/catalogs.png";
import { ArrowLeft } from "iconsax-reactjs";
import Image, { type StaticImageData } from "next/image";
import { type FC } from "react";
type CategoryItem = {
title: string;
image: StaticImageData;
featured?: boolean;
};
const categories: CategoryItem[] = [
{ title: "جعبه ها", image: boxesImage, featured: true },
{ title: "کاتالوگ ها", image: catalogsImage },
{ title: "کارت ویزیت", image: businessCardsImage },
{ title: "تقویم ها", image: calendarsImage },
{ title: "بنرها", image: bannersImage },
];
type CategoryTileProps = {
title: string;
image: StaticImageData;
featured?: boolean;
};
const CategoryTile: FC<CategoryTileProps> = ({ title, image, featured }) => {
return (
<div
className={cn(
"relative overflow-hidden rounded-[24px] sm:rounded-[40px]",
featured ? "col-span-2 row-span-2 min-h-[220px] sm:min-h-0" : "aspect-square",
)}
>
<Image src={image} alt={title} fill className="object-cover" sizes={featured ? "(max-width: 768px) 100vw, 50vw" : "(max-width: 768px) 50vw, 25vw"} />
<div
className={cn(
"absolute inset-x-3 bottom-3 sm:inset-x-4 sm:bottom-4 flex items-center justify-between rounded-2xl sm:rounded-3xl bg-[#0A1B2C]/60 backdrop-blur-[2px]",
featured ? "px-4 py-3 sm:px-6 sm:py-3.5" : "px-3 py-2.5 sm:px-5 sm:py-3",
)}
>
<div className={cn("text-white font-bold", featured ? "text-base sm:text-lg" : "text-sm sm:text-base")}>{title}</div>
<div className="flex items-center gap-1.5 text-white">
<span className={cn("font-bold", featured ? "text-xs sm:text-sm" : "text-xs")}>مشاهده</span>
<ArrowLeft size={featured ? 20 : 18} color="currentColor" />
</div>
</div>
</div>
);
};
const BestSellerCategories: FC = () => {
return (
<div className="mt-12 px-4 sm:mt-16 sm:px-8 lg:mt-[120px] lg:px-[120px]">
<div className="font-bold text-lg sm:text-2xl">دسته بندی های پرفروش</div>
<div className="mt-8 sm:mt-12 grid grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-10">
{categories.map((category) => (
<CategoryTile key={category.title} title={category.title} image={category.image} featured={category.featured} />
))}
</div>
</div>
);
};
export default BestSellerCategories;
+21
View File
@@ -0,0 +1,21 @@
import BlogImage from "@/assets/images/blog-image.png";
import { ArrowLeft } from "iconsax-reactjs";
import Image from "next/image";
import { FC } from "react";
const BlogCard: FC = () => {
return (
<div className="bg-white shadow-[0_4px_20px_rgba(0,0,0,0.1)] p-4 rounded-3xl">
<Image src={BlogImage} alt="Blog Image" width={1000} height={1000} className="w-full h-auto rounded-2xl" />
<div className="mt-4 text-[13px] text-[#A7ADB3] font-bold">۰۴ تیر ۱۴۰۵</div>
<div className="mt-2 font-bold">راهنمای انتخاب بنر مناسب</div>
<p className="mt-2 text-sm text-[#67727C]">با انواع بنر، کاربردها و نکات مهم انتخاب آشنا شوید تا بهترین گزینه را متناسب با نیاز و بودجه خود انتخاب کنید.</p>
<div className="mt-2 flex items-center gap-2 text-primary">
<div className="text-xs font-bold">مشاهده</div>
<ArrowLeft size={16} color="currentColor" className="mt-px" />
</div>
</div>
);
};
export default BlogCard;
+18
View File
@@ -0,0 +1,18 @@
import { FC } from "react";
import BlogCard from "./BlogCard";
const BlogsSection: FC = () => {
return (
<div className="mt-12 px-4 sm:mt-16 sm:px-8 lg:mt-[120px] lg:px-[120px]">
<div className="text-lg sm:text-2xl font-bold">مقالات ما</div>
<div className="mt-8 sm:mt-12 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-10">
<BlogCard />
<BlogCard />
<BlogCard />
<BlogCard />
</div>
</div>
);
};
export default BlogsSection;
+3 -3
View File
@@ -9,9 +9,9 @@ type CategoryCardProps = {
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="w-full bg-[#D4E3F154]/33 rounded-[24px] sm:rounded-[40px] p-4 sm:p-6">
<div className="flex justify-between items-center gap-2">
<div className="text-[#21588C] font-bold text-sm sm:text-base">{title}</div>
<div className="bg-white/40 flex justify-center items-center">
<ArrowLeft size={20} color="currentColor" className="text-primary rotate-45" />
</div>
+11 -65
View File
@@ -1,8 +1,4 @@
"use client";
import { ArrowLeft, ArrowRight } from "iconsax-reactjs";
import useEmblaCarousel from "embla-carousel-react";
import { useEffect, useState } from "react";
import { Carousel, CarouselControls, CarouselSlide, CarouselTrack } from "@/app/components/Carousel";
import CategoryCard from "./CategoryCard";
const categories = [
@@ -19,72 +15,22 @@ 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">
<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" />
</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" />
</button>
</div>
<div className="mt-12 px-4 sm:mt-16 sm:px-8 lg:mt-[120px] lg:px-[120px]">
<Carousel slidesPerView={{ base: 1.4, md: 3, lg: 5 }} gap={{ base: 16, md: 24, lg: 40 }}>
<div className="flex justify-between items-center gap-4">
<div className="font-bold text-lg sm:text-2xl">دسته بندی های محصولات</div>
<CarouselControls prevLabel="دسته‌بندی‌های قبلی" nextLabel="دسته‌بندی‌های بعدی" />
</div>
<div className="mt-12 overflow-hidden" ref={emblaRef}>
<div className="flex touch-pan-y gap-10">
<CarouselTrack className="mt-8 sm:mt-12">
{categories.map((title) => (
<div
key={title}
className="min-w-0 shrink-0 grow-0 basis-[calc((100%-10rem)/5)]"
>
<CarouselSlide key={title}>
<CategoryCard title={title} />
</div>
</CarouselSlide>
))}
</div>
</div>
</CarouselTrack>
</Carousel>
</div>
);
};
+25
View File
@@ -0,0 +1,25 @@
import Button from "@/app/components/Button";
import deliveryTruckImage from "@/assets/images/delivery-truck.png";
import { CallCalling } from "iconsax-reactjs";
import Image from "next/image";
import { type FC } from "react";
const ContactCtaSection: FC = () => {
return (
<div className="mt-12 sm:mt-16 lg:mt-[120px] flex flex-col sm:flex-row items-center justify-center gap-4 sm:gap-6 bg-linear-to-l from-primary/10 to-transparent px-4 py-8 sm:py-10">
<div className="flex items-center gap-3 text-center sm:text-right">
<Image src={deliveryTruckImage} alt="" width={40} height={40} className="size-10 object-contain shrink-0" />
<p className="text-base sm:text-xl font-bold text-[#0A1B2C] sm:whitespace-nowrap">
ارسال سریع به سراسر ایران | مشاوره رایگان قبل از سفارش
</p>
</div>
<Button className="gap-2 ps-5 pe-6 font-bold shrink-0">
<CallCalling size={20} color="currentColor" variant="Linear" />
تماس با ما
</Button>
</div>
);
};
export default ContactCtaSection;
@@ -0,0 +1,45 @@
import Carousel, { CarouselControls, CarouselSlide, CarouselTrack } from "@/app/components/Carousel";
import Select from "@/app/components/Select";
import { FC } from "react";
import ProductCard from "./ProductCard";
const OrderByApplication: FC = () => {
return (
<div className="px-4 py-12 sm:px-8 sm:py-16 lg:p-[120px] bg-[#F1F6FA] mt-12 sm:mt-16 lg:mt-[120px]">
<div className="text-lg sm:text-2xl font-bold">سفارش بر اساس کاربرد</div>
<Carousel slidesPerView={{ base: 1.4, md: 3, lg: 5 }} gap={{ base: 16, md: 24, lg: 40 }}>
<div className="flex mt-8 sm:mt-12 justify-between items-center gap-4">
<div className="min-w-0 flex-1 max-w-[240px] sm:max-w-none sm:flex-none">
<Select
options={[
{
label: "محبوب‌ترین محصولات",
value: "popular",
},
{
label: "جدیدترین محصولات",
value: "newest",
},
{
label: "پرفروش‌ترین محصولات",
value: "best_selling",
},
]}
/>
</div>
<CarouselControls prevLabel="محصولات قبلی" nextLabel="محصولات بعدی" />
</div>
<CarouselTrack className="mt-8 sm:mt-12 px-2 sm:px-3 py-4 -mx-2 sm:-mx-3">
{Array.from({ length: 10 }).map((_, index) => (
<CarouselSlide key={index}>
<ProductCard />
</CarouselSlide>
))}
</CarouselTrack>
</Carousel>
</div>
);
};
export default OrderByApplication;
@@ -0,0 +1,20 @@
import ImageSrc from "@/assets/images/OrderRegistrationSteps.png";
import Image from "next/image";
import { FC } from "react";
const OrderRegistrationSteps: FC = () => {
return (
<div className="px-4 py-12 sm:px-8 sm:py-16 lg:p-[120px] bg-[#F1F6FA] mt-12 sm:mt-16 lg:mt-[120px]">
<div className="text-lg sm:text-2xl font-bold">سفارش بر اساس کاربرد</div>
<Image
src={ImageSrc}
alt="Order Registration Steps"
width={1000}
height={1000}
className="w-full mt-8 sm:mt-12 h-auto object-contain"
/>
</div>
);
};
export default OrderRegistrationSteps;
+39
View File
@@ -0,0 +1,39 @@
"use client";
import Button from "@/app/components/Button";
import ProductImage from "@/assets/images/product_image.png";
import Image from "next/image";
import { type FC } from "react";
import { Rating } from "react-simple-star-rating";
type ProductCardProps = {
title?: string;
};
const ProductCard: FC<ProductCardProps> = ({ title = "جعبه کیبوردی" }) => {
return (
<div>
<div className="rounded-xl overflow-hidden">
<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">{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>
</div>
<div className="mt-2 text-[#0A1B2C8F]/56 text-sm text-center">قیمت از </div>
<div className="flex gap-1.5 items-center justify-center">
<div className="text-[#0A1B2C8F]/56 line-through decoration-[#D00003] text-[10px]">10,000,000</div>
<div className="text-sm">1,000,000 تومان</div>
</div>
<div className="mt-3 px-4">
<Button variant="outline" className="h-8 w-full">
جزییات
</Button>
</div>
</div>
</div>
);
};
export default ProductCard;
+26
View File
@@ -0,0 +1,26 @@
import { Carousel, CarouselControls, CarouselSlide, CarouselTrack } from "@/app/components/Carousel";
import { type FC } from "react";
import ProductCard from "./ProductCard";
const ProductSection: FC = () => {
return (
<div className="mt-12 px-4 sm:mt-16 sm:px-8 lg:mt-[120px] lg:px-[120px]">
<Carousel slidesPerView={{ base: 1.4, md: 3, lg: 5 }} gap={{ base: 16, md: 24, lg: 40 }}>
<div className="flex justify-between items-center gap-4">
<div className="font-bold text-lg sm:text-2xl">محبوبترین محصولات</div>
<CarouselControls prevLabel="محصولات قبلی" nextLabel="محصولات بعدی" />
</div>
<CarouselTrack className="mt-8 sm:mt-12 px-2 sm:px-3 py-4 -mx-2 sm:-mx-3">
{Array.from({ length: 10 }).map((_, index) => (
<CarouselSlide key={index}>
<ProductCard />
</CarouselSlide>
))}
</CarouselTrack>
</Carousel>
</div>
);
};
export default ProductSection;
+16 -23
View File
@@ -1,32 +1,25 @@
import { ColorSwatch, Element4, LampCharge, Magicpen, Setting2, TruckFast } from "iconsax-reactjs";
import { FC } from "react";
const steps = [
{ label: "تحلیل نیاز برند", Icon: LampCharge },
{ label: "طراحی و آماده‌سازی فایل", Icon: Magicpen },
{ label: "انتخاب فناوری و متریال", Icon: Element4 },
{ label: "کنترل رنگ و نمونه‌گیری", Icon: ColorSwatch },
{ label: "عملیات تکمیلی", Icon: Setting2 },
{ label: "تحویل به موقع", Icon: TruckFast },
];
const StepSection: FC = () => {
return (
<div className="w-full px-[120px] bg-secondary flex justify-between items-center h-[80px]">
<div className="flex gap-4 3tems-center">
<LampCharge variant="Bulk" size={24} color="currentColor" className="text-primary" />
<div className="font-bold text-sm">تحلیل نیاز برند</div>
<div className="w-full px-4 sm:px-8 lg:px-[120px] bg-secondary">
<div className="flex gap-6 lg:gap-4 lg:justify-between items-center h-auto py-4 lg:py-0 lg:h-[80px] overflow-x-auto scrollbar-none [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{steps.map(({ label, Icon }) => (
<div key={label} className="flex gap-3 items-center shrink-0">
<Icon variant="Bulk" size={24} color="currentColor" className="text-primary" />
<div className="font-bold text-sm whitespace-nowrap">{label}</div>
</div>
<div className="flex gap-3 items-center">
<Magicpen variant="Bulk" size={24} color="currentColor" className="text-primary" />
<div className="font-bold text-sm">طراحی و آمادهسازی فایل</div>
</div>
<div className="flex gap-3 items-center">
<Element4 variant="Bulk" size={24} color="currentColor" className="text-primary" />
<div className="font-bold text-sm">انتخاب فناوری و متریال</div>
</div>
<div className="flex gap-4 i3ems-center">
<ColorSwatch variant="Bulk" size={24} color="currentColor" className="text-primary" />
<div className="font-bold text-sm">کنترل رنگ و نمونهگیری</div>
</div>
<div className="flex gap-3 items-center">
<Setting2 variant="Bulk" size={24} color="currentColor" className="text-primary" />
<div className="font-bold text-sm">عملیات تکمیلی</div>
</div>
<div className="flex gap-43items-center">
<TruckFast variant="Bulk" size={24} color="currentColor" className="text-primary" />
<div className="font-bold text-sm">تحویل به موقع</div>
))}
</div>
</div>
);
+5 -3
View File
@@ -2,6 +2,7 @@ import "@/assets/iranyekan/fonts.css";
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Footer from "./shared/Footer";
import Header from "./shared/Header";
const geistSans = Geist({
@@ -25,10 +26,11 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">
<html lang="fa" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="flex min-h-full flex-col">
<Header />
{children}
<main className="flex-1">{children}</main>
<Footer />
</body>
</html>
);
+7
View File
@@ -0,0 +1,7 @@
import ProductDetail from "../components/ProductDetail";
const ProductPage = () => {
return <ProductDetail />;
};
export default ProductPage;
@@ -0,0 +1,25 @@
import { ArrowLeft2 } from "iconsax-reactjs";
import Link from "next/link";
import { type FC, Fragment } from "react";
import { BREADCRUMB_ITEMS } from "../constants";
const ProductBreadcrumb: FC = () => {
return (
<nav aria-label="مسیر صفحه" className="flex items-center justify-start gap-2 overflow-x-auto bg-secondary px-4 py-3 text-sm whitespace-nowrap text-[#3F4D5A] sm:px-8 lg:px-30">
{BREADCRUMB_ITEMS.map((item, index) => (
<Fragment key={item.label}>
{index > 0 && <ArrowLeft2 size={14} color="currentColor" className="shrink-0" />}
{"href" in item ? (
<Link href={item.href} className="transition-colors hover:text-primary">
{item.label}
</Link>
) : (
<span className="font-bold text-[#0F2438]">{item.label}</span>
)}
</Fragment>
))}
</nav>
);
};
export default ProductBreadcrumb;
+39
View File
@@ -0,0 +1,39 @@
"use client";
import ContactCtaSection from "@/app/home/components/ContactCtaSection";
import { useState, type FC } from "react";
import { PRODUCT_GALLERY_IMAGES, PRODUCT_TITLE, QUANTITY_TIERS, type QuantityTier } from "../constants";
import ProductBreadcrumb from "./ProductBreadcrumb";
import ProductGallery from "./ProductGallery";
import ProductOrderActions from "./ProductOrderActions";
import ProductPurchaseOptions from "./ProductPurchaseOptions";
import ProductRelated from "./ProductRelated";
import ProductTabs from "./ProductTabs";
const ProductDetail: FC = () => {
const [selectedTier, setSelectedTier] = useState<QuantityTier>(QUANTITY_TIERS[0]);
return (
<>
<ProductBreadcrumb />
<div className="pb-28 lg:pb-0">
<div className="px-4 py-6 sm:px-8 sm:py-8 lg:px-30">
<div className="flex flex-col items-start gap-6 sm:gap-8 lg:flex-row lg:items-start lg:gap-10">
<ProductGallery images={PRODUCT_GALLERY_IMAGES} alt={PRODUCT_TITLE} />
<div className="flex w-full min-w-0 flex-1 flex-col">
<ProductPurchaseOptions selectedTier={selectedTier} onSelectTier={setSelectedTier} />
<ProductOrderActions selectedTier={selectedTier} />
</div>
</div>
</div>
<ProductTabs />
<ProductRelated />
<ContactCtaSection />
</div>
</>
);
};
export default ProductDetail;
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { cn } from "@/app/lib/cn";
import Image, { type StaticImageData } from "next/image";
import { useState, type FC } from "react";
type ProductGalleryProps = {
images: readonly StaticImageData[];
alt: string;
};
const ProductGallery: FC<ProductGalleryProps> = ({ images, alt }) => {
const [activeIndex, setActiveIndex] = useState(0);
return (
<div className="flex w-full shrink-0 flex-col gap-3 sm:gap-4 lg:w-[45%] lg:max-w-120">
<div className="relative aspect-square w-full overflow-hidden rounded-xl bg-[#F7FAFC] sm:rounded-2xl">
<Image src={images[activeIndex]} alt={alt} fill priority className="object-contain p-4" sizes="(max-width: 1024px) 100vw, 480px" />
</div>
<div className="grid grid-cols-4 gap-2 sm:gap-2.5">
{images.slice(0, 4).map((image, index) => (
<button
key={index}
type="button"
aria-label={`تصویر ${index + 1}`}
aria-pressed={activeIndex === index}
onClick={() => setActiveIndex(index)}
className={cn(
"relative aspect-square overflow-hidden rounded-xl border-2 bg-[#F7FAFC] transition-colors",
activeIndex === index ? "border-primary" : "border-transparent",
)}
>
<Image src={image} alt="" fill className="object-contain p-1" sizes="(max-width: 1024px) 22vw, 96px" />
</button>
))}
</div>
</div>
);
};
export default ProductGallery;
@@ -0,0 +1,41 @@
"use client";
import Button from "@/app/components/Button";
import Select from "@/app/components/Select";
import { formatPrice, PRODUCT_PROFIT_PERCENT, STICKY_QUANTITY_OPTIONS, type QuantityTier } from "../constants";
type ProductOrderActionsProps = {
selectedTier: QuantityTier;
};
const ProductOrderActions = ({ selectedTier }: ProductOrderActionsProps) => {
return (
<div className="fixed inset-x-0 bottom-0 z-40 flex w-full flex-col gap-3 rounded-t-2xl bg-white px-4 py-3 shadow-[0px_-4px_34px_0px_#1A456C14] sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:px-8 sm:py-4 lg:relative lg:inset-auto lg:mt-5 lg:gap-5 lg:rounded-t-2xl lg:px-5 lg:shadow-[0px_-4px_34px_0px_#1A456C14]">
<div className="space-y-1 text-right">
<p className="text-base font-bold text-[#0A1B2C] sm:text-lg">{formatPrice(selectedTier.totalPrice)}</p>
<div className="flex flex-wrap items-center justify-end gap-2">
<span className="text-xs text-[#0A1B2C] sm:text-sm">{formatPrice(selectedTier.unitPrice)} به ازای هر عدد</span>
<span className="rounded-lg bg-[#00893E] px-2 py-0.5 text-[10px] font-medium text-white sm:text-xs">{PRODUCT_PROFIT_PERCENT}٪ سود</span>
</div>
</div>
<div className="hidden h-16 w-0 shrink-0 border-s-2 border-primary lg:block" />
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-4 lg:gap-5">
<div className="w-full space-y-1 sm:w-32">
<span className="block text-right text-xs font-medium text-[#0A1B2C]">تعداد</span>
<Select options={[...STICKY_QUANTITY_OPTIONS]} defaultValue="1000" className="h-10 rounded-xl border-[#E9EEF2] text-sm" />
</div>
<div className="flex w-full flex-col gap-1.5 sm:w-44">
<Button className="h-10 w-full text-sm font-bold">انتخاب روش طراحی</Button>
<Button variant="outline" className="h-10 w-full text-sm font-bold">
استعلام قیمت
</Button>
</div>
</div>
</div>
);
};
export default ProductOrderActions;
@@ -0,0 +1,98 @@
"use client";
import Select from "@/app/components/Select";
import { cn } from "@/app/lib/cn";
import { InfoCircle } from "iconsax-reactjs";
import { type FC } from "react";
import { Rating } from "react-simple-star-rating";
import { BOX_BAND_OPTIONS, formatPrice, PAPER_TYPE_OPTIONS, PRODUCT_PACKAGE_SIZE, PRODUCT_RATING, PRODUCT_SHORT_DESCRIPTION, PRODUCT_TITLE, QUANTITY_TIERS, type QuantityTier } from "../constants";
type ProductPurchaseOptionsProps = {
selectedTier: QuantityTier;
onSelectTier: (tier: QuantityTier) => void;
};
const ProductPurchaseOptions: FC<ProductPurchaseOptionsProps> = ({ selectedTier, onSelectTier }) => {
return (
<div className="flex w-full min-w-0 flex-1 flex-col gap-4">
<div className="w-full space-y-2 text-right">
<h1 className="text-xl font-bold text-[#0A1B2C] sm:text-2xl">{PRODUCT_TITLE}</h1>
<div className="flex items-center justify-end gap-2">
<Rating initialValue={PRODUCT_RATING} size={16} readonly rtl allowFraction SVGstyle={{ display: "inline-block" }} />
<span className="text-sm font-medium text-[#0A1B2C]">{PRODUCT_RATING}</span>
</div>
</div>
<p className="w-full whitespace-pre-line text-right text-xs leading-6 text-[#0A1B2C] sm:text-sm sm:leading-7">{PRODUCT_SHORT_DESCRIPTION}</p>
<div className="w-full space-y-0.5 text-right">
<p className="text-base font-bold text-[#0A1B2C] sm:text-lg">{formatPrice(selectedTier.totalPrice)}</p>
<p className="text-xs text-[#4D4D4D] sm:text-sm">
{formatPrice(selectedTier.unitPrice)} برای هر عدد ( بسته {formatPrice(PRODUCT_PACKAGE_SIZE)} عددی)
</p>
</div>
<div className="w-full space-y-3">
<Field label="جنس ورق">
<Select options={[...PAPER_TYPE_OPTIONS]} defaultValue={PAPER_TYPE_OPTIONS[0].value} className="h-10 rounded-xl border-[#E9EEF2] text-sm" />
</Field>
<Field label="بند جعبه">
<Select options={[...BOX_BAND_OPTIONS]} defaultValue={BOX_BAND_OPTIONS[0].value} className="h-10 rounded-xl border-[#E9EEF2] text-sm" />
</Field>
<div className="space-y-1.5">
<span className="block text-right text-xs font-medium text-[#0A1B2C] sm:text-sm">تعداد</span>
<div className="space-y-1.5">
{QUANTITY_TIERS.map((tier) => {
const isSelected = selectedTier.quantity === tier.quantity;
return (
<button
key={tier.quantity}
type="button"
onClick={() => onSelectTier(tier)}
className={cn(
"flex min-h-10 w-full items-center justify-between gap-1.5 rounded-xl border border-[#E9EEF2] px-2.5 transition-colors sm:gap-2 sm:px-3",
isSelected ? "bg-secondary" : "bg-white hover:bg-secondary/50",
)}
>
<span className="hidden w-6 shrink-0 text-end text-xs font-medium text-[#0A1B2C] sm:inline">{tier.quantity}</span>
<div className="flex min-w-0 flex-1 items-center justify-between gap-1.5 sm:justify-center sm:gap-2">
<span className="text-[10px] text-[#0A1B2C] sm:text-xs">{formatPrice(tier.unitPrice)} / هر عدد</span>
<span className="text-[11px] font-medium text-[#0A1B2C] sm:text-xs">{formatPrice(tier.totalPrice)}</span>
</div>
<span className={cn("w-8 shrink-0 text-start text-[10px] font-medium sm:w-6 sm:text-xs", tier.discount ? "text-[#DC0000]" : "text-[#0A1B2C]")}>
{tier.discount ? `-${tier.discount}%` : tier.quantity}
</span>
</button>
);
})}
</div>
<button type="button" className="flex items-center gap-1 text-[11px] text-[#21588C] sm:text-xs">
<InfoCircle size={14} color="currentColor" variant="Linear" />
تعداد بیشتر
</button>
</div>
</div>
</div>
);
};
type FieldProps = {
label: string;
children: React.ReactNode;
};
const Field: FC<FieldProps> = ({ label, children }) => (
<div className="space-y-1.5">
<span className="block text-right text-xs font-medium text-[#0A1B2C] sm:text-sm">{label}</span>
{children}
</div>
);
export default ProductPurchaseOptions;
+27
View File
@@ -0,0 +1,27 @@
import { Carousel, CarouselControls, CarouselSlide, CarouselTrack } from "@/app/components/Carousel";
import ProductCard from "@/app/home/components/ProductCard";
import { type FC } from "react";
import { RELATED_PRODUCTS } from "../constants";
const ProductRelated: FC = () => {
return (
<section className="mt-8 px-4 sm:mt-10 sm:px-8 lg:mt-12 lg:px-30">
<Carousel slidesPerView={{ base: 1.4, md: 3, lg: 5 }} gap={{ base: 12, md: 16, lg: 24 }}>
<div className="flex items-center justify-between gap-4">
<h2 className="text-lg font-bold text-[#0A1B2C] sm:text-xl">محصولات مرتبط</h2>
<CarouselControls prevLabel="محصولات قبلی" nextLabel="محصولات بعدی" />
</div>
<CarouselTrack className="mt-6 px-2 py-4 -mx-2 sm:mt-8">
{RELATED_PRODUCTS.map((title) => (
<CarouselSlide key={title}>
<ProductCard title={title} />
</CarouselSlide>
))}
</CarouselTrack>
</Carousel>
</section>
);
};
export default ProductRelated;
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { cn } from "@/app/lib/cn";
import productImage from "@/assets/images/product_image.png";
import Image from "next/image";
import { useState, type FC } from "react";
import { PRODUCT_DESCRIPTION_CONTENT, PRODUCT_TABS, type ProductTabId } from "../constants";
const ProductTabs: FC = () => {
const [activeTab, setActiveTab] = useState<ProductTabId>("description");
return (
<section className="mt-8 sm:mt-10">
<div className="px-4 sm:px-8 lg:px-30">
<div className="overflow-x-auto border-b border-[#E9EEF2]">
<div className="flex min-w-max items-end justify-start gap-6 sm:gap-8 lg:gap-10">
{PRODUCT_TABS.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button key={tab.id} type="button" onClick={() => setActiveTab(tab.id)} className="relative shrink-0">
<span className={cn("block pb-3 text-sm leading-5 transition-colors", isActive ? "font-bold text-[#0A1B2C]" : "font-normal text-[#3F4D5A]")}>{tab.label}</span>
{isActive && <span className="absolute bottom-0 left-1/2 h-0.75 w-[72%] -translate-x-1/2 rounded-full bg-primary" />}
</button>
);
})}
</div>
</div>
<div className="mt-6 rounded-2xl bg-[#F7FAFC] p-5 sm:mt-8 sm:p-6 lg:p-8">
{activeTab === "description" && <DescriptionPanel />}
{activeTab !== "description" && <p className="py-6 text-center text-xs text-[#53606B] sm:text-sm">محتوای این بخش بهزودی اضافه میشود.</p>}
</div>
</div>
</section>
);
};
const DescriptionPanel: FC = () => {
const { title, bullets, sections } = PRODUCT_DESCRIPTION_CONTENT;
return (
<div className="flex flex-col items-center gap-6 lg:flex-row lg:items-center lg:gap-12" dir="ltr">
<div className="mx-auto w-full max-w-85 shrink-0 rounded-2xl bg-white p-3 shadow-[0_4px_20px_rgba(0,0,0,0.08)] sm:max-w-90 lg:max-w-100 lg:p-4">
<div className="relative aspect-square w-full overflow-hidden rounded-xl">
<Image src={productImage} alt="" fill className="object-cover" sizes="(max-width: 1024px) 80vw, 400px" />
</div>
</div>
<div className="min-w-0 flex-1 space-y-4 text-right" dir="rtl">
<h2 className="text-sm font-bold leading-6 text-[#0A1B2C] sm:text-base sm:leading-7">{title}</h2>
<ul className="space-y-1.5 text-xs leading-6 text-[#0A1B2C] sm:text-sm sm:leading-7">
{bullets.map((item) => (
<li key={item} className="flex items-start gap-2">
<span className="mt-2 size-1 shrink-0 rounded-full bg-[#0A1B2C]" />
<span>{item}</span>
</li>
))}
</ul>
{sections.map((section) => (
<div key={section.title} className="space-y-1.5">
<h3 className="text-xs font-bold leading-6 text-[#0A1B2C] sm:text-sm sm:leading-7">{section.title}</h3>
<p className="text-xs leading-6 text-[#0A1B2C] sm:text-sm sm:leading-7">{section.body}</p>
</div>
))}
</div>
</div>
);
};
export default ProductTabs;
+99
View File
@@ -0,0 +1,99 @@
import productImage from "@/assets/images/product_image.png";
export const PRODUCT_TITLE = "جعبه کیبوردی آفتاب";
export const PRODUCT_SHORT_DESCRIPTION =
"جعبه کیبوردی، بسته‌بندی مطمئن و حرفه‌ای\nبا جعبه‌های کیبوردی اختصاصی، محصولات خود را با ظاهری شیک و مقاوم ارائه دهید. قابل سفارش در ابعاد مختلف، جنس‌های متنوع و چاپ اختصاصی با کیفیت بالا.";
export const PRODUCT_BASE_PRICE = 20_000_000;
export const PRODUCT_UNIT_PRICE = 2_000;
export const PRODUCT_PACKAGE_SIZE = 1_000;
export const PRODUCT_RATING = 4.5;
export const PRODUCT_PROFIT_PERCENT = 20;
export const BREADCRUMB_ITEMS = [
{ label: "صفحه اصلی", href: "/" },
{ label: "جعبه", href: "/category" },
{ label: PRODUCT_TITLE },
] as const;
export const PAPER_TYPE_OPTIONS = [
{ label: "سه لا E فلوت کرافت", value: "craft-e-flute" },
{ label: "سه لا یک رو سفید", value: "white-single" },
{ label: "سه لا E فلوت سفید", value: "white-e-flute" },
] as const;
export const BOX_BAND_OPTIONS = [
{ label: "بند دارد", value: "with-band" },
{ label: "بدون بند", value: "without-band" },
] as const;
export const STICKY_QUANTITY_OPTIONS = [
{ label: "۵۰ عدد", value: "50" },
{ label: "۱۰۰ عدد", value: "100" },
{ label: "۲۰۰ عدد", value: "200" },
{ label: "۳۰۰ عدد", value: "300" },
{ label: "۱۰۰۰ عدد", value: "1000" },
] as const;
export type QuantityTier = {
quantity: number;
unitPrice: number;
totalPrice: number;
discount?: number;
};
export const QUANTITY_TIERS: readonly QuantityTier[] = [
{ quantity: 50, unitPrice: 2_000, totalPrice: 1_000_000 },
{ quantity: 100, unitPrice: 1_900, totalPrice: 2_000_000, discount: 20 },
{ quantity: 200, unitPrice: 1_800, totalPrice: 4_000_000, discount: 23 },
{ quantity: 300, unitPrice: 1_700, totalPrice: 6_000_000, discount: 25 },
];
export const PRODUCT_GALLERY_IMAGES = [productImage, productImage, productImage, productImage, productImage] as const;
export const PRODUCT_TABS = [
{ id: "description", label: "توضیحات" },
{ id: "features", label: "ویژگی ها" },
{ id: "template", label: "قالب و مشخصات" },
{ id: "portfolio", label: "نمونه کار ها" },
{ id: "faq", label: "سوالات متدوال" },
] as const;
export type ProductTabId = (typeof PRODUCT_TABS)[number]["id"];
export const PRODUCT_DESCRIPTION_CONTENT = {
title: "جعبه کیبوردی؛ بسته‌بندی حرفه‌ای برای محافظت و معرفی برند",
bullets: [
"تولید در ابعاد استاندارد و سفارشی",
"چاپ اختصاصی یک‌رو یا دورو",
"قابل تولید با مقوای ایندربرد، پشت طوسی، کرافت و گلاسه",
"امکان استفاده از روکش سلفون مات، براق یا مخملی",
"قابلیت اجرای طلاکوب، نقره‌کوب، یووی موضعی و برجسته‌سازی",
"برش و قالب اختصاصی متناسب با محصول",
"مناسب برای انواع محصولات فروشگاهی، آرایشی، غذایی، پوشاک، الکترونیکی و هدایای تبلیغاتی",
],
sections: [
{
title: "بسته‌بندی که ارزش محصول شما را افزایش می‌دهد",
body: "جعبه کیبوردی یکی از پرکاربردترین انواع بسته‌بندی برای محصولات مختلف است. این نوع جعبه علاوه بر محافظت از محصول در برابر آسیب، ظاهری حرفه‌ای و جذاب به آن می‌بخشد. طراحی اختصاصی، چاپ باکیفیت و استفاده از متریال مناسب باعث می‌شود مشتری از همان اولین نگاه، کیفیت برند شما را احساس کند.",
},
{
title: "کاملاً سفارشی، متناسب با هویت برند شما",
body: "جعبه کیبوردی را دقیقاً مطابق نیاز کسب‌وکار خود طراحی کنید. ابعاد، نوع مقوا، روکش، رنگ، چاپ، قالب و خدمات تکمیلی همگی قابل شخصی‌سازی هستند تا بسته‌بندی نهایی کاملاً با هویت برند و نوع محصول شما هماهنگ باشد. نتیجه، بسته‌بندی‌ای است که علاوه بر محافظت از محصول، به ماندگاری نام برند شما در ذهن مشتری کمک می‌کند.",
},
],
};
export const RELATED_PRODUCTS = [
"بروشور",
"کارت ویزیت",
"پوستر",
"کتابچه",
"کاتالوگ",
"جعبه کیبوردی",
] as const;
export function formatPrice(value: number): string {
return value.toLocaleString("en-US");
}
+46
View File
@@ -0,0 +1,46 @@
import Seprator from "@/app/components/Seprator";
import { type FC } from "react";
import FooterContact from "./components/FooterContact";
import FooterLinkColumn from "./components/FooterLinkColumn";
import FooterNewsletter from "./components/FooterNewsletter";
import FooterSocial from "./components/FooterSocial";
import FooterTrustBadges from "./components/FooterTrustBadges";
import FooterTrustValues from "./components/FooterTrustValues";
const guideLinks = ["سوالات متداول", "راهنمای ثبت سفارش", "راهنمای طراحی فایل", "روش‌های ارسال", "شیوه‌های پرداخت", "استعلام قیمت"];
const serviceLinks = ["درباره ما", "تماس با ما", "قوانین و مقررات", "حریم خصوصی", "شرایط ثبت سفارش", "فرصت‌های همکاری"];
const infoLinks = ["پیگیری سفارش", "باشگاه مشتریان", "بلاگ و مقالات", "پیشنهادهای ویژه", "همکاری با ما", "نقشه سایت"];
const categoryLinks = ["چاپ جعبه و بسته‌بندی", "جعبه هاردباکس", "چاپ لیبل و برچسب", "چاپ استیکر", "چاپ ساک دستی", "چاپ کارت ویزیت", "چاپ تراکت و بروشور"];
const Footer: FC = () => {
return (
<footer className="mt-auto border-t border-[#E9EEF2] bg-[#F7F9FB]">
<div className="px-4 sm:px-8 lg:px-[120px] pt-8 sm:pt-12 pb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:flex xl:justify-between gap-8 xl:gap-6">
<FooterContact />
<FooterLinkColumn title="راهنمای خرید" links={guideLinks} />
<FooterLinkColumn title="خدمات مشتریان" links={serviceLinks} />
<FooterLinkColumn title="اطلاعات" links={infoLinks} />
<FooterLinkColumn title="دسته‌بندی محصولات" links={categoryLinks} />
<FooterTrustValues />
</div>
<Seprator className="my-6 sm:my-8 h-px w-full" />
<div className="flex flex-col lg:flex-row items-center lg:items-start justify-between gap-8">
<FooterSocial />
<Seprator className="hidden lg:block h-16 w-px shrink-0" />
<FooterTrustBadges />
<Seprator className="hidden lg:block h-16 w-px shrink-0" />
<FooterNewsletter />
</div>
<Seprator className="my-6 sm:my-8 h-px w-full" />
<p className="text-center text-xs text-[#6C7680] leading-5 px-2">تمامی حقوق برای سایت نیکوپکجینگ بوده و با ذکر نام لینک به منبع مجاز میباشد.</p>
</div>
</footer>
);
};
export default Footer;
+34 -15
View File
@@ -8,33 +8,51 @@ import HeaderMenu from "./components/HeaderMenu";
const Header = () => {
return (
<div>
<div className="h-12 bg-secondary flex items-center justify-between px-[120px]">
<div className="flex gap-2 items-center">
<span className="text-primary flex items-center gap-2">
<Truck size={24} color="currentColor" />
<div className="text-[#3A4147] text-sm font-bold">ارسال رایگان برای خرید بالای ۱۰۰ میلیون تومان</div>
<div className="min-h-12 bg-secondary flex items-center justify-between gap-3 px-4 sm:px-8 lg:px-[120px] py-2">
<div className="flex gap-2 items-center min-w-0">
<span className="text-primary flex items-center gap-2 min-w-0">
<Truck size={20} color="currentColor" className="shrink-0 sm:size-6" />
<div className="text-[#3A4147] text-xs sm:text-sm font-bold truncate">ارسال رایگان برای خرید بالای ۱۰۰ میلیون تومان</div>
</span>
</div>
<div className="flex text-sm font-bold gap-2 items-center">
<div className="hidden md:flex text-sm font-bold gap-2 items-center shrink-0">
<span className="text-primary">20%</span>
<span>تخفیف برای اولین سفارش خود دریافت کنید |</span>
<span className="hidden lg:inline">تخفیف برای اولین سفارش خود دریافت کنید |</span>
<span>
کد تخفیف : <span className="text-primary">FIRSTORDER</span>
</span>
</div>
<div className="flex gap-2 items-center">
<Profile className="text-primary" size={24} color="currentColor" />
<div>سلام حمید</div>
<div className="flex gap-2 items-center shrink-0">
<Profile className="text-primary" size={20} color="currentColor" />
<div className="hidden sm:block text-sm">سلام حمید</div>
</div>
</div>
<div className="bg-white h-[120px] flex gap-16 items-center border border-secondary px-[120px]">
<Image src={logo} alt="logo" width={116} height={72} />
<div className="flex-1">
<div className="bg-white flex flex-col gap-4 lg:flex-row lg:gap-16 lg:items-center lg:h-[120px] border border-secondary px-4 sm:px-8 lg:px-[120px] py-4 lg:py-0">
<div className="flex items-center justify-between gap-4 lg:contents">
<Image src={logo} alt="logo" width={116} height={72} className="h-12 w-auto sm:h-[72px] shrink-0" />
<div className="flex gap-4 items-center lg:hidden">
<button type="button" aria-label="پیگیری سفارش" className="text-[#6C7680]">
<TruckFast variant="Bold" color="currentColor" size={22} />
</button>
<button type="button" aria-label="تماس" className="text-[#6C7680]">
<Call variant="Bold" color="currentColor" size={22} />
</button>
<div className="relative">
<ShoppingCart variant="Bold" color="black" size={22} />
<div className="absolute -top-1.5 -right-1.5 bg-primary text-white text-xs font-medium rounded-full size-4 flex items-center justify-center">0</div>
</div>
</div>
</div>
<div className="flex-1 w-full">
<Input variant="search" placeholder="جستجوی محصول" />
</div>
<div className="flex gap-8 items-center">
<div className="hidden lg:flex gap-8 items-center shrink-0">
<div className="flex gap-2 items-center">
<TruckFast variant="Bold" color="black" />
<div className="text-[#6C7680] text-sm">پیگیری سفارش</div>
@@ -44,7 +62,7 @@ const Header = () => {
<div className="flex gap-2 items-center">
<Call variant="Bold" color="black" />
<div className="text-[#6C7680] text-sm">(30 خط) 02134782000</div>
<div className="text-[#6C7680] text-sm whitespace-nowrap">(30 خط) 02134782000</div>
</div>
<Seprator className="h-5 w-px" />
@@ -55,6 +73,7 @@ const Header = () => {
</div>
</div>
</div>
<HeaderMenu />
</div>
);
+8 -6
View File
@@ -42,18 +42,19 @@ const columns: CategoryGroup[][] = [
const AllProductsMenu: FC = () => {
return (
<div className="absolute top-full right-[120px] z-50 mt-2 w-[1099px] rounded-[32px] bg-[#F9FAFB]/90 px-8 pt-8 pb-10">
<div className="flex w-full items-start justify-between">
<div className="absolute top-full z-50 mt-2 inset-x-4 sm:inset-x-8 lg:inset-x-auto lg:right-[120px] w-auto lg:w-[min(1099px,calc(100vw-240px))] max-h-[70vh] overflow-y-auto rounded-2xl sm:rounded-[32px] bg-[#F9FAFB]/95 backdrop-blur-sm px-4 sm:px-8 pt-6 sm:pt-8 pb-6 sm:pb-10 shadow-lg border border-[#E9EEF2]/80">
<div className="flex w-full flex-col lg:flex-row items-start justify-between gap-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-0 lg:contents w-full lg:w-auto">
{columns.map((groups) => (
<div key={groups[0].title} className="flex w-[228px] shrink-0 flex-col gap-6">
<div key={groups[0].title} className="flex w-full lg:w-[228px] shrink-0 flex-col gap-6">
{groups.map((group) => (
<div key={group.title} className="flex flex-col gap-2">
<div className="flex h-10 items-center px-4 text-base font-bold text-black">{group.title}</div>
<div className="flex h-10 items-center px-2 sm:px-4 text-sm sm:text-base font-bold text-black">{group.title}</div>
{group.items.map((item) => (
<button
key={item}
type="button"
className="flex h-6 w-full items-center px-4 text-right text-base font-medium text-[#011E3A] transition-colors hover:text-primary"
className="flex h-6 w-full items-center px-2 sm:px-4 text-right text-sm sm:text-base font-medium text-[#011E3A] transition-colors hover:text-primary"
>
{item}
</button>
@@ -62,8 +63,9 @@ const AllProductsMenu: FC = () => {
))}
</div>
))}
</div>
<div className="relative h-[157px] w-[163px] shrink-0 overflow-hidden rounded-2xl">
<div className="relative mx-auto lg:mx-0 h-[140px] w-[145px] sm:h-[157px] sm:w-[163px] shrink-0 overflow-hidden rounded-2xl">
<Image src={businessCardMenu} alt="کارت ویزیت" fill className="object-cover" sizes="163px" />
</div>
</div>
+53
View File
@@ -0,0 +1,53 @@
import { Call, Location, MessageQuestion, Sms } from "iconsax-reactjs";
import { type FC, type ReactNode } from "react";
type ContactItemProps = {
icon: ReactNode;
children: ReactNode;
};
const ContactItem: FC<ContactItemProps> = ({ icon, children }) => {
return (
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary text-primary">{icon}</div>
<div className="text-sm leading-6 text-[#3A4147]">{children}</div>
</div>
);
};
const FooterContact: FC = () => {
return (
<div className="flex max-w-none sm:max-w-[280px] flex-col gap-5">
<ContactItem icon={<Location size={18} color="currentColor" variant="Linear" />}>
جاده خاوران، شریف آباد، شهرک صنعتی شنزار، بعد از میدان چهارم، خیابان بوستان پنجم، خیابان گلبهار، پلاک ۱۲۲
</ContactItem>
<ContactItem icon={<Call size={18} color="currentColor" variant="Linear" />}>
<div className="flex flex-col gap-0.5">
<span>۰۲۱-۳۶۹۱۱۵۹۹ | ۰۲۱-۳۶۹۱۰۷۹۹</span>
<span>۰۲۱۳۴۷۸۲۰۰۰ (۳۰ خط)</span>
</div>
</ContactItem>
<ContactItem icon={<Sms size={18} color="currentColor" variant="Linear" />}>
<div className="flex flex-col gap-0.5">
<span className="font-bold text-[#0A1B2C]">پاسخگوی سوالات شما</span>
<a href="mailto:info@example.com" className="hover:text-primary" dir="ltr">
info@example.com
</a>
</div>
</ContactItem>
<ContactItem icon={<MessageQuestion size={18} color="currentColor" variant="Linear" />}>
<div className="flex flex-col gap-0.5">
<span>نیاز به راهنمایی دارید؟</span>
<a href="#" className="font-bold text-[#0A1B2C] hover:text-primary">
از طریق سوالات متداول پاسخ خود را پیدا کنید.
</a>
</div>
</ContactItem>
</div>
);
};
export default FooterContact;
@@ -0,0 +1,25 @@
import { type FC } from "react";
type Props = {
title: string;
links: string[];
};
const FooterLinkColumn: FC<Props> = ({ title, links }) => {
return (
<div className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-[#0A1B2C]">{title}</h3>
<ul className="flex flex-col gap-2.5">
{links.map((link) => (
<li key={link}>
<a href="#" className="text-sm text-[#3A4147] transition-colors hover:text-primary">
{link}
</a>
</li>
))}
</ul>
</div>
);
};
export default FooterLinkColumn;
@@ -0,0 +1,22 @@
import { ArrowLeft } from "iconsax-reactjs";
import { type FC } from "react";
const FooterNewsletter: FC = () => {
return (
<div className="flex w-full max-w-[360px] flex-col gap-4 lg:ms-auto">
<p className="text-sm leading-6 font-bold text-[#0A1B2C]">برای دریافت آخرین پیشنهادها، اخبار و مطالب الهامبخش، در خبرنامه ما عضو شوید.</p>
<form className="relative" action="#">
<input
type="email"
placeholder="ایمیل شما"
className="h-12 w-full rounded-full border border-[#E9EEF2] bg-[#F1F6FA] ps-5 pe-14 text-sm text-[#3A4147] placeholder:text-[#9AA6B2]"
/>
<button type="submit" aria-label="عضویت در خبرنامه" className="absolute top-1/2 left-1.5 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-[#0A1B2C] text-white transition-colors hover:bg-[#0A1B2C]/90">
<ArrowLeft size={18} color="currentColor" />
</button>
</form>
</div>
);
};
export default FooterNewsletter;
+78
View File
@@ -0,0 +1,78 @@
import { type FC, type ReactNode } from "react";
type SocialLink = {
label: string;
href: string;
bg: string;
icon: ReactNode;
};
const socialLinks: SocialLink[] = [
{
label: "Facebook",
href: "#",
bg: "bg-[#1877F2]",
icon: (
<svg viewBox="0 0 24 24" className="size-4 fill-white" aria-hidden>
<path d="M14 8.5h2.5V5.2C16.1 5.1 15 5 13.8 5 11.4 5 9.8 6.5 9.8 9.2V12H7v3.5h2.8V23h3.5v-7.5H16l.5-3.5h-3.2V9.4c0-1 .3-1.9 1.7-1.9z" />
</svg>
),
},
{
label: "Instagram",
href: "#",
bg: "bg-linear-to-br from-[#F58529] via-[#DD2A7B] to-[#8134AF]",
icon: (
<svg viewBox="0 0 24 24" className="size-4 fill-white" aria-hidden>
<path d="M12 7.2A4.8 4.8 0 1 0 12 16.8 4.8 4.8 0 1 0 12 7.2zm0 7.9a3.1 3.1 0 1 1 0-6.2 3.1 3.1 0 0 1 0 6.2zm6.3-8.1a1.1 1.1 0 1 1-2.2 0 1.1 1.1 0 0 1 2.2 0zM12 3.5c-2.3 0-2.6 0-3.5.1-2.3.1-3.4 1.2-3.5 3.5-.1.9-.1 1.2-.1 3.5s0 2.6.1 3.5c.1 2.3 1.2 3.4 3.5 3.5.9.1 1.2.1 3.5.1s2.6 0 3.5-.1c2.3-.1 3.4-1.2 3.5-3.5.1-.9.1-1.2.1-3.5s0-2.6-.1-3.5c-.1-2.3-1.2-3.4-3.5-3.5-.9-.1-1.2-.1-3.5-.1zm0 1.5c2.3 0 2.5 0 3.4.1 1.8.1 2.6.9 2.7 2.7.1.9.1 1.1.1 3.4s0 2.5-.1 3.4c-.1 1.8-.9 2.6-2.7 2.7-.9.1-1.1.1-3.4.1s-2.5 0-3.4-.1c-1.8-.1-2.6-.9-2.7-2.7-.1-.9-.1-1.1-.1-3.4s0-2.5.1-3.4c.1-1.8.9-2.6 2.7-2.7.9-.1 1.1-.1 3.4-.1z" />
</svg>
),
},
{
label: "LinkedIn",
href: "#",
bg: "bg-[#0A66C2]",
icon: (
<svg viewBox="0 0 24 24" className="size-4 fill-white" aria-hidden>
<path d="M6.5 9.5H3.8V20h2.7V9.5zM5.1 4a1.6 1.6 0 1 0 0 3.2 1.6 1.6 0 0 0 0-3.2zM20.2 20h-2.7v-5.5c0-1.3 0-3-1.8-3s-2.1 1.4-2.1 2.9V20H11V9.5h2.6v1.4h.1c.4-.7 1.3-1.5 2.7-1.5 2.9 0 3.4 1.9 3.4 4.4V20z" />
</svg>
),
},
{
label: "Telegram",
href: "#",
bg: "bg-[#2AABEE]",
icon: (
<svg viewBox="0 0 24 24" className="size-4 fill-white" aria-hidden>
<path d="M9.8 15.3 9.5 19c.4 0 .6-.2.8-.4l2-1.9 4.1 3c.8.4 1.3.2 1.5-.7l2.7-12.7c.2-1.1-.4-1.5-1.2-1.2L4.2 10.3c-1 .3-1 .9-.2 1.2l4.4 1.4 10.2-6.4c.5-.3.9-.1.5.2L9.8 15.3z" />
</svg>
),
},
{
label: "WhatsApp",
href: "#",
bg: "bg-[#25D366]",
icon: (
<svg viewBox="0 0 24 24" className="size-4 fill-white" aria-hidden>
<path d="M12 3.5A8.4 8.4 0 0 0 5.2 16.3L4 20l3.8-1.2A8.4 8.4 0 1 0 12 3.5zm4.9 12c-.2.6-1.2 1.1-1.7 1.1-.4 0-.9.2-3-.8-2.5-1.2-4.1-4-4.2-4.2-.1-.2-1-1.4-1-2.6s.6-1.9.9-2.1c.2-.2.5-.3.7-.3h.5c.2 0 .4 0 .5.4l.8 1.9c.1.2 0 .4-.1.5l-.3.4c-.1.2-.3.3-.1.6.2.3.7 1.2 1.6 1.9 1.1.9 2 1.2 2.3 1.3.3.1.5.1.7-.1l.6-.7c.2-.2.4-.2.6-.1l1.8.8c.2.1.4.2.4.4 0 .2 0 1.2-.5 1.8z" />
</svg>
),
},
];
const FooterSocial: FC = () => {
return (
<div className="flex flex-col gap-4 items-center lg:items-start text-center lg:text-right">
<h3 className="text-sm font-bold text-[#0A1B2C]">ما را در شبکههای اجتماعی دنبال کنید</h3>
<div className="flex items-center gap-3 flex-wrap justify-center lg:justify-start">
{socialLinks.map((item) => (
<a key={item.label} href={item.href} aria-label={item.label} className={`flex size-9 items-center justify-center rounded-full ${item.bg}`}>
{item.icon}
</a>
))}
</div>
</div>
);
};
export default FooterSocial;
@@ -0,0 +1,20 @@
import { type FC } from "react";
const badges = ["اینماد", "ساماندهی", "اتحادیه"];
const FooterTrustBadges: FC = () => {
return (
<div className="flex flex-col items-center gap-4">
<h3 className="text-sm font-bold text-[#0A1B2C]">نماد های اعتماد</h3>
<div className="flex items-center gap-3 sm:gap-4 flex-wrap justify-center">
{badges.map((badge) => (
<div key={badge} className="flex size-14 sm:size-16 items-center justify-center rounded-full border border-[#E9EEF2] bg-white text-center text-[10px] font-bold text-[#6C7680]">
{badge}
</div>
))}
</div>
</div>
);
};
export default FooterTrustBadges;
@@ -0,0 +1,22 @@
import { TickCircle } from "iconsax-reactjs";
import { type FC } from "react";
const values = ["تضمین کیفیت چاپ", "طراحی اختصاصی", "تحویل سریع", "پشتیبانی قبل و بعد از خرید", "ارسال به سراسر ایران"];
const FooterTrustValues: FC = () => {
return (
<div className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-[#0A1B2C]">اعتماد شما، سرمایه ما</h3>
<ul className="flex flex-col gap-2.5">
{values.map((value) => (
<li key={value} className="flex items-center gap-2 text-sm text-[#3A4147]">
<TickCircle size={18} color="currentColor" variant="Bold" className="shrink-0 text-primary" />
{value}
</li>
))}
</ul>
</div>
);
};
export default FooterTrustValues;
+5 -5
View File
@@ -36,21 +36,21 @@ const HeaderMenu: FC = () => {
}, [isAllProductsOpen]);
return (
<div ref={containerRef} className="relative h-[72px] w-full flex-1 px-[120px]">
<div className="flex h-full items-center justify-center gap-8">
<div ref={containerRef} className="relative h-14 sm:h-[72px] w-full flex-1 px-4 sm:px-8 lg:px-[120px]">
<div className="flex h-full items-center gap-4 sm:gap-6 lg:gap-8 overflow-x-auto scrollbar-none [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden lg:justify-center">
<button
type="button"
className={cn("flex items-center gap-2 font-bold transition-colors", isAllProductsOpen && "text-primary")}
className={cn("flex items-center gap-2 font-bold transition-colors shrink-0 text-sm sm:text-base", isAllProductsOpen && "text-primary")}
onClick={() => setIsAllProductsOpen((open) => !open)}
aria-expanded={isAllProductsOpen}
aria-haspopup="true"
>
<HamburgerMenu size={24} color="currentColor" />
<HamburgerMenu size={22} color="currentColor" />
<span>همه محصولات</span>
</button>
{menuItems.map((item) => (
<div key={item} className="font-bold">
<div key={item} className="font-bold shrink-0 whitespace-nowrap text-sm sm:text-base">
{item}
</div>
))}
Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;
+15 -1
View File
@@ -12,7 +12,8 @@
"iconsax-reactjs": "^0.0.8",
"next": "16.2.10",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"react-simple-star-rating": "^5.1.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -5718,6 +5719,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-simple-star-rating": {
"version": "5.1.7",
"resolved": "https://registry.npmjs.org/react-simple-star-rating/-/react-simple-star-rating-5.1.7.tgz",
"integrity": "sha512-NTFkW8W3uwvI82Fv7JW5i7gmDjEZKxJmj+Z9vn+BjYIXT6ILdnU9qnSUP2cWrWN/WAUlue81f9SgM4CQcenltQ==",
"license": "MIT",
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
+2 -1
View File
@@ -13,7 +13,8 @@
"iconsax-reactjs": "^0.0.8",
"next": "16.2.10",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"react-simple-star-rating": "^5.1.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",