Compare commits
19 Commits
f9f828097b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 112aee1cdf | |||
| 14d1422970 | |||
| 0e5fe6e932 | |||
| 220f749598 | |||
| ec39e4448d | |||
| 6f22885ae9 | |||
| fe1e545aaf | |||
| 6ae9a6a17f | |||
| 42af5a44a1 | |||
| cd266c057c | |||
| 2adaac8ec4 | |||
| 2c9b43c84a | |||
| 36763aace0 | |||
| 35beea16bd | |||
| 979281bfb1 | |||
| fcf89eb2a1 | |||
| ea7745179b | |||
| 9a58990cae | |||
| 7bb9240f21 |
@@ -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)
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 775 KiB |
|
After Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 775 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 602 KiB |
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||