Compare commits
5 Commits
6f22885ae9
...
112aee1cdf
| Author | SHA1 | Date | |
|---|---|---|---|
| 112aee1cdf | |||
| 14d1422970 | |||
| 0e5fe6e932 | |||
| 220f749598 | |||
| ec39e4448d |
@@ -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
|
||||
@@ -14,10 +14,10 @@ const CategoryHero: FC = () => {
|
||||
backgroundImage: "linear-gradient(108.5deg, rgba(33, 88, 140, 0.1) 89.65%, rgba(237, 243, 248, 0.3) 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-6 lg:gap-10">
|
||||
<div className="flex-1 space-y-3 sm:space-y-4">
|
||||
<h1 className="text-[#0A1B2C] text-2xl sm:text-3xl lg:text-[32px] font-bold">{CATEGORY_TITLE}</h1>
|
||||
<p className="text-sm text-[#0A1B2C]/80 leading-7 max-w-140">{CATEGORY_DESCRIPTION}</p>
|
||||
<div 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">
|
||||
@@ -27,13 +27,13 @@ const CategoryHero: FC = () => {
|
||||
width={360}
|
||||
height={360}
|
||||
priority
|
||||
className="aspect-square w-55 sm:w-70 lg:w-[320px] rounded-2xl sm:rounded-3xl object-cover shadow-[0px_-4px_12px_0px_rgba(0,0,0,0.12)]"
|
||||
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 bg-secondary px-4 sm:px-8 lg:px-30 py-3 text-sm text-[#3F4D5A]">
|
||||
<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>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CATEGORY_PRODUCTS } from "../constants";
|
||||
|
||||
const CategoryProducts: FC = () => {
|
||||
return (
|
||||
<GridWrapper desktop={4} mobile={2} gapDesktop={24} gapMobile={12}>
|
||||
<GridWrapper desktop={4} mobile={2} gapDesktop={24} gapMobile={12} className="sm:gap-4">
|
||||
{CATEGORY_PRODUCTS.map((title, index) => (
|
||||
<ProductCard key={`${title}-${index}`} title={title} />
|
||||
))}
|
||||
|
||||
@@ -5,13 +5,13 @@ import { SORT_OPTIONS } from "../constants";
|
||||
|
||||
const CategoryToolbar: FC = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="max-w-50">
|
||||
<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="max-w-50">
|
||||
<Select options={SORT_OPTIONS} placeholder="مرتبسازی" className="min-w-50 rounded-xl border-[#E9EFF4]" />
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -9,14 +9,12 @@ const CategoryDetailPage = () => {
|
||||
<div>
|
||||
<CategoryHero />
|
||||
|
||||
<div className="space-y-4 px-4 mt-10 sm:space-y-5 sm:px-8 lg:px-30 pt-8 sm:pt-10 pb-8 sm:pb-10">
|
||||
<div className="lg:grid lg:gap-x-6">
|
||||
<CategoryToolbar />
|
||||
</div>
|
||||
|
||||
<div className="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">
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-4 sm:space-y-5">
|
||||
<CategoryToolbar />
|
||||
<CategoryProducts />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user