first commit

This commit is contained in:
amirhosein.ashourloo
2026-09-02 17:03:23 +03:30
commit 1bd131e9d8
91 changed files with 11211 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "moalem-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}
+6
View File
@@ -0,0 +1,6 @@
node_modules
.next
out
.env*.local
next-env.d.ts
tsconfig.tsbuildinfo
+179
View File
@@ -0,0 +1,179 @@
# moalem.shop — context for AI agents and tooling
Storefront for **فروشگاه موبایل معلم** built on the Meshkee CMS contract.
Keep this file current: every prompt that changes structure, data sources or
fallbacks should update it.
## Stack
- Next.js 16.3 (App Router, Turbopack) · React 19 · TypeScript
- Tailwind CSS v4 (`@theme inline` tokens in `src/app/globals.css`)
- Swiper 14 for every slider/carousel
- fa / RTL, flat design, no gradients
## Data — Meshkee Website API
- Base: `https://api.meshkee.com/api/v1`, docs: <https://api.meshkee.com/docs/website>
- Tenant: `moalem.shop` (business id 27, `specialProductsSource: store_item`)
- Every public read goes through `src/lib/api.ts`; wrap calls in `safe()` so an
empty or failing module degrades one section instead of the page.
- Never invent CMS/admin endpoints. If OpenAPI and any prose conflict, OpenAPI wins.
### What the tenant actually returns today (2026-08-31)
| Endpoint | State |
|---|---|
| `enabledModules` (`GET /tenants/moalem.shop`) | `["products", "blog", "finance"]` — **`workshops` is not enabled.** Do not build a workshops page/nav entry until it is. |
| `/products` | **479 items**, real titles, images, categories |
| `/categories?entityType=product` | **48 items**, 3 levels under «کالای دیجیتال». Products are filed on **leaf** categories only — a parent category page/carousel must query its whole subtree (`subtreeIds`/`subtreeIdsAll` in `src/lib/categoryTree.ts`). |
| `/store-items`, `/store-specials` | **empty** → no prices, no discounts, everything `inStock:false`. `/store-items/by-product/{id}` returns a single `{ storeItem }` (or null) — for a product's full variant list use `/store-items?productId=&pageSize=100` (`{ items }`) instead, see `getStoreItemsByProduct` in `src/lib/api.ts`. |
| `/website/sliders`, `/website/static-images`, `/website/category-groups` | **empty** |
| `/blogs` | **empty** (module enabled) |
| `/website/business-info`, `/website/favicon` | present but blank (no logo, no phone, no socials) |
| `/products/by-id/{id}/technical-info` | `{ form: null, values: [] }` — empty on every product checked so far |
| `/comments` | **empty**, but `POST` works (no bearer required by the API itself) |
### Fallbacks in place until the CMS is filled
| Gap | Fallback | Remove when |
|---|---|---|
| No prices | `src/lib/demoPricing.ts`, gated by `NEXT_PUBLIC_DEMO_PRICING=1` — deterministic price per product id | store-items exist |
| No sliders | `src/app/page.tsx`'s local `FALLBACK_SLIDES` (banners downloaded from the live moalem.shop theme into `public/images/banners/`) — used only when the `slider` static-image slot is also empty, see "Static-image slots" below | `/website/sliders` or the `slider` slot returns items |
| No blog posts | `src/data/fallbackBlogs.ts` — the real posts from moalem.shop/articles, shown on the homepage and `/blog` as non-clickable cards (no real detail page exists for them) | `/blogs` returns items |
| No category art | placeholder SVGs in `public/images/categories/{id}.svg`, generated by `scripts/gen-category-placeholders.mjs` — overridden per-slide by the `categories` static-image slot when it has images | real thumbnails are dropped in, or the `categories` slot is filled |
| No branding in API | logo/favicon copied from the live theme into `public/` | `logoUrl` is set on the tenant |
| No banner art (`one/two/three-banner-bg`) | `src/components/PromoBanners.tsx` renders a flat dashed placeholder per slot cell, labelled with the recommended size/ratio | the matching static-image slot is filled |
## Brand
Taken from the live moalem.shop theme, not invented:
primary `#E51E22`, dark `#B10123`, ink `#2E2E2E`, body `#7C7C7C`.
Fonts: IRANYekan (fa, local `public/fonts`) + Montserrat (latin, next/font).
## Layout conventions
- Content width `--container-boxed: 1200px`, applied with the `boxed` utility.
- Homepage order: promo strip → header → hero slider → category carousel →
hot deals → product carousels (interleaved with promo banners) → magazine
carousel → footer. Exact interleaving is in "Static-image slots" below.
- Section header (title + one-line desc on the right, «مشاهده همه» pill on the
left) is `src/components/ui/SectionHeader.tsx` — reuse it for every carousel.
- Product card: `ui/ProductCard.tsx`, 8px radius, discount badge top-right,
strikethrough old price above the current one. The price block is a fixed
44px row and the old-price line is always rendered (empty when there is no
discount) so **every card in a carousel is exactly the same height** — keep
that invariant when adding badges or meta lines.
- Carousel arrows: `ui/CarouselNav.tsx`, passed to `SectionHeader` as `nav` so
they sit beside the «مشاهده همه» pill on desktop; the copy under the track is
`lg:hidden` for thumbs.
- Prices are Toman and rendered with Persian digits (`src/lib/format.ts`).
## Navigation
- **Desktop (lg+):** full header — logo, search, account/cart, then the product
mega menu and the main nav.
- **Mobile:** no hamburger. The header carries the search box alone; everything
else lives in `MobileBottomNav.tsx`, a fixed bottom bar with خانه / سبد خرید /
دسته‌بندی‌ها / ورود (or the account menu once signed in). «دسته‌بندی‌ها» opens
a bottom sheet with the level-2 categories and an accordion for their
children. Any new mobile entry point belongs in that bar, not in a drawer.
## Auth / account menu
Login itself happens off-site, on the customer portal (`customer.{domain}`) —
this storefront never renders a login form. For the header/bottom-nav account
menu to show "نام عزیز" instead of "ورود | ثبت‌نام", the customer portal needs
to set a **non-HttpOnly cookie on the shared parent domain** (`.{domain}`)
named `AUTH_TOKEN_COOKIE` (`src/lib/config.ts`, currently `"meshkee_token"`)
holding the bearer access token. Until that exists, `getSession()` in
`src/lib/auth.ts` always resolves to `null` and the anonymous view is all
anyone will see — this is expected, not a bug, and nothing else breaks.
- `src/lib/auth.ts` — **server-only** (`next/headers`): `getSession()` reads
the cookie and calls `GET /auth/me`.
- `src/lib/authUser.ts` — pure helpers (`displayName`, `isAdmin`), safe to
import from client components. **Never import `auth.ts` from a client
component** — it pulls in `next/headers` and breaks the build; that's the
entire reason this file is split out.
- Admin detection (`isAdmin()`) reads `user.isAdmin`/`user.role` defensively —
the OpenAPI schema doesn't document a role field on the user object, so
this is a best guess. Update it once the real shape is confirmed.
- Cart ("افزودن به سبد خرید" in `ProductBuyPanel.tsx`) needs a real
`storeItemVariantId`, which only exists once a product has store-items —
today that's none of them, so the button always shows "این محصول هنوز برای
خرید آنلاین فعال نشده است" regardless of login state. That's correct given
the data, not a bug either.
## Static-image slots
Slot catalog: `src/app/meshkee/static-image-slots/route.ts`. Keys and where
each is consumed:
| Key | Kind | Consumed by |
|---|---|---|
| `slider` | list | `HeroSlider.tsx` — wins over `FALLBACK_SLIDES` when it has images |
| `categories` | list | `CategoryCarousel.tsx` — images matched **positionally** to the category list (business fills them in the same order); a category past the end of the slot keeps its SVG placeholder |
| `one-banner-bg` | single | `PromoBanners.tsx` → `OneBanner` |
| `two-banner-bg` | list (2) | `PromoBanners.tsx` → `TwoBanners` |
| `three-banner-bg` | list (3) | `PromoBanners.tsx` → `ThreeBanners` |
**Rule:** every `aspectRatio`/`recommendedWidth` in that route file must match
what the component actually renders. If you change a banner's size or ratio,
update its slot entry in the same commit — that file is the only thing
telling the dashboard (and the designer) what to upload.
Homepage banner placement (mirrors the logilook.com reference given for this
layout): hero → categories → hot deals → **three-banner-bg** → موبایل
carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **one-banner-bg** →
کنسول بازی → مجله.
## Product / blog pages
- `/products` — full catalog, `?name=` search, `?page=`.
- `/products/category/{id}/{slug}` — category listing; merges every leaf in
the subtree (see the leaf-category note above) and paginates in memory.
- `/products/{id}/{slug}` — detail: `ProductGallery.tsx` (click-to-lightbox +
hover-zoom via a background-position overlay, desktop only),
`ProductBuyPanel.tsx` (variants, price, auth-aware cart), `ProductInfoTabs.tsx`
(توضیحات / مشخصات فنی / دیدگاه کاربران), related products from
`relatedProducts` on the by-id response.
- `/blog`, `/blog/{id}/{slug}` — same shape, no variants/tabs; just content +
`CommentSection.tsx`. Fallback posts (see table above) only exist in the
listing grid, not as real detail pages.
- **Technical specs**: `values[]` from `technical-info` carries no label, only
a `fieldId` — never render it directly. Join against `form.fields` first via
`src/lib/technicalInfo.ts#buildSpecRows`.
- **Comments**: `POST /comments` doesn't require a bearer token at the API
level, but `CommentSection.tsx` gates the form behind `getSession()` per the
page brief — anonymous visitors get a login prompt instead of a textarea.
## URLs (Meshkee canonical shapes)
- Product `/products/{id}/{nameFaSlug}`
- Category `/products/category/{id}/{nameFaSlug}`
- Blog `/blog/{id}/{titleSlug}`
- Helpers live in `src/lib/slug.ts` — build links with those, never by hand.
## Published for the platform
- `GET /meshkee/static-image-slots` — slot catalog for the dashboard Refresh button
- `GET /meshkee/sitemap-config.json` — static pages only, **auto-scanned from
`src/app` at request time** (`src/app/meshkee/sitemap-config.json/route.ts`)
rather than hand-listed, so it can never advertise a page that doesn't exist
yet. Product/category/blog/portfolio detail pages are excluded on purpose —
Meshkee resolves those straight from the API.
- Do **not** add `app/sitemap.ts` or `app/robots.ts`: nginx proxies
`/sitemap.xml` and `/robots.txt` to the API.
## SEO rules for every new page
Unique `<title>` + meta description, exactly one `<h1>`, real `<h2>` structure,
non-empty `alt` on every content image.
## Scripts
```bash
npm run dev # localhost:3000
npm run build
node scripts/gen-category-placeholders.mjs # refresh category thumb placeholders
```
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+18
View File
@@ -0,0 +1,18 @@
# moalem.shop
فروشگاه اینترنتی موبایل معلم — Next.js storefront on the Meshkee CMS API.
```bash
npm install
npm run dev
```
Environment (`.env.local`):
```
NEXT_PUBLIC_SITE_DOMAIN=moalem.shop
NEXT_PUBLIC_DEMO_PRICING=1 # temporary: the tenant has no store-items yet
```
Architecture, data gaps and the fallbacks currently in place are documented in
[AGENTS.md](AGENTS.md).
BIN
View File
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+13
View File
@@ -0,0 +1,13 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "c804387.parspack.net" },
{ protocol: "https", hostname: "meshkee-storage.sas.amin.parminstorage.ir" },
{ protocol: "https", hostname: "meshkee-templates.sas.amin.parminstorage.ir" },
],
},
};
export default nextConfig;
+6802
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "moalem-shop",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.3.1",
"react": "19.2.8",
"react-dom": "19.2.8",
"swiper": "^14.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="لوازم جانبی">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">لوازم جان</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1226</text>
</svg>

After

Width:  |  Height:  |  Size: 585 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="موبایل">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">موبایل</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1251</text>
</svg>

After

Width:  |  Height:  |  Size: 571 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="تبلت">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">تبلت</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1253</text>
</svg>

After

Width:  |  Height:  |  Size: 563 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="لپ تاپ">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">لپ تاپ</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1254</text>
</svg>

After

Width:  |  Height:  |  Size: 569 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="ساعت و بند هوشمند">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">ساعت و بن</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1255</text>
</svg>

After

Width:  |  Height:  |  Size: 594 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="کنسول بازی">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">کنسول باز</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1256</text>
</svg>

After

Width:  |  Height:  |  Size: 583 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="گوشی تلفن">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">گوشی تلفن</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1258</text>
</svg>

After

Width:  |  Height:  |  Size: 581 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="مانیتور">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">مانیتور</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1259</text>
</svg>

After

Width:  |  Height:  |  Size: 575 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="لوازم برقی">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">لوازم برق</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1260</text>
</svg>

After

Width:  |  Height:  |  Size: 583 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="دوربین عکاسی">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">دوربین عک</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1261</text>
</svg>

After

Width:  |  Height:  |  Size: 587 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="مودم همراه و ثابت">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">مودم همرا</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1263</text>
</svg>

After

Width:  |  Height:  |  Size: 595 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="لوازم عکاسی">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">لوازم عکا</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1264</text>
</svg>

After

Width:  |  Height:  |  Size: 585 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="تجهیزات عکس برداری">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">تجهیزات ع</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1268</text>
</svg>

After

Width:  |  Height:  |  Size: 598 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="خط0912">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">خط0912</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1269</text>
</svg>

After

Width:  |  Height:  |  Size: 563 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="میکروفون">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">میکروفون</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1271</text>
</svg>

After

Width:  |  Height:  |  Size: 579 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="400" height="400" role="img" aria-label="بدون تصویر">
<rect width="400" height="400" fill="#f6f7f9"/>
<rect x="60" y="60" width="280" height="280" rx="16" fill="#fff" stroke="#ececec" stroke-width="2" stroke-dasharray="8 6"/>
<circle cx="160" cy="160" r="24" fill="#ececec"/>
<path d="M100 300l70-80 60 60 40-50 70 70" fill="none" stroke="#ececec" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
<text x="200" y="350" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="16" fill="#7c7c7c">بدون تصویر</text>
</svg>

After

Width:  |  Height:  |  Size: 643 B

