first commit
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "moalem-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.env*.local
|
||||
next-env.d.ts
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -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
|
||||
```
|
||||
@@ -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).
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -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 |
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
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/`);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: "راهنمای خرید",
|
||||
},
|
||||
];
|
||||
@@ -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: "پلیاستیشن، ایکسباکس و لوازم جانبی" },
|
||||
];
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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, []) ?? [];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(":", "/");
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||