+34
View File
@@ -0,0 +1,34 @@
// Writes one placeholder thumbnail per top-level product category into
// public/images/categories/{id}.svg. They are meant to be replaced with real
// artwork one file at a time — the carousel reads the same paths either way.
// Run: node scripts/gen-category-placeholders.mjs
import { mkdir, writeFile } from "node:fs/promises";
const DOMAIN = process.env.NEXT_PUBLIC_SITE_DOMAIN || "moalem.shop";
const OUT = new URL("../public/images/categories/", import.meta.url);
const res = await fetch(
`https://api.meshkee.com/api/v1/tenants/${DOMAIN}/categories?entityType=product`
);
const { items } = await res.json();
const roots = items.filter((c) => c.parentId === null).map((c) => c.id);
const level2 = items.filter((c) => c.parentId && roots.includes(c.parentId));
await mkdir(OUT, { recursive: true });
const escape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
for (const cat of level2) {
const label = escape(cat.nameFa || cat.name);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="${label}">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">${label.slice(0, 9)}</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">${cat.id}</text>
</svg>
`;
await writeFile(new URL(`${cat.id}.svg`, OUT), svg, "utf8");
}
console.log(`wrote ${level2.length} placeholders to public/images/categories/`);
+100
View File
@@ -0,0 +1,100 @@
import type { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
import Breadcrumbs from "@/components/Breadcrumbs";
import CommentSection from "@/components/CommentSection";
import BlogCarousel, { type BlogCardData } from "@/components/BlogCarousel";
import { getBlogById, getBlogs, getComments, safe } from "@/lib/api";
import { getSession } from "@/lib/auth";
import { blogHref } from "@/lib/slug";
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
try {
const { blog } = await getBlogById(id);
const title = blog.title ?? blog.nameFa ?? "";
return {
title,
description: (blog.summary || title).slice(0, 160),
openGraph: blog.thumbnailUrl ? { images: [{ url: blog.thumbnailUrl }] } : undefined,
};
} catch {
return {};
}
}
export default async function BlogDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let blog;
try {
({ blog } = await getBlogById(id));
} catch {
notFound();
}
const title = blog.title ?? blog.nameFa ?? "";
const image = blog.thumbnailUrl ?? blog.image ?? null;
const category = blog.categoryNameFa ?? blog.categoryName;
// ApiBlog doesn't carry a categoryId (only category name strings), so
// "more posts" is just the latest others rather than a same-category query.
const [{ items: comments }, moreResult, user] = await Promise.all([
safe(getComments("blog", blog.id), { items: [] }),
safe(getBlogs({ pageSize: 8 }), { items: [], total: 0, page: 1, pageSize: 8 }),
getSession(),
]);
const morePosts: BlogCardData[] = moreResult.items
.filter((p) => p.id !== blog.id)
.slice(0, 4)
.map((p) => ({
id: p.id,
href: blogHref(p.id, p.title ?? p.nameFa),
title: p.title ?? p.nameFa ?? "",
image: p.thumbnailUrl ?? p.image ?? null,
category: p.categoryNameFa ?? p.categoryName ?? undefined,
}));
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs
items={[
{ label: "مجله معلم", href: "/blog" },
...(category ? [{ label: category }] : []),
]}
/>
<article className="mx-auto max-w-3xl">
{category && <span className="text-[12px] font-bold text-brand">{category}</span>}
<h1 className="mt-2 text-xl font-bold text-ink md:text-2xl">{title}</h1>
{image && (
<span className="relative mt-5 block aspect-[16/9] w-full overflow-hidden rounded-xl bg-surface">
<Image src={image} alt={title} fill sizes="768px" className="object-cover" priority />
</span>
)}
{blog.contentHtml || blog.descriptionHtml ? (
<div
className="rich-text mt-6 text-sm leading-8 text-body"
dangerouslySetInnerHTML={{ __html: blog.contentHtml || blog.descriptionHtml || "" }}
/>
) : blog.summary ? (
<p className="mt-6 text-sm leading-8 text-body">{blog.summary}</p>
) : null}
<div className="mt-12">
<h2 className="mb-5 text-lg font-bold text-ink">دیدگاه‌ها{comments.length > 0 ? ` (${comments.length})` : ""}</h2>
<CommentSection entityType="blog" entityId={blog.id} initialComments={comments} user={user} />
</div>
</article>
{morePosts.length > 0 && <BlogCarousel posts={morePosts} />}
</main>
);
}
+89
View File
@@ -0,0 +1,89 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import Breadcrumbs from "@/components/Breadcrumbs";
import Pagination from "@/components/ui/Pagination";
import { getBlogs, safe } from "@/lib/api";
import { blogHref } from "@/lib/slug";
import { FALLBACK_BLOGS } from "@/data/fallbackBlogs";
const PAGE_SIZE = 12;
export const metadata: Metadata = {
title: "مجله معلم",
description: "تازه‌ترین راهنماهای خرید، مقایسه محصولات و اخبار دنیای دیجیتال از مجله فروشگاه موبایل معلم.",
};
export default async function BlogListPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const result = await safe(getBlogs({ page, pageSize: PAGE_SIZE }), {
items: [],
total: 0,
page,
pageSize: PAGE_SIZE,
});
const usingFallback = result.items.length === 0;
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs items={[{ label: "مجله معلم" }]} />
<h1 className="mb-1 text-xl font-bold text-ink md:text-2xl">مجله معلم</h1>
<p className="mb-8 text-[13px] text-body">راهنمای خرید، مقایسه محصولات و اخبار دنیای دیجیتال</p>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{usingFallback
? FALLBACK_BLOGS.map((post) => (
// No real detail page exists yet for these — see AGENTS.md fallback
// table — so the card is informational, not a link.
<div key={post.id} className="overflow-hidden rounded-xl border border-line bg-white">
<span className="relative block aspect-[16/9] w-full bg-surface">
<Image src={post.image} alt={post.title} fill sizes="360px" className="object-cover" />
<span className="absolute right-2 top-2 rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-bold text-brand">
{post.category}
</span>
</span>
<p className="clamp-2 p-3 text-[13px] leading-6 text-ink-soft">{post.title}</p>
</div>
))
: result.items.map((post) => {
const title = post.title ?? post.nameFa ?? "";
const image = post.thumbnailUrl ?? post.image ?? null;
const category = post.categoryNameFa ?? post.categoryName;
return (
<Link
key={post.id}
href={blogHref(post.id, title)}
className="overflow-hidden rounded-xl border border-line bg-white transition hover:border-brand/40 hover:shadow-[0_6px_20px_rgba(0,0,0,0.07)]"
>
<span className="relative block aspect-[16/9] w-full bg-surface">
{image && <Image src={image} alt={title} fill sizes="360px" className="object-cover" />}
{category && (
<span className="absolute right-2 top-2 rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-bold text-brand">
{category}
</span>
)}
</span>
<p className="clamp-2 p-3 text-[13px] leading-6 text-ink-soft">{title}</p>
</Link>
);
})}
</div>
{!usingFallback && (
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={result.total}
buildHref={(p) => (p > 1 ? `/blog?page=${p}` : "/blog")}
/>
)}
</main>
);
}
+12
View File
@@ -0,0 +1,12 @@
import localFont from "next/font/local";
// IRANYekan is the face the live moalem.shop theme uses; the two weights
// register under one CSS variable so font-weight alone picks the file.
export const iranYekan = localFont({
src: [
{ path: "../../public/fonts/iranyekanweblightfanum.woff", weight: "300", style: "normal" },
{ path: "../../public/fonts/iranyekanwebboldfanum.woff", weight: "700", style: "normal" },
],
variable: "--font-fa",
display: "swap",
});
+86
View File
@@ -0,0 +1,86 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #2e2e2e;
/* Brand palette lifted from the live moalem.shop theme
(--wb-primary-color #E51E22 / --wb-secondary-color #3B3B3B). Flat, no gradients. */
--color-brand: #e51e22;
--color-brand-dark: #b10123;
--color-brand-soft: #fdecec;
--color-ink: #2e2e2e;
--color-ink-soft: #3b3b3b;
--color-body: #7c7c7c;
--color-surface: #f6f7f9;
--color-line: #ececec;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-brand: var(--color-brand);
--color-brand-dark: var(--color-brand-dark);
--color-brand-soft: var(--color-brand-soft);
--color-ink: var(--color-ink);
--color-ink-soft: var(--color-ink-soft);
--color-body: var(--color-body);
--color-surface: var(--color-surface);
--color-line: var(--color-line);
--font-fa: var(--font-fa);
--font-en: var(--font-en);
--container-boxed: 1200px;
}
/* Base defaults live in Tailwind's base layer so utilities can override them. */
@layer base {
body {
background: var(--background);
color: var(--color-ink);
font-family: var(--font-fa), Tahoma, sans-serif;
font-weight: 300;
}
h1,
h2,
h3,
h4 {
color: var(--color-ink);
font-weight: 700;
}
}
@utility boxed {
width: 100%;
max-width: var(--container-boxed);
margin-inline: auto;
padding-inline: 1rem;
}
/* Latin runs (model names, prices in EN digits) inside Farsi copy. */
.en {
font-family: var(--font-en), var(--font-fa), sans-serif;
}
/* Swiper: the package ships its own CSS, these are the site-level overrides. */
.swiper-pagination-bullet {
background: #ffffff;
opacity: 0.55;
width: 8px;
height: 8px;
}
.swiper-pagination-bullet-active {
opacity: 1;
width: 22px;
border-radius: 4px;
}
.clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
+64
View File
@@ -0,0 +1,64 @@
import type { Metadata } from "next";
import { Montserrat } from "next/font/google";
import "./globals.css";
import { iranYekan } from "./fonts";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import TopBar from "@/components/TopBar";
import MobileBottomNav from "@/components/MobileBottomNav";
import { getCategories, safe } from "@/lib/api";
import { buildCategoryTree, carouselCategories } from "@/lib/categoryTree";
import { getSession } from "@/lib/auth";
import { SITE_NAME } from "@/data/site";
import { SITE_URL } from "@/lib/config";
// Montserrat is the Latin face the live moalem.shop theme pairs with IRANYekan.
const montserrat = Montserrat({
variable: "--font-en",
subsets: ["latin"],
weight: ["500", "700"],
display: "swap",
});
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: `${SITE_NAME} | خرید موبایل، لپ‌تاپ و لوازم جانبی`,
template: `%s | ${SITE_NAME}`,
},
description:
"خرید اینترنتی گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم.",
icons: [{ url: "/favicon.png", type: "image/png" }],
openGraph: {
type: "website",
locale: "fa_IR",
siteName: SITE_NAME,
title: `${SITE_NAME} | خرید موبایل، لپ‌تاپ و لوازم جانبی`,
description:
"خرید اینترنتی گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم.",
url: SITE_URL,
images: [{ url: "/images/logo.png", width: 130, height: 36, alt: SITE_NAME }],
},
};
export default async function RootLayout({ children }: LayoutProps<"/">) {
const [categories, user] = await Promise.all([
safe(getCategories().then(buildCategoryTree), []),
getSession(),
]);
const menu = carouselCategories(categories);
return (
<html lang="fa" dir="rtl" className={`${iranYekan.variable} ${montserrat.variable} h-full antialiased`}>
<body className="flex min-h-full flex-col">
<TopBar />
<Header menu={menu} user={user} />
<main className="flex-1 pb-6">{children}</main>
<Footer categories={menu} />
{/* Spacer so the fixed mobile bar never covers the end of the footer. */}
<div className="h-[68px] lg:hidden" />
<MobileBottomNav categories={menu} user={user} />
</body>
</html>
);
}
@@ -0,0 +1,64 @@
import { readdir } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import { SITE_URL } from "@/lib/config";
// Static routes only, generated from src/app itself rather than
// hand-maintained — a hand list drifts the moment a page is added or removed
// and starts advertising 404s. CMS-backed detail pages (products, categories,
// blog, portfolios) are resolved by Meshkee straight from the API and must
// stay out of this list; so must anything auth/checkout/cart/admin-shaped.
const APP_DIR = path.join(process.cwd(), "src", "app");
const EXCLUDED_SEGMENTS = new Set(["meshkee", "api"]);
const EXCLUDED_PREFIXES = ["/products", "/blog", "/orders", "/cart", "/account", "/checkout", "/admin"];
const PRIORITY: Record<string, number> = {
"/": 1.0,
"/about": 0.6,
"/contact": 0.5,
};
async function collectStaticPages(dir: string, segments: string[] = []): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const pages: string[] = [];
const hasPage = entries.some(
(e) => e.isFile() && /^page\.(tsx|ts|jsx|js)$/.test(e.name)
);
if (hasPage) {
const route = "/" + segments.join("/");
pages.push(route === "/" ? "/" : route.replace(/\/$/, ""));
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const name = entry.name;
// Route groups "(x)" don't affect the URL; dynamic "[x]" and parallel/
// intercepting segments aren't static; "_x" and reserved dirs are skipped.
if (name.startsWith("_") || EXCLUDED_SEGMENTS.has(name)) continue;
if (name.startsWith("[") || name.startsWith("@") || name.startsWith("(")) {
if (name.startsWith("(")) {
pages.push(...(await collectStaticPages(path.join(dir, name), segments)));
}
continue;
}
pages.push(...(await collectStaticPages(path.join(dir, name), [...segments, name])));
}
return pages;
}
export async function GET() {
const routes = await collectStaticPages(APP_DIR);
const staticPages = routes
.filter((route) => !EXCLUDED_PREFIXES.some((p) => route === p || route.startsWith(`${p}/`)))
.sort((a, b) => (a === "/" ? -1 : b === "/" ? 1 : a.localeCompare(b)))
.map((route) => ({
path: route,
changefreq: route === "/" ? "daily" : "monthly",
priority: PRIORITY[route] ?? 0.5,
}));
return NextResponse.json({ baseUrl: SITE_URL, staticPages });
}
@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
// Slot catalog the business dashboard imports with its Refresh button.
//
// RULE: every `aspectRatio` / `recommendedWidth` / `itemCount` below must
// match what the matching component actually renders — HeroSlider.tsx
// (FALLBACK_ASPECT_CLASSES), CategoryCarousel.tsx (90px circles) and
// PromoBanners.tsx (defaultAspect/recommendedWidth per banner count). If a
// component's aspect ratio or image size changes during development, update
// its entry here in the same commit — this file is what tells the dashboard,
// and the designer, what to upload; letting it drift makes every future
// upload the wrong shape.
export function GET() {
return NextResponse.json({
slots: [
{
key: "slider",
label: "اسلایدر صفحه اصلی",
kind: "list",
pageKey: "home",
aspectRatio: "1296:366",
itemCount: null,
recommendedWidth: 1296,
},
{
key: "categories",
label: "تصویر دسته‌بندی‌های صفحه اصلی (به ترتیب دسته‌ها)",
kind: "list",
pageKey: "home",
aspectRatio: "1:1",
itemCount: null,
recommendedWidth: 180,
},
{
key: "one-banner-bg",
label: "بنر تمام‌عرض",
kind: "single",
pageKey: "home",
aspectRatio: "16:5",
itemCount: 1,
recommendedWidth: 1200,
},
{
key: "two-banner-bg",
label: "ردیف دو بنر",
kind: "list",
pageKey: "home",
aspectRatio: "16:9",
itemCount: 2,
recommendedWidth: 580,
},
{
key: "three-banner-bg",
label: "ردیف سه بنر",
kind: "list",
pageKey: "home",
aspectRatio: "4:3",
itemCount: 3,
recommendedWidth: 380,
},
],
});
}
+184
View File
@@ -0,0 +1,184 @@
import HeroSlider, { type Slide } from "@/components/HeroSlider";
import CategoryCarousel from "@/components/CategoryCarousel";
import HotDeals from "@/components/HotDeals";
import ProductCarousel from "@/components/ProductCarousel";
import { OneBanner, ThreeBanners, TwoBanners } from "@/components/PromoBanners";
import BlogCarousel, { type BlogCardData } from "@/components/BlogCarousel";
import {
findSlot,
getBlogs,
getCategories,
getProducts,
getSliders,
getStaticImageSlots,
getStoreSpecials,
safe,
} from "@/lib/api";
import { buildCategoryTree, carouselCategories, findNode, subtreeIds } from "@/lib/categoryTree";
import { productToCard, specialToCards } from "@/lib/cards";
import { blogHref, categoryHref } from "@/lib/slug";
import { HOME_CAROUSELS } from "@/data/site";
import { FALLBACK_BLOGS } from "@/data/fallbackBlogs";
// The slider slot is empty on this tenant, so the homepage falls back to the
// campaign banners already running on moalem.shop (same art, same targets).
const FALLBACK_SLIDES: Slide[] = [
{
id: "laptop",
image: "/images/banners/9WYorEOTwn.jpg",
mobileImage: "/images/banners/WRbs72TDPR.jpg",
href: categoryHref("1254", "لپ تاپ"),
alt: "لپ تاپ",
},
{
id: "console",
image: "/images/banners/OiNl9v5gTf.jpg",
mobileImage: "/images/banners/ieYGotL2qd.jpg",
href: categoryHref("1256", "کنسول بازی"),
alt: "کنسول بازی",
},
{
id: "iphone-17",
image: "/images/banners/06SQinO4sG.jpg",
mobileImage: "/images/banners/lAGRh3ks32.jpg",
href: categoryHref("1251", "موبایل"),
alt: "آیفون ۱۷ پرو مکس",
},
];
const EMPTY_PAGE = { items: [], total: 0, page: 1, pageSize: 12 };
export default async function HomePage() {
const [categories, sliders, specials, blogs, imageSlots] = await Promise.all([
safe(getCategories().then(buildCategoryTree), []),
safe(getSliders(), []),
safe(getStoreSpecials(), []),
safe(getBlogs({ pageSize: 10 }), { items: [], total: 0, page: 1, pageSize: 10 }),
safe(getStaticImageSlots("home"), []),
]);
const carouselCats = carouselCategories(categories);
// Empty or failing slots fall through to each section's own placeholder —
// findSlot() already returns undefined for an empty/missing key.
const sliderSlot = findSlot(imageSlots, "slider");
const categoriesSlot = findSlot(imageSlots, "categories");
const oneBannerSlot = findSlot(imageSlots, "one-banner-bg");
const twoBannerSlot = findSlot(imageSlots, "two-banner-bg");
const threeBannerSlot = findSlot(imageSlots, "three-banner-bg");
const slides: Slide[] =
sliders.length > 0
? sliders
.filter((s) => s.imageUrl)
.map((s, i) => ({
id: s.id ?? `slide-${i}`,
image: s.imageUrl!,
mobileImage: s.mobileImageUrl ?? undefined,
href: s.linkUrl ?? "/products",
alt: s.title ?? "بنر فروشگاه موبایل معلم",
}))
: FALLBACK_SLIDES;
// "تخفیف‌های داغ" prefers the curated special-discount group; until it is
// filled in the dashboard it falls back to the newest catalog products.
const discountGroup =
specials.find((s) => s.key === "special-discount" && s.isActive) ?? specials[0];
const dealsFromCms = discountGroup ? specialToCards(discountGroup) : [];
const fallbackDeals = dealsFromCms.length
? []
: (await safe(getProducts({ pageSize: 12 }), EMPTY_PAGE)).items.map(productToCard);
const deals = dealsFromCms.length ? dealsFromCms : fallbackDeals;
const carousels = await Promise.all(
HOME_CAROUSELS.map(async (section) => {
const cat = findNode(categories, section.categoryId);
const ids = cat ? subtreeIds(cat) : [section.categoryId];
const pages = await Promise.all(
ids.map((categoryId) => safe(getProducts({ categoryId, pageSize: 12 }), EMPTY_PAGE))
);
const seen = new Set<string>();
const products = pages
.flatMap((page) => page.items)
.filter((product) => !seen.has(product.id) && seen.add(product.id))
.slice(0, 12)
.map(productToCard);
return {
...section,
href: categoryHref(section.categoryId, cat?.nameFa ?? section.title, cat?.name),
products,
};
})
);
const posts: BlogCardData[] =
blogs.items.length > 0
? blogs.items.map((b) => ({
id: b.id,
href: blogHref(b.id, b.title ?? b.nameFa),
title: b.title ?? b.nameFa ?? "",
image: b.thumbnailUrl ?? b.image ?? null,
category: b.categoryNameFa ?? b.categoryName ?? undefined,
}))
: FALLBACK_BLOGS.map((b) => ({
id: b.id,
href: "/blog",
title: b.title,
image: b.image,
category: b.category,
}));
// The deals countdown runs to the end of the current day, recomputed on each
// server render so the timer never shows a stale target.
const endOfDay = new Date();
endOfDay.setHours(23, 59, 59, 999);
const [mobileCarousel, accessoriesCarousel, laptopCarousel, consoleCarousel] = carousels;
return (
<>
<h1 className="sr-only">فروشگاه موبایل معلم | خرید موبایل، لپ‌تاپ و لوازم جانبی</h1>
<HeroSlider slot={sliderSlot} slides={slides} />
<CategoryCarousel categories={carouselCats} slot={categoriesSlot} />
<HotDeals products={deals} endsAt={endOfDay.getTime() + 2 * 86_400_000} />
<ThreeBanners slot={threeBannerSlot} />
{mobileCarousel && (
<ProductCarousel
title={mobileCarousel.title}
desc={mobileCarousel.desc}
href={mobileCarousel.href}
products={mobileCarousel.products}
/>
)}
<TwoBanners slot={twoBannerSlot} />
{accessoriesCarousel && (
<ProductCarousel
title={accessoriesCarousel.title}
desc={accessoriesCarousel.desc}
href={accessoriesCarousel.href}
products={accessoriesCarousel.products}
/>
)}
{laptopCarousel && (
<ProductCarousel
title={laptopCarousel.title}
desc={laptopCarousel.desc}
href={laptopCarousel.href}
products={laptopCarousel.products}
/>
)}
<OneBanner slot={oneBannerSlot} />
{consoleCarousel && (
<ProductCarousel
title={consoleCarousel.title}
desc={consoleCarousel.desc}
href={consoleCarousel.href}
products={consoleCarousel.products}
/>
)}
<BlogCarousel posts={posts} />
</>
);
}
+105
View File
@@ -0,0 +1,105 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Breadcrumbs from "@/components/Breadcrumbs";
import ProductGallery from "@/components/ProductGallery";
import ProductBuyPanel from "@/components/ProductBuyPanel";
import ProductInfoTabs from "@/components/ProductInfoTabs";
import ProductCard from "@/components/ui/ProductCard";
import SectionHeader from "@/components/ui/SectionHeader";
import {
getComments,
getProductById,
getProductTechnicalInfo,
getStoreItemsByProduct,
safe,
} from "@/lib/api";
import { getSession } from "@/lib/auth";
import { buildSpecRows } from "@/lib/technicalInfo";
import { productToCard } from "@/lib/cards";
import { categoryHref } from "@/lib/slug";
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
try {
const { product } = await getProductById(id);
const title = product.nameFa || product.title;
return {
title,
description: (product.summary || title).slice(0, 160),
openGraph: product.thumbnailUrl ? { images: [{ url: product.thumbnailUrl }] } : undefined,
};
} catch {
return {};
}
}
export default async function ProductDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let data;
try {
data = await getProductById(id);
} catch {
notFound();
}
const { product, relatedProducts } = data;
const title = product.nameFa || product.title;
const [{ items: storeItems }, technicalInfo, { items: comments }, user] = await Promise.all([
safe(getStoreItemsByProduct(product.id), { items: [] }),
safe(getProductTechnicalInfo(product.id), { form: null, values: [] }),
safe(getComments("product", product.id), { items: [] }),
getSession(),
]);
const images = [
...(product.thumbnailUrl ? [product.thumbnailUrl] : []),
...product.images.map((img) => img.url),
].filter((url, i, all) => all.indexOf(url) === i);
const specRows = buildSpecRows(technicalInfo.form, technicalInfo.values);
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs
items={[
{ label: "محصولات", href: "/products" },
...(product.categoryId && product.categoryNameFa
? [{ label: product.categoryNameFa, href: categoryHref(product.categoryId, product.categoryNameFa) }]
: []),
{ label: title },
]}
/>
<div className="grid grid-cols-1 gap-8 md:gap-10 lg:grid-cols-[380px_1fr]">
<ProductGallery images={images} title={title} />
<ProductBuyPanel product={product} items={storeItems} user={user} />
</div>
<ProductInfoTabs
title={title}
descriptionHtml={product.descriptionHtml}
specRows={specRows}
comments={comments}
productId={product.id}
user={user}
/>
{relatedProducts.length > 0 && (
<div className="mt-14 md:mt-20">
<SectionHeader title="محصولات مشابه" href={product.categoryId ? categoryHref(product.categoryId, product.categoryNameFa) : "/products"} />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:gap-5 lg:grid-cols-4">
{relatedProducts.map((p) => (
<ProductCard key={p.id} product={productToCard(p)} />
))}
</div>
</div>
)}
</main>
);
}
@@ -0,0 +1,107 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Breadcrumbs from "@/components/Breadcrumbs";
import ProductCard from "@/components/ui/ProductCard";
import Pagination from "@/components/ui/Pagination";
import { getCategories, getProducts, safe } from "@/lib/api";
import { buildCategoryTree, findNode, pathToRoot, subtreeIdsAll } from "@/lib/categoryTree";
import { productToCard } from "@/lib/cards";
import { categoryHref } from "@/lib/slug";
const PAGE_SIZE = 24;
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const categories = buildCategoryTree(await safe(getCategories(), []));
const category = findNode(categories, id);
if (!category) return {};
const name = category.nameFa || category.name;
return {
title: name,
description: `خرید ${name} با بهترین قیمت از فروشگاه موبایل معلم.`,
};
}
export default async function CategoryPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ page?: string }>;
}) {
const { id } = await params;
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const roots = buildCategoryTree(await safe(getCategories(), []));
const category = findNode(roots, id);
if (!category) notFound();
const name = category.nameFa || category.name;
const crumbs = pathToRoot(roots, id);
// A parent category (e.g. «لوازم جانبی») has no products of its own —
// products are filed against leaves — so every id in the subtree is
// queried and the results merged, then paginated in memory.
const leafIds = subtreeIdsAll(category);
const perLeafPageSize = leafIds.length > 1 ? 200 : PAGE_SIZE;
const pages = await Promise.all(
leafIds.map((categoryId) =>
safe(getProducts({ categoryId, pageSize: perLeafPageSize }), {
items: [],
total: 0,
page: 1,
pageSize: perLeafPageSize,
})
)
);
const seen = new Set<string>();
const allProducts = pages
.flatMap((p) => p.items)
.filter((p) => !seen.has(p.id) && seen.add(p.id));
const total = allProducts.length;
const start = (page - 1) * PAGE_SIZE;
const items = allProducts.slice(start, start + PAGE_SIZE);
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs
items={[
{ label: "محصولات", href: "/products" },
...crumbs.map((c, i) => ({
label: c.nameFa || c.name,
href: i < crumbs.length - 1 ? categoryHref(c.id, c.nameFa, c.name) : undefined,
})),
]}
/>
<h1 className="mb-1 text-xl font-bold text-ink md:text-2xl">{name}</h1>
<p className="mb-8 text-[13px] text-body">{total.toLocaleString("fa-IR")} محصول</p>
{items.length > 0 ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:gap-5 lg:grid-cols-4 xl:grid-cols-5">
{items.map((p) => (
<ProductCard key={p.id} product={productToCard(p)} />
))}
</div>
) : (
<p className="rounded-xl border border-dashed border-line bg-surface py-16 text-center text-sm text-body">
محصولی در این دسته‌بندی ثبت نشده است.
</p>
)}
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={total}
buildHref={(p) => (p > 1 ? `${categoryHref(id, category.nameFa, category.name)}?page=${p}` : categoryHref(id, category.nameFa, category.name))}
/>
</main>
);
}
+62
View File
@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import Breadcrumbs from "@/components/Breadcrumbs";
import ProductCard from "@/components/ui/ProductCard";
import Pagination from "@/components/ui/Pagination";
import { getProducts, safe } from "@/lib/api";
import { productToCard } from "@/lib/cards";
const PAGE_SIZE = 24;
export const metadata: Metadata = {
title: "همه محصولات",
description: "فهرست کامل محصولات فروشگاه موبایل معلم — موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم جانبی.",
};
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; name?: string }>;
}) {
const params = await searchParams;
const page = Math.max(1, Number(params.page) || 1);
const name = params.name?.trim();
const result = await safe(getProducts({ page, pageSize: PAGE_SIZE, name }), {
items: [],
total: 0,
page,
pageSize: PAGE_SIZE,
});
const buildHref = (p: number) => {
const qs = new URLSearchParams();
if (name) qs.set("name", name);
if (p > 1) qs.set("page", String(p));
const query = qs.toString();
return `/products${query ? `?${query}` : ""}`;
};
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs items={[{ label: "محصولات" }]} />
<h1 className="mb-1 text-xl font-bold text-ink md:text-2xl">
{name ? `نتایج جستجوی «${name}»` : "همه محصولات"}
</h1>
<p className="mb-8 text-[13px] text-body">{result.total.toLocaleString("fa-IR")} محصول</p>
{result.items.length > 0 ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:gap-5 lg:grid-cols-4 xl:grid-cols-5">
{result.items.map((p) => (
<ProductCard key={p.id} product={productToCard(p)} />
))}
</div>
) : (
<p className="rounded-xl border border-dashed border-line bg-surface py-16 text-center text-sm text-body">
محصولی پیدا نشد.
</p>
)}
<Pagination page={page} pageSize={PAGE_SIZE} total={result.total} buildHref={buildHref} />
</main>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { ChevronDown, UserIcon } from "./icons";
import { BUSINESS_PORTAL_URL, CUSTOMER_PORTAL_URL } from "@/lib/config";
import { displayName, isAdmin } from "@/lib/authUser";
import type { AuthUser } from "@/lib/types";
// Desktop header account control. Anonymous: a plain link to the customer
// portal's login/register. Signed in: "نام عزیز" + a dropdown with پروفایل
// من / خروج, plus مدیریت وبسایت when the session looks like an admin's.
export default function AccountMenu({ user }: { user: AuthUser | null }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
if (!user) {
return (
<Link
href={CUSTOMER_PORTAL_URL}
className="flex h-11 items-center gap-2 rounded-xl border border-line px-3 text-[13px] transition hover:border-brand hover:text-brand"
>
<UserIcon className="size-5" />
<span className="hidden sm:inline">ورود | ثبت نام</span>
</Link>
);
}
return (
<div ref={ref} className="relative h-11">
<button
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex h-11 items-center gap-2 rounded-xl border border-line px-3 text-[13px] text-ink transition hover:border-brand hover:text-brand"
>
<UserIcon className="size-5" />
<span className="hidden max-w-[120px] truncate sm:inline">{displayName(user)}</span>
<ChevronDown className={`size-4 transition ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-2 w-44 overflow-hidden rounded-xl border border-line bg-white py-1 shadow-xl">
<Link
href={`${CUSTOMER_PORTAL_URL}/profile`}
className="block px-4 py-2.5 text-[13px] text-ink-soft transition hover:bg-surface hover:text-brand"
>
پروفایل من
</Link>
{isAdmin(user) && (
<Link
href={BUSINESS_PORTAL_URL}
className="block px-4 py-2.5 text-[13px] text-ink-soft transition hover:bg-surface hover:text-brand"
>
مدیریت وبسایت
</Link>
)}
<Link
href={`${CUSTOMER_PORTAL_URL}/logout`}
className="block px-4 py-2.5 text-[13px] text-ink-soft transition hover:bg-surface hover:text-brand"
>
خروج
</Link>
</div>
)}
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import type { Swiper as SwiperType } from "swiper";
import { FreeMode } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/free-mode";
import SectionHeader from "./ui/SectionHeader";
import CarouselNav from "./ui/CarouselNav";
export interface BlogCardData {
id: string;
href: string;
title: string;
image: string | null;
category?: string;
}
export default function BlogCarousel({ posts }: { posts: BlogCardData[] }) {
const [swiper, setSwiper] = useState<SwiperType | null>(null);
if (posts.length === 0) return null;
return (
<section className="boxed mt-10">
<SectionHeader
title="مجله معلم"
desc="تازه‌ترین راهنماهای خرید و اخبار دنیای دیجیتال"
href="/blog"
nav={<CarouselNav swiper={swiper} />}
/>
<Swiper
modules={[FreeMode]}
dir="rtl"
freeMode
onSwiper={setSwiper}
slidesPerView={1.3}
spaceBetween={16}
breakpoints={{
640: { slidesPerView: 2.3 },
1024: { slidesPerView: 4 },
}}
>
{posts.map((post) => (
<SwiperSlide key={post.id} className="h-auto">
<Link
href={post.href}
className="flex h-full flex-col overflow-hidden rounded-xl border border-line bg-white transition hover:border-brand/40 hover:shadow-[0_6px_20px_rgba(0,0,0,0.07)]"
>
<span className="relative block aspect-[16/9] w-full bg-surface">
{post.image && (
<Image
src={post.image}
alt={post.title}
fill
sizes="(max-width: 1024px) 60vw, 280px"
className="object-cover"
/>
)}
{post.category && (
<span className="absolute right-2 top-2 rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-bold text-brand">
{post.category}
</span>
)}
</span>
<span className="clamp-2 p-3 text-[13px] leading-6 text-ink-soft">{post.title}</span>
</Link>
</SwiperSlide>
))}
</Swiper>
<div className="mt-4 flex justify-center lg:hidden">
<CarouselNav swiper={swiper} />
</div>
</section>
);
}
+26
View File
@@ -0,0 +1,26 @@
import Link from "next/link";
import { ChevronLeft } from "./icons";
export interface Crumb {
label: string;
href?: string;
}
export default function Breadcrumbs({ items }: { items: Crumb[] }) {
return (
<nav className="mb-5 flex flex-wrap items-center gap-1.5 text-[12.5px] text-body" aria-label="مسیر صفحه">
{items.map((item, i) => (
<span key={i} className="flex items-center gap-1.5">
{i > 0 && <ChevronLeft className="size-3.5 opacity-60" />}
{item.href ? (
<Link href={item.href} className="transition hover:text-brand">
{item.label}
</Link>
) : (
<span className="text-ink-soft">{item.label}</span>
)}
</span>
))}
</nav>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { FreeMode } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/free-mode";
import { categoryHref } from "@/lib/slug";
import type { CategoryNode, StaticImageSlot } from "@/lib/types";
// Circular category thumbnails. The "categories" static-image slot has no
// per-category id, so its images (kind: list, duplicatable) are matched
// positionally to the category list, in the same order the business fills
// them in — each still links to its real category page, only the art swaps.
// A category past the end of the slot, or the whole thing when the slot is
// empty, keeps its placeholder at public/images/categories/{id}.svg.
export default function CategoryCarousel({
categories,
slot,
}: {
categories: CategoryNode[];
slot?: StaticImageSlot;
}) {
if (categories.length === 0) return null;
const slotImages = slot?.images ?? [];
return (
<section className="boxed mt-8" aria-label="دسته‌بندی محصولات">
<Swiper
modules={[FreeMode]}
dir="rtl"
freeMode
slidesPerView={3.5}
spaceBetween={12}
breakpoints={{
480: { slidesPerView: 4.5 },
768: { slidesPerView: 6.5 },
1024: { slidesPerView: 9 },
}}
>
{categories.map((cat, i) => {
const label = cat.nameFa || cat.name;
const art = slotImages[i]?.url || `/images/categories/${cat.id}.svg`;
return (
<SwiperSlide key={cat.id}>
<Link href={categoryHref(cat.id, cat.nameFa, cat.name)} className="group flex flex-col items-center gap-2">
<span className="relative size-[90px] overflow-hidden rounded-full bg-surface transition group-hover:bg-brand-soft">
<Image src={art} alt={label} fill sizes="90px" className="object-contain p-2" />
</span>
<span className="text-center text-[12.5px] text-ink transition group-hover:text-brand">
{label}
</span>
</Link>
</SwiperSlide>
);
})}
</Swiper>
</section>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { postComment } from "@/lib/api";
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
import { displayName } from "@/lib/authUser";
import type { AuthUser, EntityComment } from "@/lib/types";
// Comment list + form. The API's POST /comments doesn't itself require
// auth, but the brief asks the storefront to gate the form: signed-in
// visitors get a name-less textarea, anonymous visitors get a login prompt
// instead of the form.
export default function CommentSection({
entityType,
entityId,
initialComments,
user,
}: {
entityType: "product" | "blog";
entityId: string;
initialComments: EntityComment[];
user: AuthUser | null;
}) {
const [comments, setComments] = useState(initialComments);
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!user || !text.trim()) return;
setSubmitting(true);
setError(null);
try {
const { comment } = await postComment({
entityType,
entityId,
authorName: displayName(user).replace(/ عزیز$/, ""),
text: text.trim(),
});
setComments((prev) => [comment, ...prev]);
setText("");
} catch {
setError("ثبت دیدگاه ناموفق بود. لطفاً دوباره تلاش کنید.");
} finally {
setSubmitting(false);
}
}
return (
<div>
{user ? (
<form onSubmit={handleSubmit} className="mb-8">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="دیدگاه خود را بنویسید…"
rows={4}
className="w-full rounded-xl border border-line p-3 text-sm outline-none transition focus:border-brand"
/>
<div className="mt-2 flex items-center justify-between">
{error && <span className="text-[12px] text-brand">{error}</span>}
<button
type="submit"
disabled={submitting || !text.trim()}
className="mr-auto flex h-10 items-center rounded-lg bg-brand px-5 text-[13px] font-bold text-white transition hover:bg-brand-dark disabled:cursor-not-allowed disabled:opacity-40"
>
{submitting ? "در حال ارسال…" : "ثبت دیدگاه"}
</button>
</div>
</form>
) : (
<div className="mb-8 flex flex-col items-center gap-3 rounded-xl border border-dashed border-line bg-surface p-6 text-center">
<p className="text-sm text-body">برای ثبت دیدگاه ابتدا وارد حساب کاربری خود شوید.</p>
<Link
href={CUSTOMER_PORTAL_URL}
className="rounded-lg bg-brand px-5 py-2 text-[13px] font-bold text-white transition hover:bg-brand-dark"
>
ورود | ثبت‌نام
</Link>
</div>
)}
{comments.length > 0 ? (
<ul className="space-y-4">
{comments.map((c) => (
<li key={c.id} className="rounded-xl border border-line p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-ink">{c.authorName || "کاربر"}</span>
{c.createdAt && (
<span className="en text-xs text-body/60">{c.createdAt.slice(0, 10)}</span>
)}
</div>
{c.text && <p className="mt-2 text-sm leading-7 text-body">{c.text}</p>}
</li>
))}
</ul>
) : (
<p className="text-sm text-body">هنوز دیدگاهی ثبت نشده است.</p>
)}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import Link from "next/link";
import Image from "next/image";
import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME, SITE_TAGLINE } from "@/data/site";
import { categoryHref } from "@/lib/slug";
import type { CategoryNode } from "@/lib/types";
import { PhoneIcon } from "./icons";
import { toFaDigits } from "@/lib/format";
export default function Footer({ categories }: { categories: CategoryNode[] }) {
return (
<footer className="mt-14 border-t border-line bg-surface">
<div className="boxed grid gap-8 py-10 md:grid-cols-4">
<div className="md:col-span-2">
<Image src="/images/logo.png" alt={SITE_NAME} width={130} height={36} />
<p className="mt-3 max-w-md text-[13px] leading-7 text-body">{SITE_TAGLINE}</p>
<a
href={PHONE_HREF}
className="mt-4 inline-flex items-center gap-2 text-[13px] font-bold text-ink transition hover:text-brand"
>
<PhoneIcon className="size-4" />
{PHONE}
</a>
</div>
<div>
<h3 className="mb-3 text-[14px]">دسته‌بندی‌ها</h3>
<ul className="space-y-2">
{categories.slice(0, 6).map((cat) => (
<li key={cat.id}>
<Link
href={categoryHref(cat.id, cat.nameFa, cat.name)}
className="text-[13px] text-body transition hover:text-brand"
>
{cat.nameFa || cat.name}
</Link>
</li>
))}
</ul>
</div>
<div>
<h3 className="mb-3 text-[14px]">دسترسی سریع</h3>
<ul className="space-y-2">
{MAIN_NAV.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-[13px] text-body transition hover:text-brand">
{item.label}
</Link>
</li>
))}
</ul>
</div>
</div>
<div className="border-t border-line py-4 text-center text-[12px] text-body">
© {toFaDigits(1404)} {SITE_NAME} — تمامی حقوق محفوظ است.
</div>
</footer>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import SearchBox from "./SearchBox";
import AccountMenu from "./AccountMenu";
import { CartIcon, ChevronDown, GridIcon, PhoneIcon } from "./icons";
import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME } from "@/data/site";
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
import { categoryHref } from "@/lib/slug";
import type { AuthUser, CategoryNode } from "@/lib/types";
// Two rows: logo + search + account/cart, then the product mega menu and the
// main nav — the same split the reference storefront uses.
export default function Header({ menu, user }: { menu: CategoryNode[]; user: AuthUser | null }) {
const [megaOpen, setMegaOpen] = useState(false);
return (
<header className="sticky top-0 z-40 w-full border-b border-line bg-white">
<div className="boxed">
{/* Mobile keeps the header down to the search box alone — account,
cart and browsing live in the fixed bottom bar instead. */}
<div className="flex h-[70px] items-center gap-4">
<Link href="/" className="hidden shrink-0 lg:block" aria-label={SITE_NAME}>
<Image src="/images/logo.png" alt={SITE_NAME} width={130} height={36} priority />
</Link>
<div className="flex-1">
<SearchBox />
</div>
<div className="mr-auto hidden items-center gap-2 lg:flex">
<AccountMenu user={user} />
<Link
href={`${CUSTOMER_PORTAL_URL}/cart`}
className="flex h-11 items-center gap-2 rounded-xl bg-brand px-3 text-[13px] text-white transition hover:bg-brand-dark"
aria-label="سبد خرید"
>
<CartIcon className="size-5" />
<span className="font-bold">۰</span>
</Link>
</div>
</div>
<nav className="hidden h-12 items-center gap-6 text-[13px] lg:flex">
<div
className="relative h-full"
onMouseEnter={() => setMegaOpen(true)}
onMouseLeave={() => setMegaOpen(false)}
>
<button
className="flex h-full items-center gap-2 font-bold text-ink transition hover:text-brand"
aria-expanded={megaOpen}
>
<GridIcon className="size-5" />
منو محصولات
<ChevronDown className="size-4" />
</button>
{megaOpen && menu.length > 0 && (
<div className="absolute right-0 top-full z-50 w-[900px] rounded-b-2xl border border-line bg-white p-6 shadow-xl">
<div className="grid grid-cols-4 gap-x-6 gap-y-5">
{menu.map((cat) => (
<div key={cat.id}>
<Link
href={categoryHref(cat.id, cat.nameFa, cat.name)}
className="mb-2 block font-bold text-ink transition hover:text-brand"
>
{cat.nameFa || cat.name}
</Link>
<ul className="space-y-1.5">
{cat.children.slice(0, 6).map((child) => (
<li key={child.id}>
<Link
href={categoryHref(child.id, child.nameFa, child.name)}
className="text-[12.5px] text-body transition hover:text-brand"
>
{child.nameFa || child.name}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
)}
</div>
{MAIN_NAV.map((item) => (
<Link
key={item.href}
href={item.href}
className={`transition hover:text-brand ${
item.highlight ? "font-bold text-brand" : "text-ink-soft"
}`}
>
{item.label}
</Link>
))}
<a
href={PHONE_HREF}
className="mr-auto flex items-center gap-2 text-ink-soft transition hover:text-brand"
>
<PhoneIcon className="size-4" />
<span className="font-bold">{PHONE}</span>
</a>
</nav>
</div>
</header>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import type { CSSProperties } from "react";
import { Autoplay, Navigation, Pagination } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/pagination";
import { ChevronLeft, ChevronRight } from "./icons";
import { toCssAspectRatio } from "@/lib/format";
import type { StaticImageSlot } from "@/lib/types";
export interface Slide {
id: string;
image: string;
mobileImage?: string;
href: string;
alt: string;
}
// Default aspect ratios match the fallback banner assets: 700x400 mobile art,
// 1296x366 desktop, so neither crop loses the headline. Kept in sync with the
// "slider" entry in src/app/meshkee/static-image-slots/route.ts — see the
// note there: if this ratio ever changes, that entry must change with it.
const FALLBACK_ASPECT_CLASSES = "aspect-[7/4] md:aspect-[1296/366]";
// `slot` is the "slider" static-image slot (kind: list, duplicatable) — when
// it has images they win over the `slides` fallback prop entirely. A CMS
// slide is one image for every breakpoint (not a separate mobile crop), and
// slot.aspectRatio (if set) drives the frame instead of the built-in default.
export default function HeroSlider({ slot, slides = [] }: { slot?: StaticImageSlot; slides?: Slide[] }) {
const fromCms = (slot?.images.length ?? 0) > 0;
const items: Slide[] = fromCms
? slot!.images.map((img, i) => ({
id: img.id,
image: img.url,
href: img.linkUrl || "/products",
alt: img.titleFa || img.titleEn || `بنر فروشگاه موبایل معلم ${i + 1}`,
}))
: slides;
if (items.length === 0) return null;
const cssAspect = fromCms ? toCssAspectRatio(slot?.aspectRatio) : undefined;
const aspectStyle = cssAspect ? ({ aspectRatio: cssAspect } as CSSProperties) : undefined;
return (
<section className="boxed mt-5" aria-label="بنرهای اصلی">
<div className="relative overflow-hidden rounded-2xl">
<Swiper
modules={[Pagination, Navigation, Autoplay]}
dir="rtl"
loop={items.length > 1}
autoplay={{ delay: 5000, disableOnInteraction: false }}
pagination={{ clickable: true, el: ".hero-dots" }}
navigation={{ prevEl: ".hero-prev", nextEl: ".hero-next" }}
className={`w-full ${aspectStyle ? "" : FALLBACK_ASPECT_CLASSES}`}
style={aspectStyle}
>
{items.map((slide) => (
<SwiperSlide key={slide.id}>
<Link href={slide.href} className="relative block size-full">
{fromCms ? (
<Image src={slide.image} alt={slide.alt} fill priority sizes="100vw" className="object-cover" />
) : (
<>
<Image
src={slide.mobileImage || slide.image}
alt={slide.alt}
fill
priority
sizes="100vw"
className="object-cover md:hidden"
/>
<Image
src={slide.image}
alt={slide.alt}
fill
priority
sizes="1200px"
className="hidden object-cover md:block"
/>
</>
)}
</Link>
</SwiperSlide>
))}
</Swiper>
<div className="absolute bottom-4 left-4 z-10 flex items-center gap-2">
<button
className="hero-prev flex size-9 items-center justify-center rounded-full bg-white/90 text-ink shadow transition hover:bg-white"
aria-label="اسلاید قبلی"
>
<ChevronRight className="size-5" />
</button>
<button
className="hero-next flex size-9 items-center justify-center rounded-full bg-white/90 text-ink shadow transition hover:bg-white"
aria-label="اسلاید بعدی"
>
<ChevronLeft className="size-5" />
</button>
</div>
{/* Swiper's own .swiper-pagination rules set left:0/width:100% on this
element, so it is centred with justify-content rather than a
translate — a translate would fight those and push it off-canvas. */}
<div className="hero-dots absolute bottom-5 z-10 flex w-full justify-center gap-1.5" />
</div>
</section>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import type { Swiper as SwiperType } from "swiper";
import { FreeMode } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/free-mode";
import ProductCard from "./ui/ProductCard";
import CarouselNav from "./ui/CarouselNav";
import { ChevronLeft, FireIcon } from "./icons";
import { toFaDigits } from "@/lib/format";
import type { ProductCardData } from "@/lib/types";
const UNITS: { key: keyof Remaining; label: string }[] = [
{ key: "seconds", label: "ثانیه" },
{ key: "minutes", label: "دقیقه" },
{ key: "hours", label: "ساعت" },
{ key: "days", label: "روز" },
];
interface Remaining {
days: number;
hours: number;
minutes: number;
seconds: number;
}
function remainingUntil(target: number): Remaining {
const diff = Math.max(0, target - Date.now());
const total = Math.floor(diff / 1000);
return {
days: Math.floor(total / 86400),
hours: Math.floor((total % 86400) / 3600),
minutes: Math.floor((total % 3600) / 60),
seconds: total % 60,
};
}
// The countdown starts only after mount: rendering a live clock on the server
// would hydrate against a different second every time. The first value is set
// from a frame callback rather than the effect body so the state update stays
// out of the render pass.
function Countdown({ endsAt }: { endsAt: number }) {
const [left, setLeft] = useState<Remaining | null>(null);
useEffect(() => {
const tick = () => setLeft(remainingUntil(endsAt));
const frame = requestAnimationFrame(tick);
const id = setInterval(tick, 1000);
return () => {
cancelAnimationFrame(frame);
clearInterval(id);
};
}, [endsAt]);
return (
<div className="flex items-center gap-1.5" dir="ltr">
{UNITS.map(({ key, label }) => (
<div
key={key}
className="flex size-12 flex-col items-center justify-center rounded-lg bg-white/15 text-white"
>
<span className="text-[15px] font-bold leading-none">
{left ? toFaDigits(String(left[key]).padStart(2, "0")) : "--"}
</span>
<span className="mt-0.5 text-[9px] opacity-80">{label}</span>
</div>
))}
</div>
);
}
export default function HotDeals({
products,
endsAt,
href = "/products?special=1",
}: {
products: ProductCardData[];
endsAt: number;
href?: string;
}) {
const [swiper, setSwiper] = useState<SwiperType | null>(null);
if (products.length === 0) return null;
return (
<section className="boxed mt-10">
<div className="relative overflow-hidden rounded-2xl bg-brand p-5 lg:p-8">
{/* Faint dot grid so the flat brand block does not read as a plain box. */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.13]"
style={{
backgroundImage: "radial-gradient(#fff 1.5px, transparent 1.5px)",
backgroundSize: "22px 22px",
}}
aria-hidden
/>
<div className="relative flex flex-col gap-5 lg:flex-row lg:items-stretch">
<div className="flex shrink-0 flex-col justify-between gap-4 lg:w-[230px]">
<div>
<h2 className="flex items-center gap-2 text-[26px] font-black leading-tight text-white lg:text-[32px]">
<FireIcon className="size-7 shrink-0" />
تخفیف‌های داغ
</h2>
<p className="mt-1 text-[13px] text-white/85">تا پایان تایمر، بعدش قیمت‌ها برمی‌گرده</p>
</div>
<Countdown endsAt={endsAt} />
<div className="flex items-center justify-between gap-2">
<Link
href={href}
className="flex items-center gap-1 rounded-full bg-white px-4 py-2 text-[13px] font-bold text-brand transition hover:bg-white/90"
>
مشاهده همه
<ChevronLeft className="size-4" />
</Link>
<div className="hidden lg:block">
<CarouselNav swiper={swiper} tone="onBrand" />
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<Swiper
modules={[FreeMode]}
dir="rtl"
freeMode
onSwiper={setSwiper}
slidesPerView={2.1}
spaceBetween={12}
breakpoints={{
480: { slidesPerView: 2.6 },
768: { slidesPerView: 3.5 },
1024: { slidesPerView: 5 },
}}
>
{products.map((product) => (
<SwiperSlide key={product.id} className="h-auto">
<ProductCard product={product} />
</SwiperSlide>
))}
</Swiper>
</div>
</div>
</div>
</section>
);
}
+195
View File
@@ -0,0 +1,195 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { CartIcon, ChevronDown, GridIcon, UserIcon } from "./icons";
import { BUSINESS_PORTAL_URL, CUSTOMER_PORTAL_URL } from "@/lib/config";
import { categoryHref } from "@/lib/slug";
import { displayName, isAdmin } from "@/lib/authUser";
import type { AuthUser, CategoryNode } from "@/lib/types";
const HomeIcon = ({ className }: { className?: string }) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden
>
<path d="M3 10.2 12 3l9 7.2V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" />
</svg>
);
// Mobile navigation lives here instead of a hamburger in the header: a fixed
// bar with the four destinations that matter, and the category tree as a sheet
// so browsing never leaves the page.
export default function MobileBottomNav({
categories,
user,
}: {
categories: CategoryNode[];
user: AuthUser | null;
}) {
const [sheetOpen, setSheetOpen] = useState(false);
const [openId, setOpenId] = useState<string | null>(null);
const [accountOpen, setAccountOpen] = useState(false);
const accountRef = useRef<HTMLDivElement>(null);
useEffect(() => {
document.body.style.overflow = sheetOpen ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [sheetOpen]);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!accountRef.current?.contains(e.target as Node)) setAccountOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
const item = "flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px]";
return (
<>
{sheetOpen && (
<div className="fixed inset-0 z-50 lg:hidden" role="dialog" aria-label="دسته‌بندی محصولات">
<button
className="absolute inset-0 bg-black/40"
onClick={() => setSheetOpen(false)}
aria-label="بستن"
/>
<div className="absolute inset-x-0 bottom-0 max-h-[75vh] overflow-auto rounded-t-2xl bg-white pb-[76px]">
<div className="sticky top-0 flex items-center justify-between border-b border-line bg-white px-4 py-3">
<h2 className="text-[15px] font-bold">دسته‌بندی محصولات</h2>
<button
onClick={() => setSheetOpen(false)}
className="text-[13px] text-body"
aria-label="بستن"
>
بستن
</button>
</div>
<ul className="divide-y divide-line">
{categories.map((cat) => (
<li key={cat.id}>
<div className="flex items-center">
<Link
href={categoryHref(cat.id, cat.nameFa, cat.name)}
onClick={() => setSheetOpen(false)}
className="flex-1 px-4 py-3 text-[14px] text-ink"
>
{cat.nameFa || cat.name}
</Link>
{cat.children.length > 0 && (
<button
onClick={() => setOpenId(openId === cat.id ? null : cat.id)}
className="px-4 py-3 text-body"
aria-expanded={openId === cat.id}
aria-label={`زیرشاخه‌های ${cat.nameFa || cat.name}`}
>
<ChevronDown
className={`size-5 transition ${openId === cat.id ? "rotate-180" : ""}`}
/>
</button>
)}
</div>
{openId === cat.id && cat.children.length > 0 && (
<ul className="bg-surface px-4 py-2">
{cat.children.map((child) => (
<li key={child.id}>
<Link
href={categoryHref(child.id, child.nameFa, child.name)}
onClick={() => setSheetOpen(false)}
className="block py-2 text-[13px] text-body"
>
{child.nameFa || child.name}
</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
</div>
)}
<nav className="fixed inset-x-0 bottom-0 z-50 border-t border-line bg-white lg:hidden">
<div className="flex items-stretch">
<Link href="/" className={`${item} text-ink`}>
<HomeIcon className="size-6" />
خانه
</Link>
<Link href={`${CUSTOMER_PORTAL_URL}/cart`} className={`${item} text-ink`}>
<CartIcon className="size-6" />
سبد خرید
</Link>
<button
onClick={() => setSheetOpen((v) => !v)}
className={`${item} ${sheetOpen ? "text-brand" : "text-ink"}`}
aria-expanded={sheetOpen}
>
<GridIcon className="size-6" />
دسته‌بندی‌ها
</button>
{user ? (
<div ref={accountRef} className="relative flex-1">
{accountOpen && (
<div className="absolute inset-x-2 bottom-full mb-2 overflow-hidden rounded-xl border border-line bg-white py-1 shadow-xl">
<Link
href={`${CUSTOMER_PORTAL_URL}/profile`}
className="block px-4 py-2.5 text-[13px] text-ink-soft"
onClick={() => setAccountOpen(false)}
>
پروفایل من
</Link>
{isAdmin(user) && (
<Link
href={BUSINESS_PORTAL_URL}
className="block px-4 py-2.5 text-[13px] text-ink-soft"
onClick={() => setAccountOpen(false)}
>
مدیریت وبسایت
</Link>
)}
<Link
href={`${CUSTOMER_PORTAL_URL}/logout`}
className="block px-4 py-2.5 text-[13px] text-ink-soft"
onClick={() => setAccountOpen(false)}
>
خروج
</Link>
</div>
)}
<button
onClick={() => setAccountOpen((v) => !v)}
className={`${item} w-full ${accountOpen ? "text-brand" : "text-ink"}`}
aria-expanded={accountOpen}
>
<UserIcon className="size-6" />
<span className="max-w-[64px] truncate">{displayName(user)}</span>
</button>
</div>
) : (
<Link href={CUSTOMER_PORTAL_URL} className={`${item} text-ink`}>
<UserIcon className="size-6" />
ورود
</Link>
)}
</div>
</nav>
</>
);
}
+232
View File
@@ -0,0 +1,232 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { CartIcon, CheckIcon, RotateIcon, ShieldIcon, TruckIcon } from "./icons";
import { formatPrice } from "@/lib/format";
import { AUTH_TOKEN_COOKIE, CUSTOMER_PORTAL_URL } from "@/lib/config";
import { DEMO_PRICING, demoPrice } from "@/lib/demoPricing";
import type { ApiProduct, AuthUser, StoreItem } from "@/lib/types";
const trustPoints = [
{ icon: ShieldIcon, title: "ضمانت اصالت کالا" },
{ icon: TruckIcon, title: "ارسال سریع به سراسر کشور" },
{ icon: RotateIcon, title: "تا ۷ روز امکان مرجوعی" },
];
export default function ProductBuyPanel({
product,
items = [],
user,
}: {
product: ApiProduct;
items?: StoreItem[];
user: AuthUser | null;
}) {
const [selectedId, setSelectedId] = useState(items[0]?.id);
const [adding, setAdding] = useState(false);
const [added, setAdded] = useState(false);
const [error, setError] = useState<string | null>(null);
const selected = items.find((i) => i.id === selectedId) ?? items[0];
const title = product.nameFa || product.title;
const variationName = selected?.selections[0]?.variationName;
let price: number | null = null;
let oldPrice: number | null = null;
if (selected) {
price = selected.discountedPrice ?? selected.price;
oldPrice = selected.discountedPrice != null ? selected.price : null;
} else if (product.store.minPrice !== null) {
price = product.store.minPrice;
} else if (DEMO_PRICING) {
const demo = demoPrice(product);
price = demo.price;
oldPrice = demo.oldPrice;
}
const percent = price !== null && oldPrice !== null && oldPrice > price
? Math.round(((oldPrice - price) / oldPrice) * 100)
: 0;
// Purchasing needs a real store-item variant (cart items are added by
// storeItemVariantId, not productId) — until this product has one, there
// is nothing a bearer token could add to a cart, logged in or not.
const purchasable = items.length > 0 && Boolean(selected);
const inStock = selected ? selected.stockQuantity > 0 : product.store.inStock;
async function handleAddToCart() {
if (!selected || !user) return;
setAdding(true);
setError(null);
try {
const token = document.cookie
.split("; ")
.find((c) => c.startsWith(`${AUTH_TOKEN_COOKIE}=`))
?.split("=")[1];
if (!token) throw new Error("no-token");
const res = await fetch(
`https://api.meshkee.com/api/v1/businesses/${product.businessId}/cart/items`,
{
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ storeItemVariantId: selected.storeItemId, quantity: 1 }),
}
);
if (!res.ok) throw new Error(String(res.status));
setAdded(true);
setTimeout(() => setAdded(false), 1800);
} catch {
setError("افزودن به سبد خرید ناموفق بود. لطفاً دوباره تلاش کنید.");
} finally {
setAdding(false);
}
}
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_300px] md:gap-10">
<div>
<h1 className="text-xl font-bold text-ink md:text-2xl">{title}</h1>
{product.brand && <p className="mt-1.5 text-sm text-body">برند: {product.brand}</p>}
{product.summary && (
<div className="mt-5">
<p className="mb-1.5 text-sm font-bold text-ink">توضیح کوتاه:</p>
<p className="text-sm leading-7 text-body">{product.summary}</p>
</div>
)}
{items.length > 0 && (
<div className="mt-5">
<p className="mb-2.5 text-sm font-bold text-ink">{variationName || "انتخاب گزینه"}</p>
<div className="flex flex-wrap gap-2">
{items.map((item) => {
const rawValue = item.selections[0]?.value;
const colorHex = rawValue?.startsWith("#") ? rawValue : null;
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedId(item.id)}
disabled={item.stockQuantity <= 0}
title={item.label}
className={`flex items-center justify-center transition disabled:cursor-not-allowed disabled:opacity-40 ${
colorHex
? `size-9 rounded-full border-2 ${item.id === selectedId ? "border-brand" : "border-line"}`
: `h-10 rounded-lg border px-4 text-sm font-bold ${
item.id === selectedId
? "border-brand bg-brand text-white"
: "border-line text-ink hover:border-brand hover:text-brand"
}`
}`}
>
{colorHex ? (
<span className="block size-6 rounded-full" style={{ background: colorHex }} />
) : (
item.label
)}
</button>
);
})}
</div>
</div>
)}
<div className="mt-5 space-y-2 text-sm">
{product.categoryNameFa && (
<p className="text-body">
دسته‌بندی: <span className="font-bold text-ink">{product.categoryNameFa}</span>
</p>
)}
{product.tags.length > 0 && <p className="text-body">تگ‌ها: {product.tags.join("، ")}</p>}
</div>
</div>
<div className="h-fit space-y-4 lg:sticky lg:top-24">
<div className="rounded-2xl border border-line bg-white p-5">
<div className="mb-4 flex items-center justify-between border-b border-line pb-4">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-bold ${
inStock ? "bg-brand-soft text-brand" : "bg-surface text-body"
}`}
>
{inStock ? "آماده ارسال" : "ناموجود"}
</span>
<TruckIcon className="size-5 text-body" />
</div>
<div className="mb-1.5 flex items-center justify-between text-sm text-body">
<span>قیمت:</span>
{percent > 0 && (
<span className="rounded-md bg-brand-soft px-1.5 py-0.5 text-xs font-bold text-brand">
%{percent}
</span>
)}
</div>
<div className="mb-5 flex items-baseline justify-between gap-2">
{oldPrice !== null && oldPrice > (price ?? 0) && (
<span className="text-xs text-body line-through">{formatPrice(oldPrice)}</span>
)}
<span className="mr-auto text-xl font-bold text-ink md:text-2xl">
{price !== null ? (
<>
{formatPrice(price)}
<span className="mr-1 text-xs font-normal text-body">تومان</span>
</>
) : (
"تماس بگیرید"
)}
</span>
</div>
{!purchasable ? (
<p className="rounded-lg bg-surface px-3 py-2.5 text-center text-[12.5px] text-body">
این محصول هنوز برای خرید آنلاین فعال نشده است.
</p>
) : !user ? (
<Link
href={CUSTOMER_PORTAL_URL}
className="flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-brand text-sm font-bold text-white transition hover:bg-brand-dark"
>
برای خرید وارد شوید
</Link>
) : (
<button
type="button"
onClick={handleAddToCart}
disabled={!inStock || adding}
className={`flex h-12 w-full items-center justify-center gap-2 rounded-lg text-sm font-bold transition disabled:cursor-not-allowed disabled:opacity-40 ${
added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark"
}`}
>
{added ? (
<>
<CheckIcon className="size-4.5" />
افزوده شد
</>
) : (
<>
<CartIcon className="size-4.5" />
{adding ? "در حال افزودن…" : "افزودن به سبد خرید"}
</>
)}
</button>
)}
{error && <p className="mt-2 text-center text-[12px] text-brand">{error}</p>}
</div>
<div className="grid grid-cols-3 gap-4 rounded-2xl border border-dashed border-line p-5">
{trustPoints.map((p) => (
<div key={p.title} className="flex flex-col items-center gap-2 text-center">
<p.icon className="size-6 text-brand" />
<span className="text-[11px] leading-5 text-body">{p.title}</span>
</div>
))}
</div>
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
import type { Swiper as SwiperType } from "swiper";
import { FreeMode } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/free-mode";
import ProductCard from "./ui/ProductCard";
import SectionHeader from "./ui/SectionHeader";
import CarouselNav from "./ui/CarouselNav";
import type { ProductCardData } from "@/lib/types";
export default function ProductCarousel({
title,
desc,
href,
products,
}: {
title: string;
desc?: string;
href: string;
products: ProductCardData[];
}) {
const [swiper, setSwiper] = useState<SwiperType | null>(null);
if (products.length === 0) return null;
return (
<section className="boxed mt-10">
<SectionHeader title={title} desc={desc} href={href} nav={<CarouselNav swiper={swiper} />} />
<div className="relative">
<Swiper
modules={[FreeMode]}
dir="rtl"
freeMode
onSwiper={setSwiper}
slidesPerView={2.2}
spaceBetween={12}
breakpoints={{
480: { slidesPerView: 3.2 },
768: { slidesPerView: 4.2 },
1024: { slidesPerView: 6 },
}}
>
{products.map((product) => (
<SwiperSlide key={product.id} className="h-auto">
<ProductCard product={product} />
</SwiperSlide>
))}
</Swiper>
<div className="mt-4 flex justify-center lg:hidden">
<CarouselNav swiper={swiper} />
</div>
</div>
</section>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { ChevronLeft, ChevronRight, CloseIcon, ZoomIcon } from "./icons";
// Product/workshop-style media gallery: a square main image that magnifies
// under the cursor on hover (desktop only — this is a hover interaction, it
// simply does nothing on touch), plus a click-to-open full-screen lightbox
// with its own prev/next and thumbnail strip.
export default function ProductGallery({ images, title }: { images: string[]; title: string }) {
const gallery = images.length > 0 ? images : ["/images/products/placeholder.svg"];
const [active, setActive] = useState(0);
const [zoomPos, setZoomPos] = useState<{ x: number; y: number } | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
const prev = () => setActive((i) => (i - 1 + gallery.length) % gallery.length);
const next = () => setActive((i) => (i + 1) % gallery.length);
useEffect(() => {
if (!lightboxOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setLightboxOpen(false);
if (e.key === "ArrowLeft") next(); // RTL: visual left = next
if (e.key === "ArrowRight") prev();
};
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lightboxOpen]);
return (
<div>
<div
className="group relative aspect-square w-full cursor-zoom-in overflow-hidden rounded-xl border border-line bg-white"
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setZoomPos({
x: ((e.clientX - rect.left) / rect.width) * 100,
y: ((e.clientY - rect.top) / rect.height) * 100,
});
}}
onMouseLeave={() => setZoomPos(null)}
onClick={() => setLightboxOpen(true)}
>
<Image src={gallery[active]} alt={title} fill sizes="500px" className="object-contain p-4" />
{zoomPos && (
<div
className="pointer-events-none absolute inset-0 hidden bg-no-repeat lg:block"
style={{
backgroundImage: `url(${gallery[active]})`,
backgroundSize: "220%",
backgroundPosition: `${zoomPos.x}% ${zoomPos.y}%`,
}}
aria-hidden
/>
)}
<span className="absolute left-3 top-3 flex size-9 items-center justify-center rounded-full bg-white/90 text-ink opacity-0 shadow transition group-hover:opacity-100">
<ZoomIcon className="size-5" />
</span>
{gallery.length > 1 && (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
prev();
}}
aria-label="تصویر قبلی"
className="absolute top-1/2 start-3 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 text-ink shadow transition hover:bg-white"
>
<ChevronRight className="size-4.5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
next();
}}
aria-label="تصویر بعدی"
className="absolute top-1/2 end-3 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 text-ink shadow transition hover:bg-white"
>
<ChevronLeft className="size-4.5" />
</button>
</>
)}
</div>
{gallery.length > 1 && (
<div className="mt-3 flex flex-wrap gap-2.5">
{gallery.map((src, i) => (
<button
key={src + i}
type="button"
onClick={() => setActive(i)}
className={`relative size-16 shrink-0 overflow-hidden rounded-lg border-2 transition md:size-20 ${
i === active ? "border-brand" : "border-line"
}`}
>
<Image src={src} alt="" fill sizes="80px" className="object-contain p-1" />
</button>
))}
</div>
)}
{lightboxOpen && (
<div
className="fixed inset-0 z-[60] flex flex-col bg-black/95"
role="dialog"
aria-modal
aria-label={`گالری تصاویر ${title}`}
>
<div className="flex items-center justify-between p-4">
<span className="text-[13px] text-white/70">
{active + 1} / {gallery.length}
</span>
<button
onClick={() => setLightboxOpen(false)}
className="flex size-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
aria-label="بستن"
>
<CloseIcon className="size-5" />
</button>
</div>
<div className="relative flex-1">
<Image src={gallery[active]} alt={title} fill sizes="90vw" className="object-contain" />
{gallery.length > 1 && (
<>
<button
onClick={prev}
aria-label="تصویر قبلی"
className="absolute top-1/2 start-4 flex size-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
>
<ChevronRight className="size-5" />
</button>
<button
onClick={next}
aria-label="تصویر بعدی"
className="absolute top-1/2 end-4 flex size-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
>
<ChevronLeft className="size-5" />
</button>
</>
)}
</div>
{gallery.length > 1 && (
<div className="flex justify-center gap-2 overflow-x-auto p-4">
{gallery.map((src, i) => (
<button
key={src + i}
onClick={() => setActive(i)}
className={`relative size-14 shrink-0 overflow-hidden rounded-lg border-2 transition ${
i === active ? "border-brand" : "border-white/20"
}`}
>
<Image src={src} alt="" fill sizes="56px" className="object-contain" />
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useState } from "react";
import CommentSection from "./CommentSection";
import type { AuthUser, EntityComment, SpecRow } from "@/lib/types";
type Tab = "description" | "technical" | "reviews";
export default function ProductInfoTabs({
title,
descriptionHtml,
specRows,
comments,
productId,
user,
}: {
title: string;
descriptionHtml?: string;
specRows: SpecRow[];
comments: EntityComment[];
productId: string;
user: AuthUser | null;
}) {
const [tab, setTab] = useState<Tab>("description");
const tabs: { key: Tab; label: string }[] = [
{ key: "description", label: "توضیحات" },
{ key: "technical", label: "مشخصات فنی" },
{ key: "reviews", label: `دیدگاه کاربران${comments.length > 0 ? ` (${comments.length})` : ""}` },
];
return (
<div className="mt-14 md:mt-20">
<div className="flex flex-wrap gap-2 border-b border-line pb-px">
{tabs.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`-mb-px h-11 rounded-t-lg border-b-2 px-5 text-sm font-bold transition ${
tab === t.key ? "border-brand text-brand" : "border-transparent text-body hover:text-ink"
}`}
>
{t.label}
</button>
))}
</div>
<div className="pt-8">
{tab === "description" && (
<div>
<h2 className="mb-4 text-lg font-bold text-ink md:text-xl">معرفی {title}</h2>
{descriptionHtml ? (
<div
className="rich-text text-sm leading-8 text-body"
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
/>
) : (
<p className="text-sm text-body">توضیحی برای این محصول ثبت نشده است.</p>
)}
</div>
)}
{tab === "technical" && (
<div>
{specRows.length > 0 ? (
<dl className="divide-y divide-line overflow-hidden rounded-xl border border-line">
{specRows.map((row) => (
<div key={row.key} className="flex justify-between gap-4 px-4 py-3 text-sm odd:bg-surface">
<dt className="text-body">{row.label}</dt>
<dd className="font-bold text-ink">{row.value}</dd>
</div>
))}
</dl>
) : (
<p className="text-sm text-body">مشخصات فنی این محصول ثبت نشده است.</p>
)}
</div>
)}
{tab === "reviews" && (
<CommentSection entityType="product" entityId={productId} initialComments={comments} user={user} />
)}
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import Image from "next/image";
import Link from "next/link";
import type { CSSProperties } from "react";
import { toCssAspectRatio } from "@/lib/format";
import type { StaticImageSlot } from "@/lib/types";
// Shared renderer for the "one-banner-bg" / "two-banner-bg" / "three-banner-bg"
// static-image slots: url -> <img src>, linkUrl -> wrap in <a>, slot.aspectRatio
// drives the frame. An empty or failed slot keeps a flat, labelled placeholder
// (this site's usual stand-in for undesigned art, see
// public/images/categories/{id}.svg) instead of a fake photo, sized so the
// business/designer can see exactly what to fill in.
function BannerGrid({
slot,
count,
defaultAspect,
recommendedWidth,
cols,
}: {
slot?: StaticImageSlot;
count: number;
defaultAspect: string;
recommendedWidth: number;
cols: string;
}) {
const images = (slot?.images ?? []).slice(0, count);
const cssAspect = toCssAspectRatio(slot?.aspectRatio) ?? defaultAspect;
const aspectStyle = { aspectRatio: cssAspect } as CSSProperties;
const cells = Array.from({ length: count }, (_, i) => images[i]);
return (
<section className={`boxed mt-10`} aria-label="بنرهای تبلیغاتی">
<div className={`grid gap-3 ${cols}`}>
{cells.map((img, i) => {
if (!img) {
return (
<div
key={i}
className="flex flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-line bg-surface text-body"
style={aspectStyle}
>
<span className="text-[12px]">بنر تبلیغاتی</span>
<span className="text-[11px] opacity-70" dir="ltr">
{recommendedWidth}px · {cssAspect.replace("/", ":")}
</span>
</div>
);
}
const media = (
<Image
src={img.url}
alt={img.titleFa || img.titleEn || "بنر تبلیغاتی"}
fill
sizes={`(max-width: 768px) 100vw, ${recommendedWidth}px`}
className="object-cover"
/>
);
const frameClass = "relative block overflow-hidden rounded-xl bg-surface";
// Only wrap in <a> when the slot actually carries a link — an
// undesignated banner shouldn't become a dead click target.
return img.linkUrl ? (
<Link key={img.id} href={img.linkUrl} className={frameClass} style={aspectStyle}>
{media}
</Link>
) : (
<div key={img.id} className={frameClass} style={aspectStyle}>
{media}
</div>
);
})}
</div>
</section>
);
}
export function OneBanner({ slot }: { slot?: StaticImageSlot }) {
return <BannerGrid slot={slot} count={1} defaultAspect="16/5" recommendedWidth={1200} cols="grid-cols-1" />;
}
export function TwoBanners({ slot }: { slot?: StaticImageSlot }) {
return (
<BannerGrid slot={slot} count={2} defaultAspect="16/9" recommendedWidth={580} cols="grid-cols-1 md:grid-cols-2" />
);
}
export function ThreeBanners({ slot }: { slot?: StaticImageSlot }) {
return (
<BannerGrid slot={slot} count={3} defaultAspect="4/3" recommendedWidth={380} cols="grid-cols-1 sm:grid-cols-3" />
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { SearchIcon } from "./icons";
import { formatPrice } from "@/lib/format";
import { productHref } from "@/lib/slug";
import type { ApiProduct } from "@/lib/types";
const API = "https://api.meshkee.com/api/v1";
const DOMAIN = process.env.NEXT_PUBLIC_SITE_DOMAIN || "moalem.shop";
// Instant search: fires once the query passes 2 characters, debounced, and
// every in-flight request is abortable so a fast typist never sees an older
// response overwrite a newer one.
export default function SearchBox() {
const [query, setQuery] = useState("");
const [items, setItems] = useState<ApiProduct[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
// Clearing on a short query happens in the change handler, not here: the
// effect body must not call setState synchronously.
const onChange = (value: string) => {
setQuery(value);
if (value.trim().length < 3) {
setItems([]);
setLoading(false);
setOpen(false);
}
};
useEffect(() => {
const term = query.trim();
if (term.length < 3) return;
const controller = new AbortController();
const timer = setTimeout(async () => {
setLoading(true);
try {
const url = new URL(`${API}/tenants/${DOMAIN}/products`);
url.searchParams.set("name", term);
url.searchParams.set("pageSize", "6");
const res = await fetch(url, { signal: controller.signal });
const data = (await res.json()) as { items: ApiProduct[] };
setItems(data.items ?? []);
setOpen(true);
} catch {
/* aborted or offline — keep the previous list */
} finally {
setLoading(false);
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!boxRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
return (
<div ref={boxRef} className="relative w-full">
<div className="flex h-12 items-center gap-2 rounded-xl bg-surface px-4 text-sm">
<SearchIcon className="size-5 shrink-0 text-body" />
<input
value={query}
onChange={(e) => onChange(e.target.value)}
onFocus={() => items.length > 0 && setOpen(true)}
placeholder="جستجو براساس نام یا کد محصول"
className="h-full w-full bg-transparent outline-none placeholder:text-body"
aria-label="جستجوی محصولات"
/>
{loading && (
<span className="size-4 shrink-0 animate-spin rounded-full border-2 border-line border-t-brand" />
)}
</div>
{open && query.trim().length >= 3 && (
<div className="absolute inset-x-0 top-14 z-50 overflow-hidden rounded-xl border border-line bg-white shadow-xl">
{items.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-body">محصولی پیدا نشد.</p>
) : (
<ul className="max-h-96 overflow-auto">
{items.map((p) => (
<li key={p.id}>
<Link
href={productHref(p.id, p.nameFa, p.title)}
onClick={() => setOpen(false)}
className="flex items-center gap-3 px-3 py-2.5 transition hover:bg-surface"
>
<span className="relative size-12 shrink-0 overflow-hidden rounded-lg bg-surface">
{p.thumbnailUrl && (
<Image
src={p.thumbnailUrl}
alt={p.nameFa || p.title}
fill
sizes="48px"
className="object-contain"
/>
)}
</span>
<span className="min-w-0 flex-1">
<span className="clamp-2 text-[13px] leading-6">{p.nameFa || p.title}</span>
</span>
{p.store.minPrice !== null && (
<span className="shrink-0 text-[12px] font-bold text-ink">
{formatPrice(p.store.minPrice)}
</span>
)}
</Link>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
import { PROMO } from "@/data/site";
// Slim promo strip above the header — the reference storefront's
// `.header_banner_wrapper`, rebuilt as type instead of a baked GIF so the
// copy stays editable.
export default function TopBar() {
return (
<div className="w-full bg-brand text-white">
<div className="boxed flex h-10 items-center justify-center gap-3 text-[13px]">
<span className="truncate">{PROMO.text}</span>
<Link
href={PROMO.href}
className="hidden shrink-0 rounded-full bg-white/15 px-3 py-1 text-[12px] font-bold transition hover:bg-white/25 sm:inline-block"
>
{PROMO.cta}
</Link>
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
// Single-stroke line icons in the Iconsax style the reference storefront uses.
type P = { className?: string };
const base = {
fill: "none",
stroke: "currentColor",
strokeWidth: 1.6,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
viewBox: "0 0 24 24",
};
export const SearchIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<circle cx="11" cy="11" r="7" />
<path d="M20 20l-3-3" />
</svg>
);
export const UserIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-3.3 3.6-5.5 8-5.5s8 2.2 8 5.5" />
</svg>
);
export const CartIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M3 4h2l2.2 10.2A2 2 0 0 0 9.2 16h7.7a2 2 0 0 0 2-1.6L20.5 7H6" />
<circle cx="9.5" cy="20" r="1.4" />
<circle cx="17.5" cy="20" r="1.4" />
</svg>
);
export const GridIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<rect x="3" y="3" width="7.5" height="7.5" rx="2" />
<rect x="13.5" y="3" width="7.5" height="7.5" rx="2" />
<rect x="3" y="13.5" width="7.5" height="7.5" rx="2" />
<rect x="13.5" y="13.5" width="7.5" height="7.5" rx="2" />
</svg>
);
export const PhoneIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M21 16.5v2.2a2 2 0 0 1-2.2 2 18.6 18.6 0 0 1-8.1-2.9 18.3 18.3 0 0 1-5.6-5.6A18.6 18.6 0 0 1 2.2 4.1 2 2 0 0 1 4.2 2h2.2a2 2 0 0 1 2 1.7c.1.9.3 1.8.7 2.6a2 2 0 0 1-.5 2.1l-1 1a15 15 0 0 0 5.6 5.6l1-1a2 2 0 0 1 2.1-.5c.8.3 1.7.6 2.6.7a2 2 0 0 1 1.7 2z" />
</svg>
);
export const ChevronLeft = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M14.5 6l-6 6 6 6" />
</svg>
);
export const ChevronRight = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M9.5 6l6 6-6 6" />
</svg>
);
export const ChevronDown = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M6 9.5l6 6 6-6" />
</svg>
);
export const FireIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M12 3s5 3.6 5 8.4A5 5 0 0 1 12 21a5 5 0 0 1-5-4.9c0-2 1-3.4 1-3.4s.4 1.4 1.6 1.9c0-2.6 1-5.4 2.4-6.6 0 0-.3 2.4 1.3 3.5.5-1.6-.3-3.5-.3-3.5z" />
</svg>
);
export const CheckIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M4.5 12.5l5 5 10-11" />
</svg>
);
export const CloseIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M5 5l14 14M19 5L5 19" />
</svg>
);
export const ZoomIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<circle cx="10.5" cy="10.5" r="6.5" />
<path d="M15.2 15.2L20 20M8 10.5h5M10.5 8v5" />
</svg>
);
export const ShieldIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M12 3l7 3v5.2c0 4.6-3 7.7-7 9.8-4-2.1-7-5.2-7-9.8V6z" />
<path d="M9 12l2 2 4-4" />
</svg>
);
export const TruckIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M3 6h10v9H3zM13 10h4l3 3v2h-7z" />
<circle cx="7" cy="17.5" r="1.7" />
<circle cx="17" cy="17.5" r="1.7" />
</svg>
);
export const RotateIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M4 12a8 8 0 0 1 14-5.3M20 12a8 8 0 0 1-14 5.3" />
<path d="M17.5 3.5V7H14M6.5 20.5V17H10" />
</svg>
);
+38
View File
@@ -0,0 +1,38 @@
"use client";
import type { Swiper as SwiperType } from "swiper";
import { ChevronLeft, ChevronRight } from "../icons";
// Prev/next controls that drive a Swiper instance held by the parent, so a
// section can place them wherever its layout needs them.
export default function CarouselNav({
swiper,
tone = "light",
}: {
swiper: SwiperType | null;
tone?: "light" | "onBrand";
}) {
const style =
tone === "onBrand"
? "bg-white/20 text-white hover:bg-white/35"
: "border border-line bg-white text-ink hover:border-brand hover:text-brand";
return (
<div className="flex items-center gap-2">
<button
onClick={() => swiper?.slidePrev()}
className={`flex size-9 items-center justify-center rounded-full transition ${style}`}
aria-label="قبلی"
>
<ChevronRight className="size-5" />
</button>
<button
onClick={() => swiper?.slideNext()}
className={`flex size-9 items-center justify-center rounded-full transition ${style}`}
aria-label="بعدی"
>
<ChevronLeft className="size-5" />
</button>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import Link from "next/link";
import { toFaDigits } from "@/lib/format";
import { ChevronLeft, ChevronRight } from "../icons";
// Simple numbered pager. `buildHref` turns a page number into the URL for
// that page — callers own how query params are preserved (search, filters).
export default function Pagination({
page,
pageSize,
total,
buildHref,
}: {
page: number;
pageSize: number;
total: number;
buildHref: (page: number) => string;
}) {
const pageCount = Math.max(1, Math.ceil(total / pageSize));
if (pageCount <= 1) return null;
// A short window around the current page, plus first/last, keeps the
// control usable even with a few hundred pages.
const pages = new Set<number>([1, pageCount, page - 1, page, page + 1].filter((p) => p >= 1 && p <= pageCount));
const sorted = [...pages].sort((a, b) => a - b);
const linkClass = (active: boolean) =>
`flex h-9 min-w-9 items-center justify-center rounded-lg px-2 text-[13px] transition ${
active ? "bg-brand text-white" : "border border-line text-ink-soft hover:border-brand hover:text-brand"
}`;
return (
<nav className="mt-8 flex items-center justify-center gap-1.5" aria-label="صفحه‌بندی">
<Link
href={buildHref(Math.max(1, page - 1))}
aria-disabled={page <= 1}
className={`${linkClass(false)} ${page <= 1 ? "pointer-events-none opacity-40" : ""}`}
aria-label="صفحه قبل"
>
<ChevronRight className="size-4" />
</Link>
{sorted.map((p, i) => (
<span key={p} className="flex items-center gap-1.5">
{i > 0 && p - sorted[i - 1] > 1 && <span className="px-1 text-body">…</span>}
<Link href={buildHref(p)} className={linkClass(p === page)}>
{toFaDigits(p)}
</Link>
</span>
))}
<Link
href={buildHref(Math.min(pageCount, page + 1))}
aria-disabled={page >= pageCount}
className={`${linkClass(false)} ${page >= pageCount ? "pointer-events-none opacity-40" : ""}`}
aria-label="صفحه بعد"
>
<ChevronLeft className="size-4" />
</Link>
</nav>
);
}
+65
View File
@@ -0,0 +1,65 @@
import Image from "next/image";
import Link from "next/link";
import { formatPrice, toFaDigits } from "@/lib/format";
import type { ProductCardData } from "@/lib/types";
export default function ProductCard({ product }: { product: ProductCardData }) {
const { href, title, image, price, oldPrice, percent, inStock } = product;
const hasOldPrice = price !== null && oldPrice !== null && oldPrice > price;
return (
<Link
href={href}
className="flex h-full flex-col rounded-lg border border-line bg-white p-2 transition hover:border-brand/40 hover:shadow-[0_6px_20px_rgba(0,0,0,0.07)]"
>
<span className="relative mb-2 block aspect-square w-full overflow-hidden rounded-md bg-white">
{image ? (
<Image
src={image}
alt={title}
fill
sizes="(max-width: 768px) 45vw, 180px"
className="object-contain p-1"
/>
) : (
<span className="flex size-full items-center justify-center text-[11px] text-body">
بدون تصویر
</span>
)}
{percent > 0 && (
<span className="absolute right-1.5 top-1.5 rounded-full bg-brand px-2 py-0.5 text-[11px] font-bold text-white">
{toFaDigits(percent)}٪
</span>
)}
</span>
<span className="clamp-2 mb-3 min-h-[42px] px-1 text-[12.5px] leading-[21px] text-ink-soft">
{title}
</span>
{/* The old-price line always occupies its row — rendered empty when there
is no discount — so a discounted card is exactly as tall as the rest
and every card in a carousel lines up. */}
<span className="mt-auto flex h-[44px] flex-col items-start justify-end px-1 pb-1">
{price !== null && inStock ? (
<>
<span
className={`h-[17px] text-[11px] text-body ${hasOldPrice ? "line-through" : ""}`}
>
{hasOldPrice ? formatPrice(oldPrice) : ""}
</span>
<span className="text-[15px] font-bold text-ink">
{formatPrice(price)}
<span className="mr-1 text-[11px] font-normal text-body">تومان</span>
</span>
</>
) : (
<>
<span className="h-[17px]" />
<span className="text-[12px] text-body">ناموجود</span>
</>
)}
</span>
</Link>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { ChevronLeft } from "../icons";
// Title + one-line description on the right; on the left the "مشاهده همه" pill
// with the carousel arrows beside it (desktop only — on mobile the arrows move
// under the track, where the thumb is).
export default function SectionHeader({
title,
desc,
href,
nav,
}: {
title: string;
desc?: string;
href: string;
nav?: ReactNode;
}) {
return (
<div className="mb-4 flex items-end justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-ink">{title}</h2>
{desc && <p className="mt-1 text-[13px] text-body">{desc}</p>}
</div>
<div className="flex shrink-0 items-center gap-2">
{nav && <div className="hidden lg:flex">{nav}</div>}
<Link
href={href}
className="flex items-center gap-1 rounded-full border border-line px-4 py-2 text-[13px] text-ink-soft transition hover:border-brand hover:text-brand"
>
مشاهده همه
<ChevronLeft className="size-4" />
</Link>
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
// The blog module is enabled on this tenant but GET /blogs is still empty.
// These posts are the real ones published on moalem.shop/articles, kept as a
// fallback so the magazine section has content until the posts are imported.
// Delete this file once /tenants/moalem.shop/blogs returns items.
export interface FallbackBlog {
id: string;
title: string;
image: string;
category: string;
}
const S = "https://meshkee-storage.sas.amin.parminstorage.ir/moallem_store_2467";
export const FALLBACK_BLOGS: FallbackBlog[] = [
{
id: "ps5-games",
title: "لیست بهترین بازی های PS5 | از God of War تا FIFA و Call of Duty",
image: `${S}/1c626735dbcac8ba9eb4870abbf6db52/conversions/Best-ps5-games-2-thumbnail.jpg`,
category: "کنسول بازی",
},
{
id: "laptop-programming",
title: "بهترین لپتاپ برای برنامه نویسی | بررسی تخصصی مشخصات و مدل‌های پیشنهادی",
image: `${S}/53cefd2e98c16a12132c9a9291ba2eff/conversions/Best-Laptop-for-Programming-2-thumbnail.jpg`,
category: "لپ تاپ",
},
{
id: "iphone-apps",
title: "بهترین اپلیکیشن‌های آیفون ۲۰۲۶ | معرفی برنامه‌ها، بازی‌ها و راهنمای خرید اشتراک",
image: `${S}/271fd508674da9facca8c01c112d11e9/conversions/Best-iPhone-Apps-(Cover)-2-thumbnail.jpg`,
category: "موبایل",
},
{
id: "apple-id",
title: "تفاوت اپل آیدی شخصی و کارتی | مزایا، معایب و خطرات برای کاربران ایرانی",
image: `${S}/27a88efe1ad4a5af32b19e07472d3da9/conversions/Difference-between-personal-and-card-Apple-ID-2-thumbnail.jpg`,
category: "آموزش",
},
{
id: "best-smartwatch",
title: "راهنمای نهایی انتخاب بهترین ساعت هوشمند برای هر فرد",
image: `${S}/c0b028dcad0c8724b66f42af3c2799ea/conversions/Best-Smartwatch-2-thumbnail.jpg`,
category: "ساعت هوشمند",
},
{
id: "flagship-vs-midrange",
title: "گوشی پرچم‌دار یا میان‌رده؟ راهنمای انتخاب هوشمندانه بر اساس نیاز و بودجه",
image: `${S}/1a0fb6a0a25999aaa2ba5e44f1976b37/conversions/Difference-between-mid-range-and-flagship-phones-2-thumbnail.jpg`,
category: "راهنمای خرید",
},
{
id: "macbook-vs-windows",
title: "مقایسه واقعی مک بوک و ویندوز با بررسی تجربه کاربران و کاربردهای روزمره",
image: `${S}/de0311e65e2b183381c3a76a9a46da51/conversions/MacBook-vs-Windows-2-thumbnail.jpg`,
category: "لپ تاپ",
},
{
id: "ios-26",
title: "تحول واقعی iOS آغاز شد؛ چرا iOS 26 فقط یک آپدیت ساده نیست؟",
image: `${S}/6cd651d586d171994dc4b0867c74a499/conversions/iOS-26-(Cover)-2-thumbnail.jpg`,
category: "اخبار",
},
{
id: "iphone-17-pro-max",
title: "بررسی جامع آیفون ۱۷ پرو مکس؛ پرچم‌دار جدید اپل با جهشی بزرگ در فناوری",
image: `${S}/ca0aa580c6a0d73def3646e50cb6cf4f/conversions/%D8%A2%DB%8C%D9%81%D9%88%D9%86-17-%D9%BE%D8%B1%D9%88%D9%85%DA%A9%D8%B3-2-thumbnail.jpg`,
category: "موبایل",
},
{
id: "used-iphone-guide",
title: "راهنمای کامل خرید گوشی دست دوم اپل: نکاتی که قبل از خرید نباید از دست بدهید",
image: `${S}/76794f3bb9964508228367586e7c5ec3/conversions/%D8%AE%D8%B1%DB%8C%D8%AF-%DA%AF%D9%88%D8%B4%DB%8C-%D8%AF%D8%B3%D8%AA-%D8%AF%D9%88%D9%85-%D8%A7%D9%BE%D9%84-2-thumbnail.jpg`,
category: "راهنمای خرید",
},
];
+31
View File
@@ -0,0 +1,31 @@
// Content that is not (yet) in the CMS: nav labels, the promo strip and the
// contact block. Everything product-shaped comes from the Meshkee API.
export const SITE_NAME = "فروشگاه موبایل معلم";
export const SITE_TAGLINE = "مرجع خرید موبایل، لپ‌تاپ و لوازم جانبی";
export const PROMO = {
text: "ارسال رایگان برای خریدهای بالای ۵ میلیون تومان + پرداخت در محل",
cta: "مشاهده شرایط",
href: "/installments",
};
export const PHONE = "۰۲۱-۳۳۹۰۰۰۰۰";
export const PHONE_HREF = "tel:+982133900000";
export const MAIN_NAV = [
{ label: "برندها", href: "/brands" },
{ label: "تخفیف ویژه", href: "/products?special=1", highlight: true },
{ label: "مجله معلم", href: "/blog" },
{ label: "پیگیری سفارشات", href: "/orders" },
{ label: "تماس با ما", href: "/contact" },
{ label: "درباره ما", href: "/about" },
];
// Homepage product carousels. Each one is a real category id from
// GET /tenants/moalem.shop/categories?entityType=product.
export const HOME_CAROUSELS = [
{ categoryId: "1251", title: "موبایل", desc: "جدیدترین گوشی‌های روز بازار" },
{ categoryId: "1226", title: "لوازم جانبی", desc: "هر چیزی که گوشی و لپ‌تاپت نیاز داره" },
{ categoryId: "1254", title: "لپ تاپ", desc: "برای کار، بازی و دانشجویی" },
{ categoryId: "1256", title: "کنسول بازی", desc: "پلی‌استیشن، ایکس‌باکس و لوازم جانبی" },
];
+208
View File
@@ -0,0 +1,208 @@
import { API_BASE, SITE_DOMAIN } from "./config";
import type {
ApiBlog,
ApiCategory,
ApiProduct,
AuthUser,
BusinessInfo,
ContactFormInput,
EntityComment,
PagedResult,
ProductVariation,
SliderItem,
StaticImageSlot,
StoreItem,
StoreSpecial,
Tenant,
TechnicalInfoForm,
TechnicalInfoValue,
} from "./types";
type QueryParams = Record<string, string | number | boolean | undefined>;
async function apiGet<T>(path: string, params?: QueryParams): Promise<T> {
const url = new URL(`${API_BASE}${path}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
}
}
const res = await fetch(url.toString(), { next: { revalidate: 60 } });
if (!res.ok) {
throw new Error(`API request failed (${res.status}): ${path}`);
}
return res.json() as Promise<T>;
}
async function apiPost<T>(path: string, body: unknown, token?: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`API request failed (${res.status}): ${path}`);
}
return res.json() as Promise<T>;
}
// /auth/me is the one call in this file that needs a bearer token, so it
// bypasses apiGet's no-auth default rather than growing every call site an
// optional header param it will never use.
async function apiGetAuthed<T>(path: string, token: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (!res.ok) {
throw new Error(`API request failed (${res.status}): ${path}`);
}
return res.json() as Promise<T>;
}
// Swallows errors so one empty/broken section degrades instead of taking
// the whole page down — several modules on this tenant are still unfilled.
export async function safe<T>(promise: Promise<T>, fallback: T): Promise<T> {
try {
return await promise;
} catch {
return fallback;
}
}
const t = (path: string) => `/tenants/${SITE_DOMAIN}${path}`;
export function getTenant(): Promise<Tenant> {
return apiGet<Tenant>(t(""));
}
export function getBusinessInfo(): Promise<BusinessInfo> {
return apiGet<BusinessInfo>(t("/website/business-info"));
}
export function getSliders(): Promise<SliderItem[]> {
return apiGet<{ items: SliderItem[] }>(t("/website/sliders")).then((r) => r.items);
}
export function getStaticImageSlots(pageKey?: string): Promise<StaticImageSlot[]> {
return apiGet<{ items: StaticImageSlot[] }>(t("/website/static-images"), { pageKey }).then(
(r) => r.items
);
}
export function getCategories(
entityType: "product" | "blog" | "portfolio" | "video" = "product"
): Promise<ApiCategory[]> {
return apiGet<{ items: ApiCategory[] }>(t("/categories"), { entityType }).then((r) => r.items);
}
export function getCategoryById(id: string): Promise<ApiCategory> {
return apiGet<{ category: ApiCategory }>(t(`/categories/by-id/${id}`)).then((r) => r.category);
}
export function getProducts(
params: {
page?: number;
pageSize?: number;
name?: string;
categoryId?: string;
brandId?: string;
tag?: string;
inStore?: boolean;
} = {}
): Promise<PagedResult<ApiProduct>> {
return apiGet<PagedResult<ApiProduct>>(t("/products"), params);
}
export function getProductById(
id: string
): Promise<{ product: ApiProduct; relatedProducts: ApiProduct[] }> {
return apiGet(t(`/products/by-id/${id}`));
}
// Curated merchandising groups managed from the business dashboard
// ("special-discount", "top-selling", …). `source` repeats the tenant's
// specialProductsSource — store_item for this tenant.
export function getStoreSpecials(): Promise<StoreSpecial[]> {
return apiGet<{ source: string; items: StoreSpecial[] }>(t("/store-specials")).then(
(r) => r.items
);
}
export function getBlogs(
params: { page?: number; pageSize?: number; categoryId?: string; title?: string } = {}
): Promise<PagedResult<ApiBlog>> {
return apiGet<PagedResult<ApiBlog>>(t("/blogs"), params);
}
export function getBlogById(id: string): Promise<{ blog: ApiBlog }> {
return apiGet(t(`/blogs/by-id/${id}`));
}
export function getProductVariations(
productId: string
): Promise<{ variations: ProductVariation[] }> {
return apiGet(t(`/products/by-id/${productId}/variations`));
}
// Labels live in form.fields, values in values[] — join by fieldId. Never
// render values[] alone (see AGENTS.md "Technical specs").
export function getProductTechnicalInfo(
productId: string
): Promise<{ form: TechnicalInfoForm | null; values: TechnicalInfoValue[] }> {
return apiGet(t(`/products/by-id/${productId}/technical-info`));
}
// Not /store-items/by-product/{id} — that one returns a single { storeItem }
// (or null), not the full variant list a buy panel needs. The paginated
// /store-items list, filtered by productId, is the one that returns every
// purchasable variant.
export function getStoreItemsByProduct(productId: string): Promise<{ items: StoreItem[] }> {
return apiGet(t("/store-items"), { productId, pageSize: 100 });
}
export function getComments(
entityType: "product" | "blog" | "portfolio" | "video",
entityId: string
): Promise<{ items: EntityComment[] }> {
return apiGet(t("/comments"), { entityType, entityId });
}
// Public endpoint (no bearer required by the API), but the storefront gates
// the form behind login per the product-page brief — see ProductInfoTabs.
export function postComment(input: {
entityType: "product" | "blog" | "portfolio" | "video";
entityId: string;
authorName: string;
authorEmail?: string;
text: string;
}): Promise<{ comment: EntityComment }> {
return apiPost(t("/comments"), input);
}
// { user } from a bearer token — see src/lib/auth.ts for where the token
// comes from (a cookie the customer portal is expected to set).
export function getMe(token: string): Promise<{ user: AuthUser }> {
return apiGetAuthed("/auth/me", token);
}
export function submitContactForm(
data: ContactFormInput
): Promise<{ submission: unknown; message: string }> {
return apiPost(t("/contact-submissions"), data);
}
export function findSlot(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined {
const slot = slots.find((s) => s.key === key);
if (!slot || slot.images.length === 0) return undefined;
return {
...slot,
images: [...slot.images].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)),
};
}
+19
View File
@@ -0,0 +1,19 @@
import { cookies } from "next/headers";
import { getMe, safe } from "./api";
import { AUTH_TOKEN_COOKIE } from "./config";
import type { AuthUser } from "./types";
export { displayName, isAdmin } from "./authUser";
// Server-side "am I logged in" check for the header/account menu. See the
// AUTH_TOKEN_COOKIE comment in config.ts for the one external dependency
// this relies on. Resolves to null whenever the cookie is absent or /auth/me
// fails — never throws, so a missing session degrades to the anonymous view.
export async function getSession(): Promise<AuthUser | null> {
const store = await cookies();
const token = store.get(AUTH_TOKEN_COOKIE)?.value;
if (!token) return null;
const result = await safe(getMe(token), null);
return result?.user ?? null;
}
+16
View File
@@ -0,0 +1,16 @@
// Pure AuthUser helpers with no server-only dependency (unlike auth.ts,
// which reads next/headers) — safe to import from client components.
import type { AuthUser } from "./types";
// "علی عزیز" — first name (or the fullest name field available) + عزیز.
export function displayName(user: AuthUser): string {
const name = user.firstName || user.nameFa || user.name || "کاربر";
return `${name} عزیز`;
}
// Best-effort: the OpenAPI schema doesn't document a role/admin field on the
// user object, so this reads every plausible shape defensively instead of
// assuming one. Absent all of them, the admin menu item is simply hidden.
export function isAdmin(user: AuthUser): boolean {
return Boolean(user.isAdmin) || user.role === "admin" || user.role === "owner";
}
+52
View File
@@ -0,0 +1,52 @@
import { discountPercent } from "./format";
import { productHref } from "./slug";
import { DEMO_PRICING, demoPrice } from "./demoPricing";
import type { ApiProduct, ProductCardData, StoreSpecial } from "./types";
export function productToCard(product: ApiProduct): ProductCardData {
const base = {
id: product.id,
href: productHref(product.id, product.nameFa, product.title),
title: product.nameFa || product.title,
image: product.thumbnailUrl || product.image || product.thumbnail,
};
if (product.store.minPrice !== null) {
return { ...base, price: product.store.minPrice, oldPrice: null, percent: 0, inStock: product.store.inStock };
}
if (DEMO_PRICING) {
const { price, oldPrice } = demoPrice(product);
return {
...base,
price,
oldPrice,
percent: oldPrice ? discountPercent(price, oldPrice) : 0,
inStock: true,
};
}
return { ...base, price: null, oldPrice: null, percent: 0, inStock: false };
}
// A curated store-special group carries its own variant pricing, so the
// discount shown there is the real one rather than a product-level guess.
export function specialToCards(special: StoreSpecial): ProductCardData[] {
return special.items
.filter((item) => item.isActive)
.map((item) => {
const variant = item.variants.find((v) => v.discountedPrice !== null) ?? item.variants[0];
const price = variant?.discountedPrice ?? variant?.price ?? null;
const oldPrice = variant?.discountedPrice !== null ? variant?.price ?? null : null;
return {
id: item.id,
href: productHref(item.productId, item.productNameFa, item.productTitle),
title: item.productNameFa || item.productTitle,
image: item.productImage,
price,
oldPrice,
percent: price !== null && oldPrice !== null ? discountPercent(price, oldPrice) : 0,
inStock: (variant?.stockQuantity ?? 0) > 0,
};
});
}
+75
View File
@@ -0,0 +1,75 @@
import type { ApiCategory, CategoryNode } from "./types";
export function buildCategoryTree(categories: ApiCategory[]): CategoryNode[] {
const byId = new Map<string, CategoryNode>();
for (const c of categories) byId.set(c.id, { ...c, children: [] });
const roots: CategoryNode[] = [];
for (const node of byId.values()) {
const parent = node.parentId ? byId.get(node.parentId) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
const sort = (nodes: CategoryNode[]) => {
nodes.sort((a, b) => a.sortOrder - b.sortOrder || a.nameFa.localeCompare(b.nameFa, "fa"));
nodes.forEach((n) => sort(n.children));
};
sort(roots);
return roots;
}
// The category carousel shows level 2 (children of the single root
// "کالای دیجیتال"), which is what shoppers actually browse by.
export function carouselCategories(roots: CategoryNode[]): CategoryNode[] {
const level2 = roots.flatMap((r) => r.children);
return level2.length > 0 ? level2 : roots;
}
export function findNode(nodes: CategoryNode[], id: string): CategoryNode | undefined {
for (const node of nodes) {
if (node.id === id) return node;
const hit = findNode(node.children, id);
if (hit) return hit;
}
return undefined;
}
// Products are filed against leaf categories, so a parent like "لوازم جانبی"
// matches almost nothing on its own — a carousel for it has to ask for the
// whole subtree. Capped for the homepage's small carousels; a full category
// listing page needs every id, so use subtreeIdsAll there instead.
export function subtreeIds(node: CategoryNode, limit = 8): string[] {
const ids: string[] = [];
const walk = (n: CategoryNode) => {
if (ids.length >= limit) return;
ids.push(n.id);
n.children.forEach(walk);
};
walk(node);
return ids;
}
export function subtreeIdsAll(node: CategoryNode): string[] {
const ids: string[] = [];
const walk = (n: CategoryNode) => {
ids.push(n.id);
n.children.forEach(walk);
};
walk(node);
return ids;
}
// Root-first breadcrumb chain, e.g. [کالای دیجیتال, لوازم جانبی, هدفون].
export function pathToRoot(roots: CategoryNode[], id: string): CategoryNode[] {
const walk = (nodes: CategoryNode[], trail: CategoryNode[]): CategoryNode[] | null => {
for (const node of nodes) {
const nextTrail = [...trail, node];
if (node.id === id) return nextTrail;
const hit = walk(node.children, nextTrail);
if (hit) return hit;
}
return null;
};
return walk(roots, []) ?? [];
}
+24
View File
@@ -0,0 +1,24 @@
export const SITE_DOMAIN = process.env.NEXT_PUBLIC_SITE_DOMAIN || "moalem.shop";
export const API_BASE = "https://api.meshkee.com/api/v1";
export const SITE_URL = `https://${SITE_DOMAIN}`;
export const CUSTOMER_PORTAL_URL = `https://customer.${SITE_DOMAIN}`;
// Business dashboard — the AI brief reserves "business." alongside "api."
// and "customer." as a subdomain on every Meshkee site. Used only for the
// "مدیریت وبسایت" account-menu item shown to admins.
export const BUSINESS_PORTAL_URL = `https://business.${SITE_DOMAIN}`;
// Login happens on the customer portal (a separate app), not on this site.
// For the account menu to know a visitor is signed in, that portal needs to
// set this cookie on the shared parent domain (".{SITE_DOMAIN}", not
// HttpOnly) after login, so requests to this site carry it too. Until it
// does, getSession() in src/lib/auth.ts always resolves to null and the
// header simply shows the anonymous "ورود | ثبت‌نام" link — nothing breaks,
// the logged-in state just never activates.
export const AUTH_TOKEN_COOKIE = "meshkee_token";
// Pulled from the live moalem.shop theme (--wb-primary-color / --wb-secondary-color).
export const BRAND = {
primary: "#E51E22",
primaryDark: "#B10123",
secondary: "#3B3B3B",
} as const;
+48
View File
@@ -0,0 +1,48 @@
// TEMPORARY. This tenant has 479 products but zero store-items, so the API
// reports every product as priceless and out of stock and the homepage
// carousels would render as a wall of "ناموجود".
//
// While NEXT_PUBLIC_DEMO_PRICING=1 the cards fall back to a deterministic
// price derived from the product id (same product -> same number on server
// and client, so hydration stays stable). Real store-items always win.
// Delete this file and the env flag once pricing is entered in the dashboard.
import type { ApiProduct } from "./types";
export const DEMO_PRICING = process.env.NEXT_PUBLIC_DEMO_PRICING === "1";
// Sequential product ids ("4216", "4217") differ by one character, so the
// hash needs an avalanche step or neighbouring products land on the same price.
function hash(id: string): number {
let h = 2166136261;
for (let i = 0; i < id.length; i++) {
h ^= id.charCodeAt(i);
h = Math.imul(h, 16777619);
}
h ^= h >>> 15;
h = Math.imul(h, 2246822507);
h ^= h >>> 13;
return h >>> 0;
}
const BANDS: { match: RegExp; min: number; max: number }[] = [
{ match: /موبایل|گوشی|آیفون/, min: 12_000_000, max: 95_000_000 },
{ match: /لپ ?تاپ|مک ?بوک/, min: 35_000_000, max: 180_000_000 },
{ match: /تبلت/, min: 9_000_000, max: 60_000_000 },
{ match: /کنسول|پلی ?استیشن/, min: 25_000_000, max: 70_000_000 },
{ match: /ساعت|بند/, min: 1_500_000, max: 18_000_000 },
{ match: /هدفون|هندزفری|اسپیکر/, min: 800_000, max: 12_000_000 },
];
export function demoPrice(product: ApiProduct): { price: number; oldPrice: number | null } {
const name = product.nameFa || product.title;
const band = BANDS.find((b) => b.match.test(name)) ?? { min: 300_000, max: 4_000_000 };
const h = hash(product.id);
const span = band.max - band.min;
const raw = band.min + (h % span);
const price = Math.round(raw / 10_000) * 10_000;
// Roughly one product in three carries a discount, 5–25%.
const hasDiscount = h % 3 === 0;
const percent = 5 + (h % 21);
const oldPrice = hasDiscount ? Math.round((price / (1 - percent / 100)) / 10_000) * 10_000 : null;
return { price, oldPrice };
}
+23
View File
@@ -0,0 +1,23 @@
const FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
export function toFaDigits(input: string | number): string {
return String(input).replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
}
// Prices come from the API in Toman.
export function formatPrice(value: number | null | undefined): string {
if (value === null || value === undefined) return "";
return toFaDigits(value.toLocaleString("en-US"));
}
export function discountPercent(price: number, oldPrice: number): number {
if (!oldPrice || oldPrice <= price) return 0;
return Math.round(((oldPrice - price) / oldPrice) * 100);
}
// The static-image-slot API documents aspectRatio as "16:9", but CSS's
// aspect-ratio property needs a slash ("16/9"), not a colon.
export function toCssAspectRatio(value: string | number | null | undefined): string | undefined {
if (value === null || value === undefined || value === "") return undefined;
return String(value).replace(":", "/");
}
+24
View File
@@ -0,0 +1,24 @@
// Canonical Meshkee detail URLs are /{id}/{slug} — the slug segment is
// SEO-only, so it is built from the Farsi name rather than the DB slug.
export function faSlug(input: string | null | undefined): string {
if (!input) return "-";
return (
input
.trim()
.replace(/[\/\?%*:|"'<>.,()]/g, "")
.replace(/\s+/g, "-")
.slice(0, 80) || "-"
);
}
export function productHref(id: string, nameFa?: string | null, title?: string | null): string {
return `/products/${id}/${faSlug(nameFa || title)}`;
}
export function categoryHref(id: string, nameFa?: string | null, name?: string | null): string {
return `/products/category/${id}/${faSlug(nameFa || name)}`;
}
export function blogHref(id: string, title?: string | null): string {
return `/blog/${id}/${faSlug(title)}`;
}
+42
View File
@@ -0,0 +1,42 @@
import type { SpecRow, TechnicalInfoForm, TechnicalInfoValue } from "./types";
// technical-info values carry no label — only fieldId + a raw value. This
// joins them against form.fields (by id) to build the label→value rows the
// specs tab actually renders. See AGENTS.md "Technical specs" for why this
// join is required and what NOT to do (never read a label off values[]).
export function buildSpecRows(
form: TechnicalInfoForm | null | undefined,
values: TechnicalInfoValue[]
): SpecRow[] {
if (!form || values.length === 0) return [];
const fieldsById = new Map(form.fields.map((f) => [f.id, f]));
const rows: SpecRow[] = [];
for (const value of values) {
const field = fieldsById.get(value.fieldId);
if (!field) continue;
const label = field.labelFa || field.label || field.key || value.fieldId;
let display = "";
if (value.textValue) {
display = value.textValue;
} else if (value.optionId && field.options) {
const opt = field.options.find((o) => o.id === value.optionId);
display = opt ? opt.labelFa || opt.label || opt.id : "";
} else if (value.optionIds && field.options) {
display = value.optionIds
.map((id) => field.options?.find((o) => o.id === id))
.filter((o): o is NonNullable<typeof o> => Boolean(o))
.map((o) => o.labelFa || o.label || o.id)
.join("، ");
}
if (display) rows.push({ key: field.id, label, value: display });
}
return rows.sort(
(a, b) => (fieldsById.get(a.key)?.sortOrder ?? 0) - (fieldsById.get(b.key)?.sortOrder ?? 0)
);
}
+302
View File
@@ -0,0 +1,302 @@
export interface SocialMediaLinks {
whatsapp?: string;
telegram?: string;
instagram?: string;
linkedin?: string;
youtube?: string;
aparat?: string;
}
export interface Tenant {
id: number;
name: string;
nameFa: string;
slug: string;
domain: string;
primaryColor: string | null;
defaultLocale: string;
themeMode: string;
enabledModules: string[];
specialProductsSource: "product" | "store_item";
logoUrl: string | null;
logoDarkUrl: string | null;
faviconUrl: string | null;
}
// This tenant has no emails/phones/addresses filled in yet, so the loose
// shapes are read defensively at render time.
export interface BusinessInfo {
id: string;
name: string;
nameFa: string;
about: string;
vision: string;
logoUrl: string | null;
logoDarkUrl: string | null;
faviconUrl: string | null;
emails: unknown[];
phoneNumbers: unknown[];
socialMedia: SocialMediaLinks;
addresses: unknown[];
}
export interface ApiCategory {
id: string;
businessId: string;
entityType: string;
parentId: string | null;
name: string;
nameFa: string;
slug: string;
description: string | null;
sortOrder: number;
isActive: boolean;
variationCount: number;
}
export interface CategoryNode extends ApiCategory {
children: CategoryNode[];
}
export interface ProductImage {
mediaId: string;
url: string;
}
export interface ProductStoreSummary {
variantCount: number;
minPrice: number | null;
maxPrice: number | null;
inStock: boolean;
}
export interface ApiProduct {
id: string;
businessId: string;
title: string;
nameFa: string;
summary: string;
descriptionHtml: string;
slug: string;
status: string;
categoryId: string | null;
categoryName: string | null;
categoryNameFa: string | null;
brandId: string | null;
brand: string | null;
tags: string[];
thumbnailUrl: string | null;
image: string | null;
thumbnail: string | null;
images: ProductImage[];
commentCount: number;
variantCount: number;
createdAt: string;
updatedAt: string;
store: ProductStoreSummary;
}
export interface PagedResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
// The blog module is enabled but empty on this tenant, so every field is
// optional and consumers must read defensively.
export interface ApiBlog {
id: string;
title?: string;
nameFa?: string;
summary?: string;
descriptionHtml?: string;
contentHtml?: string;
slug: string;
thumbnailUrl?: string | null;
image?: string | null;
categoryName?: string | null;
categoryNameFa?: string | null;
createdAt?: string;
publishedAt?: string;
}
export interface SliderItem {
id?: string;
title?: string | null;
subtitle?: string | null;
imageUrl?: string | null;
mobileImageUrl?: string | null;
linkUrl?: string | null;
sortOrder?: number;
}
export interface StaticImageItem {
id: string;
url: string;
titleFa?: string | null;
titleEn?: string | null;
subtext?: string | null;
linkUrl?: string | null;
sortOrder?: number;
}
export interface StaticImageSlot {
key: string;
pageKey: string;
kind: "single" | "list";
label: string;
aspectRatio?: string | number | null;
recommendedWidth?: number | null;
itemCount?: number | null;
images: StaticImageItem[];
}
export interface StoreItemSelection {
variationId: string;
variationName: string;
optionId: string;
value: string;
}
export interface StoreSpecialVariant {
id: string;
label: string;
selections: StoreItemSelection[];
price: number | null;
discountedPrice: number | null;
stockQuantity: number;
isFestival: boolean;
sortOrder: number;
}
export interface StoreSpecialItem {
id: string;
productId: string;
productTitle: string;
productNameFa: string;
productImage: string | null;
isActive: boolean;
sortOrder: number;
variants: StoreSpecialVariant[];
}
export interface StoreSpecial {
id: string;
key: string;
title: string;
sortOrder: number;
isActive: boolean;
items: StoreSpecialItem[];
}
// A card the carousels render: a product plus the pricing actually shown.
export interface ProductCardData {
id: string;
href: string;
title: string;
image: string | null;
price: number | null;
oldPrice: number | null;
percent: number;
inStock: boolean;
}
export interface ContactFormInput {
title: string;
name: string;
text: string;
email?: string;
cellNumber?: string;
}
export interface VariationOption {
id: string;
label: string;
value: string;
colorHex: string | null;
sortOrder: number;
}
export interface ProductVariation {
id: string;
name: string;
type: string;
sortOrder: number;
options: VariationOption[];
}
// A concrete, purchasable variant of a product (one combination of
// variation options), as listed under /store-items.
export interface StoreItem {
id: string;
storeItemId: string;
productId: string;
productTitle: string;
productNameFa: string;
productImage: string | null;
productTotalStock: number;
selections: StoreItemSelection[];
label: string;
price: number | null;
discountedPrice: number | null;
stockQuantity: number;
rewardPoints: number | null;
isFestival: boolean;
sortOrder: number;
}
// Technical-info values carry no label (only fieldId + a value) — the label
// comes from the matching technical-info() form.fields entry. See
// AGENTS.md "Technical specs" for the required join.
export interface TechnicalInfoField {
id: string;
label?: string;
labelFa?: string;
key?: string;
type?: string;
sortOrder?: number;
options?: { id: string; label?: string; labelFa?: string }[];
}
export interface TechnicalInfoForm {
fields: TechnicalInfoField[];
}
export interface TechnicalInfoValue {
fieldId: string;
textValue?: string | null;
optionId?: string | null;
optionIds?: string[] | null;
}
// A joined, display-ready spec row — built by src/lib/technicalInfo.ts from
// the raw form + values, never rendered from values[] alone.
export interface SpecRow {
key: string;
label: string;
value: string;
}
// Comment/user shapes aren't documented beyond a few fields, so every
// optional field is read defensively at render time.
export interface EntityComment {
id: string;
authorName?: string;
text?: string;
createdAt?: string;
}
// GET /auth/me → { user }. Field names beyond id/name aren't in the OpenAPI
// schema, so this is intentionally loose and every field but id is read
// defensively (see src/lib/auth.ts).
export interface AuthUser {
id: string;
name?: string;
nameFa?: string;
firstName?: string;
lastName?: string;
cellNumber?: string;
isAdmin?: boolean;
role?: string;
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}