Add about/contact/installments pages, fix category slot bugs, wire live branding

- Enlarge category carousel thumbnails (90px -> 104-140px responsive) and
  confirm the slider/categories/banner static-image slots are all reading
  correctly from the CMS (verified live, not a wiring bug)
- Fix installments page dropping "هندزفری و هدفون": it's a level-3 category,
  not level-2, so level2.find() silently missed it - use findNode() over the
  whole tree instead
- Extend gen-category-placeholders.mjs to generate a placeholder for every
  category at every level (was level-2 only), needed for the fix above
- Add /about, /contact, /installments pages
  - About: real copy transcribed from the live moalem.shop theme, real
    product photos from the API, text-only brand strip (no scraped logos)
  - Contact: ContactForm.tsx posts to the real POST /contact-submissions,
    branch cards with key-less Google Maps embeds
  - Installments: flat brand-colored hero (no photo/gradient), category
    highlights reusing the same categories slot images as the homepage
- Replace the placeholder header/footer phone number with the real one, add
  real branch addresses and Instagram/Telegram links (src/data/site.ts),
  pulled from the live theme's footer
- Wire logo/favicon to GET /website/favicon (getFavicon() in api.ts) instead
  of the static bundled files, so a dashboard upload now shows automatically;
  layout.tsx metadata is now generateMetadata() to support this. The tenant's
  uploaded logo/favicon currently belongs to a different business - the
  wiring is correct and will self-correct once that's fixed in the dashboard

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
amirhosein.ashourloo
2026-09-06 07:22:54 +03:30
co-authored by Claude Sonnet 5
parent 09a409b3e7
commit db7d00e473
46 changed files with 913 additions and 54 deletions
+40 -8
View File
@@ -19,7 +19,7 @@ fallbacks should update it.
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)
### What the tenant actually returns today (2026-09-06)
| Endpoint | State |
|---|---|
@@ -27,9 +27,10 @@ fallbacks should update it.
| `/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** |
| `/website/sliders`, `/website/category-groups` | **empty** |
| `/website/static-images` | **filled** — `slider` (2), `categories` (8 of 15), `one/two/three-banner-bg` all have real images. Verified live and rendering correctly; if a future check finds otherwise, suspect a stale build/deploy before suspecting the wiring. |
| `/blogs` | **empty** (module enabled) |
| `/website/business-info`, `/website/favicon` | present but blank (no logo, no phone, no socials) |
| `/website/business-info` | phones/addresses/socialMedia still empty; `logoUrl`/`faviconUrl` are now set but point to a **different business's** mark (see "Brand") |
| `/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) |
@@ -38,18 +39,31 @@ fallbacks should update it.
| 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 |
| No category art past slot position 8 | placeholder SVGs in `public/images/categories/{id}.svg`, generated by `scripts/gen-category-placeholders.mjs` for every category at every level, not just level-2 | the `categories` slot has an image for that position |
| Wrong logo/favicon in API | `getFavicon()` is wired live (see "Brand") but the uploaded asset itself is wrong; no code fallback needed once the dashboard upload is fixed | the correct asset is uploaded |
## Brand
Taken from the live moalem.shop theme, not invented:
Colors 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).
- Logo/favicon: `getFavicon()` in `src/lib/api.ts` reads
`GET /tenants/moalem.shop/website/favicon` — the documented single source —
and `Header.tsx`/`Footer.tsx`/`generateMetadata()` in `layout.tsx` all use
it, falling back to the bundled `/images/logo.png` / `/favicon.png` only
when the API field is null. **As of 2026-09-06 the dashboard has a logo/
favicon uploaded, but it's the wrong business's** (a home-appliances store's
mark, not موبایل معلم) — the wiring is correct and will pick up the right
asset automatically the moment it's replaced in the dashboard; don't
"fix" this by hardcoding the local fallback again.
- Contact data (phone, mobile, branch addresses, Instagram/Telegram) in
`src/data/site.ts` is real, pulled from the live theme's footer — `GET
/website/business-info` still returns empty `phoneNumbers`/`addresses`/
`socialMedia` for this tenant, so these stay hand-maintained here until
that's filled in. Update both together when it is.
## Layout conventions
- Content width `--container-boxed: 1200px`, applied with the `boxed` utility.
@@ -127,6 +141,24 @@ layout): hero → categories → hot deals → **three-banner-bg** → موبا
carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **one-banner-bg** →
کنسول بازی → مجله.
## Static pages
- `/about` — real copy transcribed from the live moalem.shop theme's
about-us module (verified against its cached HTML, not invented); brand
strip is plain text wordmarks (no scraped logo images — avoids a
trademark/asset-licensing headache for names we don't have real logo files
for) with real product photos pulled from the API.
- `/contact` — `ContactForm.tsx` posts to the real `POST /contact-submissions`
(no bearer required); branch cards use `BRANCHES` from `src/data/site.ts`
plus a key-less Google Maps `output=embed` iframe per branch (no API key
needed for a query-based embed).
- `/installments` — flat, brand-colored hero (no photo, no gradient — this
site stays flat) instead of the reference design's photographic banner;
category highlights reuse the same `categories` static-image slot images
as the homepage carousel, matched by the same positional index. One
reference category ("خانه هوشمند") has no equivalent on this tenant and is
swapped for "لوازم برقی" — see `HIGHLIGHT_IDS` in the page for the mapping.
## Product / blog pages
- `/products` — full catalog, `?name=` search, `?page=`.
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="کابل و رابط">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">کابل و را</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1227</text>
</svg>

After

Width:  |  Height:  |  Size: 583 B

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

After

Width:  |  Height:  |  Size: 581 B

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

After

Width:  |  Height:  |  Size: 583 B

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

After

Width:  |  Height:  |  Size: 587 B

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

After

Width:  |  Height:  |  Size: 603 B

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

After

Width:  |  Height:  |  Size: 592 B

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

After

Width:  |  Height:  |  Size: 589 B

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

After

Width:  |  Height:  |  Size: 583 B

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

After

Width:  |  Height:  |  Size: 588 B

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

After

Width:  |  Height:  |  Size: 594 B

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

After

Width:  |  Height:  |  Size: 594 B

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

After

Width:  |  Height:  |  Size: 594 B

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

After

Width:  |  Height:  |  Size: 588 B

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

After

Width:  |  Height:  |  Size: 589 B

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

After

Width:  |  Height:  |  Size: 581 B

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

After

Width:  |  Height:  |  Size: 567 B

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

After

Width:  |  Height:  |  Size: 587 B

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

After

Width:  |  Height:  |  Size: 567 B

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

After

Width:  |  Height:  |  Size: 563 B

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

After

Width:  |  Height:  |  Size: 597 B

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

After

Width:  |  Height:  |  Size: 569 B

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

After

Width:  |  Height:  |  Size: 593 B

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

After

Width:  |  Height:  |  Size: 589 B

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

After

Width:  |  Height:  |  Size: 589 B

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

After

Width:  |  Height:  |  Size: 593 B

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

After

Width:  |  Height:  |  Size: 596 B

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

After

Width:  |  Height:  |  Size: 596 B

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

After

Width:  |  Height:  |  Size: 581 B

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

After

Width:  |  Height:  |  Size: 595 B

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

After

Width:  |  Height:  |  Size: 579 B

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

After

Width:  |  Height:  |  Size: 587 B

+13 -7
View File
@@ -1,6 +1,9 @@
// 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.
// Writes one placeholder thumbnail per product category (every level, not
// just level-2) into public/images/categories/{id}.svg. They are meant to be
// replaced with real artwork one file at a time — the carousel and any other
// page that links a specific category id (e.g. /installments) read the same
// paths either way, so every id needs a file, not just the level-2 ones the
// homepage carousel shows by default.
// Run: node scripts/gen-category-placeholders.mjs
import { mkdir, writeFile } from "node:fs/promises";
@@ -12,14 +15,17 @@ const res = await fetch(
);
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));
// Root categories (parentId === null) have no icon of their own on this
// site — nothing links directly to "کالای دیجیتال" itself — so only their
// descendants get a placeholder.
const roots = new Set(items.filter((c) => c.parentId === null).map((c) => c.id));
const withIcons = items.filter((c) => !roots.has(c.id));
await mkdir(OUT, { recursive: true });
const escape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
for (const cat of level2) {
for (const cat of withIcons) {
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"/>
@@ -31,4 +37,4 @@ for (const cat of level2) {
await writeFile(new URL(`${cat.id}.svg`, OUT), svg, "utf8");
}
console.log(`wrote ${level2.length} placeholders to public/images/categories/`);
console.log(`wrote ${withIcons.length} placeholders to public/images/categories/`);
+141
View File
@@ -0,0 +1,141 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import Breadcrumbs from "@/components/Breadcrumbs";
import { CheckIcon } from "@/components/icons";
import { FALLBACK_BLOGS } from "@/data/fallbackBlogs";
export const metadata: Metadata = {
title: "درباره ما",
description:
"فروشگاه اینترنتی موبایل معلم، بیش از دو دهه تجربه در عرضه موبایل، لپ‌تاپ، کنسول بازی و لوازم جانبی دیجیتال با ضمانت اصالت کالا.",
};
// Real copy transcribed from the live moalem.shop theme's about-us module —
// see the paragraph match confirmed against the cached homepage HTML.
const INTRO =
"فروشگاه اینترنتی موبایل معلم با گستره‌ای از کالاهای متنوع در زمینه دیجیتال، برای کاربران خود «تجربه‌ای لذت‌بخش از یک خرید اینترنتی» را تداعی می‌کند. ارسال سریع، ضمانت بهترین قیمت و تضمین اصل بودن کالا، سه اصل اولیه‌ای است که ما از نخستین روز سعی کرده‌ایم به آن پایبند باشیم. فروشگاه موبایل معلم فعالیت خود را از سال ۱۳۸۰ با دو دهه تجربه در صنف موبایل آغاز نمود. این شرکت مفتخر است که با بهره‌گیری از کادری متخصص و مجرب، توانسته گام‌های بلندی در زمینه تامین و پخش تلفن همراه، لپ‌تاپ، کنسول بازی، دوربین دیجیتال و انواع تجهیزات و اکسسوری کالای دیجیتال بردارد و سهم شایسته‌ای در حوزه موبایل و رایانه را به خود اختصاص دهد.";
const ACTIVITIES = [
"عرضه انواع گوشی‌های موبایل و تبلت برندهای اصلی",
"عرضه و فروش انواع لپ‌تاپ‌های ایسوس و لنوو",
"عرضه انواع کنسول بازی و دوربین‌های دیجیتال",
"فروش و عرضه انواع اکسسوری موبایل در بندهای مختلف",
"ارائه پشتیبانی و خدمات پس از فروش مناسب با نیاز مشتریان",
"تعمیرات تخصصی انواع لپ‌تاپ، موبایل و…",
"فروش اقساطی تمامی کالاهای دیجیتال",
"فروش آنلاین به همکاران عزیز و مشتریان گرامی",
];
const BRANDS = ["Samsung", "Apple", "Xiaomi", "Huawei", "Asus", "Lenovo", "Nokia"];
export default function AboutPage() {
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs items={[{ label: "درباره ما" }]} />
<h1 className="mb-8 text-xl font-bold text-ink md:text-2xl">درباره ما</h1>
{/* Intro */}
<section className="grid grid-cols-1 items-center gap-8 rounded-2xl border border-line bg-surface p-6 md:grid-cols-2 md:p-10">
<div>
<span className="inline-flex items-center rounded-full bg-brand px-3 py-1.5 text-[12px] font-bold text-white">
بیش از ۱۰۰+ فروش موفق
</span>
<h2 className="mt-4 text-lg font-bold text-ink md:text-xl">فروشگاه موبایل معلم را بیشتر بشناسید</h2>
<p className="mt-4 text-[13.5px] leading-8 text-body">{INTRO}</p>
</div>
<div className="relative aspect-square w-full overflow-hidden rounded-2xl bg-white">
<Image
src="https://c804387.parspack.net/c804387/meshkee/businesses/27/media/e5dfb9fe-8910-4ce7-a43a-6a66e327bbf6.jpg"
alt="گوشی موبایل از فروشگاه موبایل معلم"
fill
sizes="(max-width: 768px) 100vw, 500px"
className="object-contain p-6"
/>
</div>
</section>
{/* Brand strip */}
<section className="my-10 flex flex-wrap items-center justify-center gap-x-10 gap-y-4 opacity-60 grayscale">
{BRANDS.map((b) => (
<span key={b} className="text-lg font-bold text-ink-soft">
{b}
</span>
))}
</section>
{/* Activities */}
<section className="grid grid-cols-1 items-center gap-8 md:grid-cols-2">
<div className="relative order-2 aspect-square w-full overflow-hidden rounded-2xl bg-surface md:order-1">
<Image
src="https://c804387.parspack.net/c804387/meshkee/businesses/27/media/a089678c-b194-4d2c-bf4d-b920208ac2e7.jpg"
alt="ساعت هوشمند از فروشگاه موبایل معلم"
fill
sizes="(max-width: 768px) 100vw, 500px"
className="object-contain p-6"
/>
</div>
<div className="order-1 md:order-2">
<h2 className="text-lg font-bold text-ink md:text-xl">از ما بیشتر بدانید</h2>
<p className="mt-2 text-[13px] text-body">زمینه فعالیت شرکت به شرح زیر می‌باشد:</p>
<ul className="mt-5 space-y-3">
{ACTIVITIES.map((item, i) => (
<li key={i} className="flex items-start gap-2.5 text-[13.5px] leading-7 text-ink-soft">
<CheckIcon className="mt-0.5 size-4 shrink-0 text-brand" />
{item}
</li>
))}
</ul>
<Link
href="/contact"
className="mt-6 inline-flex items-center gap-2 rounded-full bg-brand px-6 py-3 text-[13px] font-bold text-white transition hover:bg-brand-dark"
>
همین حالا با ما تماس بگیرید
</Link>
</div>
</section>
{/* Magazine highlight */}
<section className="mt-14 md:mt-20">
<h2 className="mb-5 text-lg font-bold text-ink md:text-xl">از مجله معلم بخوانید</h2>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<ul className="divide-y divide-line rounded-xl border border-line bg-white">
{FALLBACK_BLOGS.slice(0, 3).map((post) => (
<li key={post.id} className="flex gap-3 p-4">
<span className="relative size-16 shrink-0 overflow-hidden rounded-lg bg-surface">
<Image src={post.image} alt={post.title} fill sizes="64px" className="object-cover" />
</span>
<div className="min-w-0">
<span className="text-[11px] font-bold text-brand">{post.category}</span>
<p className="clamp-2 mt-1 text-[13px] leading-6 text-ink-soft">{post.title}</p>
</div>
</li>
))}
</ul>
<div className="relative overflow-hidden rounded-xl">
<span className="relative block aspect-[16/10] w-full bg-surface lg:aspect-auto lg:h-full">
<Image
src={FALLBACK_BLOGS[0].image}
alt={FALLBACK_BLOGS[0].title}
fill
sizes="(max-width: 1024px) 100vw, 50vw"
className="object-cover"
/>
{/* Flat overlay, not a gradient — this site's design keeps to flat fills. */}
<span className="absolute inset-x-0 bottom-0 bg-black/55 p-5">
<span className="rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-bold text-brand">
{FALLBACK_BLOGS[0].category}
</span>
<p className="clamp-2 mt-2 text-[14px] font-bold leading-7 text-white">
{FALLBACK_BLOGS[0].title}
</p>
</span>
</span>
</div>
</div>
</section>
</main>
);
}
+100
View File
@@ -0,0 +1,100 @@
import type { Metadata } from "next";
import Breadcrumbs from "@/components/Breadcrumbs";
import ContactForm from "@/components/ContactForm";
import { InstagramIcon, MailIcon, MapPinIcon, PhoneIcon, TelegramIcon } from "@/components/icons";
import { BRANCHES, MOBILE, MOBILE_HREF, PHONE, PHONE_HREF, SOCIAL } from "@/data/site";
export const metadata: Metadata = {
title: "تماس با ما",
description: "راه‌های تماس با فروشگاه موبایل معلم — شماره تماس، آدرس شعب و فرم ارسال پیام.",
};
export default function ContactPage() {
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-8 lg:grid-cols-[1fr_360px]">
<div className="rounded-2xl border border-line bg-white p-6 md:p-8">
<h2 className="mb-5 text-[15px] font-bold text-ink">فرم ارسال پیام</h2>
<ContactForm />
</div>
<div className="space-y-5">
<div className="rounded-2xl border border-line bg-white p-6">
<h2 className="mb-4 text-[15px] font-bold text-ink">راه‌های ارتباطی</h2>
<ul className="space-y-4 text-[13px]">
<li className="flex items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
<PhoneIcon className="size-4.5" />
</span>
<a href={PHONE_HREF} className="en text-ink transition hover:text-brand" dir="ltr">
{PHONE}
</a>
</li>
<li className="flex items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
<PhoneIcon className="size-4.5" />
</span>
<a href={MOBILE_HREF} className="en text-ink transition hover:text-brand" dir="ltr">
{MOBILE}
</a>
</li>
<li className="flex items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
<MailIcon className="size-4.5" />
</span>
<span className="text-body">پاسخگویی از ۹ صبح تا ۹ شب</span>
</li>
</ul>
<div className="mt-5 flex items-center gap-2 border-t border-line pt-5">
<a
href={SOCIAL.instagram}
target="_blank"
rel="noreferrer"
aria-label="اینستاگرام موبایل معلم"
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
>
<InstagramIcon className="size-4.5" />
</a>
<a
href={SOCIAL.telegram}
target="_blank"
rel="noreferrer"
aria-label="تلگرام موبایل معلم"
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
>
<TelegramIcon className="size-4.5" />
</a>
</div>
</div>
{BRANCHES.map((branch) => (
<div key={branch.title} className="overflow-hidden rounded-2xl border border-line bg-white">
<div className="flex items-start gap-3 p-5">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
<MapPinIcon className="size-4.5" />
</span>
<div>
<p className="text-[13px] font-bold text-ink">{branch.title}</p>
<p className="mt-1 text-[12.5px] leading-6 text-body">{branch.address}</p>
</div>
</div>
<iframe
title={`نقشه ${branch.title}`}
src={`https://www.google.com/maps?q=${encodeURIComponent(branch.mapQuery)}&output=embed`}
className="h-48 w-full border-0 grayscale"
loading="lazy"
/>
</div>
))}
</div>
</div>
</main>
);
}
+138
View File
@@ -0,0 +1,138 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import Breadcrumbs from "@/components/Breadcrumbs";
import TrustBadges from "@/components/ui/TrustBadges";
import { ChevronLeft, LockIcon, RotateIcon, ShieldIcon, TruckIcon, WalletIcon } from "@/components/icons";
import { findSlot, getCategories, getStaticImageSlots, safe } from "@/lib/api";
import { buildCategoryTree, carouselCategories, findNode } from "@/lib/categoryTree";
import { categoryHref } from "@/lib/slug";
export const metadata: Metadata = {
title: "فروش اقساطی",
description: "خرید اقساطی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم دیجیتال تا ۱۴ ماه، فقط با یک برگ چک.",
};
// Categories this page highlights, mapped to real ids from
// GET /tenants/moalem.shop/categories — "لوازم برقی" stands in for
// "خانه هوشمند" from the reference design since this tenant has no such
// category; every other label matches a real one.
const HIGHLIGHT_IDS = ["1251", "1254", "1253", "1256", "1261", "1232", "1260"];
const TRUST_ITEMS = [
{ icon: TruckIcon, title: "ارسال رایگان کالا", desc: "برای سفارش‌های بالای ۵ میلیون تومان" },
{ icon: ShieldIcon, title: "تضمین کیفیت", desc: "ضمانت اصالت تمامی کالاها" },
{ icon: LockIcon, title: "حریم خصوصی", desc: "عدم ذخیره‌سازی اطلاعات شما" },
{ icon: RotateIcon, title: "برگشت پول", desc: "برای اولین سفارش شما" },
{ icon: WalletIcon, title: "ضمانت با چک", desc: "بدون نیاز به ضامن" },
];
export default async function InstallmentsPage() {
const [roots, slots] = await Promise.all([
safe(getCategories().then(buildCategoryTree), []),
safe(getStaticImageSlots("home"), []),
]);
const level2 = carouselCategories(roots);
const categoriesSlot = findSlot(slots, "categories");
const slotImages = categoriesSlot?.images ?? [];
const highlights = HIGHLIGHT_IDS.map((id) => {
// findNode searches the whole tree, not just level2 — "هندزفری و هدفون"
// (1232) is a level-3 category, not level-2, so level2.find missed it.
// The "categories" slot only has art for the level-2 list though, so a
// deeper id like this one always keeps its own placeholder.
const cat = findNode(roots, id);
if (!cat) return null;
const index = level2.findIndex((c) => c.id === id);
const art = (index >= 0 && slotImages[index]?.url) || `/images/categories/${id}.svg`;
return { id, label: cat.nameFa || cat.name, art, href: categoryHref(id, cat.nameFa, cat.name) };
}).filter((x): x is NonNullable<typeof x> => Boolean(x));
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>
{/* Flat hero banner — brand-colored, no photo, no gradient. */}
<section className="overflow-hidden rounded-2xl bg-ink p-8 text-white md:p-12">
<p className="en text-sm font-bold tracking-widest text-white/60">INSTALLMENTS</p>
<p className="mt-2 text-6xl font-black leading-none text-brand md:text-8xl">۱۴ ماه</p>
<h2 className="mt-4 text-xl font-bold md:text-2xl">فروش اقساطی تمامی محصولات</h2>
<p className="mt-1 text-[14px] text-white/70">راهی مطمئن برای خرید آسان و اقساطی</p>
<Link
href="/products"
className="mt-6 inline-flex items-center gap-1.5 rounded-full bg-brand px-6 py-3 text-[13px] font-bold text-white transition hover:bg-brand-dark"
>
همین حالا خرید کنید
<ChevronLeft className="size-4" />
</Link>
</section>
{/* Goal + intro */}
<section className="mt-10 grid grid-cols-1 items-center gap-8 md:grid-cols-[200px_1fr]">
<div className="text-center md:text-start">
<p className="text-7xl font-black leading-none text-brand">۱۴</p>
<p className="en text-sm font-bold tracking-widest text-body">MONTH</p>
</div>
<div>
<h2 className="text-lg font-bold text-ink md:text-xl">هدف ما، فروش اقساطی به‌صورت منصفانه و متنوع</h2>
<p className="mt-3 text-[13.5px] leading-8 text-body">
ما فروش اقساطی لوازم دیجیتال ارائه می‌دهیم؛ شامل گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و سایر
محصولات روز. پرداخت اقساط به‌صورت اقساط تا ۱۴ ماه انجام می‌شود و تنها تضمین موردنیاز، چک است.
هدف ما فراهم‌کردن خرید آسان، سریع و مطمئن برای مشتریان است تا بدون فشار مالی، دستگاه موردنظرشان
را تهیه کنند.
</p>
</div>
</section>
{/* Category highlights */}
{highlights.length > 0 && (
<section className="mt-14">
<h2 className="text-lg font-bold text-ink md:text-xl">نقد و اقساط ۱۴ ماهه</h2>
<p className="mt-1 text-[13px] text-body">
با شرایطی آسان، هر کالای دیجیتالی و گجت هوشمند مورد علاقه‌تان را به‌صورت اقساطی بخرید
</p>
<div className="mt-6 grid grid-cols-3 gap-6 sm:grid-cols-4 lg:grid-cols-7">
{highlights.map((h) => (
<Link key={h.id} href={h.href} className="group flex flex-col items-center gap-2">
<span className="relative size-[84px] overflow-hidden rounded-full bg-surface transition group-hover:bg-brand-soft sm:size-[96px]">
<Image src={h.art} alt={h.label} fill sizes="96px" className="object-contain p-3" />
</span>
<span className="text-center text-[12.5px] text-ink transition group-hover:text-brand">
{h.label}
</span>
</Link>
))}
</div>
</section>
)}
{/* Second promo block */}
<section className="mt-14 grid grid-cols-1 items-center gap-8 rounded-2xl border border-line bg-surface p-6 md:grid-cols-[1fr_220px] md:p-10">
<div>
<h2 className="text-lg font-bold text-ink md:text-xl">
فروش اقساطی تمامی اجناس و پرداخت حداکثر تا ۱۴ ماه
</h2>
<p className="mt-3 text-[13.5px] leading-8 text-body">
برای سهولت بیشتر، تنها با ۲۰٪ پیش‌پرداخت می‌توانید محصول مورد نظرتان را دریافت کنید. باقی مبلغ
در بلندمدت تا ۱۴ ماه تسویه می‌شود و کارمزدها هم کاملاً منصفانه و شفاف هستند. با این شرایط، خرید
راحت، سریع و بدون فشار مالی انجام می‌شود.
</p>
<Link
href="/contact"
className="mt-5 inline-flex items-center gap-1.5 rounded-full bg-brand px-6 py-3 text-[13px] font-bold text-white transition hover:bg-brand-dark"
>
همین حالا ثبت‌نام بگیرید
<ChevronLeft className="size-4" />
</Link>
</div>
<p className="text-center text-8xl font-black leading-none text-brand md:text-9xl">۱۴</p>
</section>
<section className="mt-14">
<TrustBadges items={TRUST_ITEMS} />
</section>
</main>
);
}
+42 -24
View File
@@ -6,12 +6,14 @@ 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 { getCategories, getFavicon, 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";
const NO_BRAND_ASSETS = { faviconUrl: null, logoUrl: null, logoDarkUrl: null, hasDedicatedFavicon: false };
// Montserrat is the Latin face the live moalem.shop theme pairs with IRANYekan.
const montserrat = Montserrat({
variable: "--font-en",
@@ -20,41 +22,57 @@ const montserrat = Montserrat({
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 }],
},
};
const BASE_DESCRIPTION =
"خرید اینترنتی گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم.";
// generateMetadata (not a static export) because the tab icon comes from
// GET /tenants/{domain}/website/favicon — once the business uploads a real
// one in the dashboard this picks it up with no code change. Falls back to
// the bundled /favicon.png only while that API field is empty.
export async function generateMetadata(): Promise<Metadata> {
const brand = await safe(getFavicon(), NO_BRAND_ASSETS);
return {
metadataBase: new URL(SITE_URL),
title: {
default: `${SITE_NAME} | خرید موبایل، لپ‌تاپ و لوازم جانبی`,
template: `%s | ${SITE_NAME}`,
},
description: BASE_DESCRIPTION,
icons: [{ url: brand.faviconUrl || "/favicon.png", type: "image/png" }],
openGraph: {
type: "website",
locale: "fa_IR",
siteName: SITE_NAME,
title: `${SITE_NAME} | خرید موبایل، لپ‌تاپ و لوازم جانبی`,
description: BASE_DESCRIPTION,
url: SITE_URL,
// The dashboard logo is a square mark; the bundled fallback is a wide
// wordmark — the two aren't the same shape, so their og:image sizes
// can't share one hardcoded pair.
images: brand.logoUrl
? [{ url: brand.logoUrl, width: 512, height: 512, alt: SITE_NAME }]
: [{ url: "/images/logo.png", width: 130, height: 36, alt: SITE_NAME }],
},
};
}
export default async function RootLayout({ children }: LayoutProps<"/">) {
const [categories, user] = await Promise.all([
const [categories, user, brand] = await Promise.all([
safe(getCategories().then(buildCategoryTree), []),
getSession(),
safe(getFavicon(), NO_BRAND_ASSETS),
]);
const menu = carouselCategories(categories);
const logoUrl = brand.logoUrl || "/images/logo.png";
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} />
<Header menu={menu} user={user} logoUrl={logoUrl} />
<main className="flex-1 pb-6">{children}</main>
<Footer categories={menu} />
<Footer categories={menu} logoUrl={logoUrl} />
{/* Spacer so the fixed mobile bar never covers the end of the footer. */}
<div className="h-[68px] lg:hidden" />
<MobileBottomNav categories={menu} user={user} />
+7 -7
View File
@@ -31,12 +31,12 @@ export default function CategoryCarousel({
modules={[FreeMode]}
dir="rtl"
freeMode
slidesPerView={3.5}
slidesPerView={2.8}
spaceBetween={12}
breakpoints={{
480: { slidesPerView: 4.5 },
768: { slidesPerView: 6.5 },
1024: { slidesPerView: 9 },
480: { slidesPerView: 3.6 },
768: { slidesPerView: 5 },
1024: { slidesPerView: 7 },
}}
>
{categories.map((cat, i) => {
@@ -45,10 +45,10 @@ export default function CategoryCarousel({
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 className="relative size-[104px] overflow-hidden rounded-full bg-surface transition group-hover:bg-brand-soft sm:size-[124px] lg:size-[140px]">
<Image src={art} alt={label} fill sizes="140px" className="object-contain p-3" />
</span>
<span className="text-center text-[12.5px] text-ink transition group-hover:text-brand">
<span className="text-center text-[13.5px] text-ink transition group-hover:text-brand">
{label}
</span>
</Link>
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useState } from "react";
import { submitContactForm } from "@/lib/api";
type Status = "idle" | "submitting" | "sent" | "error";
// Posts straight to POST /tenants/moalem.shop/contact-submissions — a real,
// documented endpoint, no bearer required.
export default function ContactForm() {
const [status, setStatus] = useState<Status>("idle");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
const name = String(form.get("name") || "").trim();
const title = String(form.get("title") || "").trim();
const text = String(form.get("text") || "").trim();
const email = String(form.get("email") || "").trim();
const cellNumber = String(form.get("cellNumber") || "").trim();
if (!name || !title || !text) return;
setStatus("submitting");
try {
await submitContactForm({
name,
title,
text,
email: email || undefined,
cellNumber: cellNumber || undefined,
});
setStatus("sent");
e.currentTarget.reset();
} catch {
setStatus("error");
}
}
if (status === "sent") {
return (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-line bg-surface p-10 text-center">
<p className="text-[15px] font-bold text-ink">پیام شما با موفقیت ارسال شد.</p>
<p className="text-[13px] text-body">همکاران ما در اسرع وقت با شما تماس می‌گیرند.</p>
<button
onClick={() => setStatus("idle")}
className="mt-2 rounded-lg border border-line px-4 py-2 text-[13px] text-ink-soft transition hover:border-brand hover:text-brand"
>
ارسال پیام دیگر
</button>
</div>
);
}
const input =
"h-12 w-full rounded-xl border border-line bg-white px-4 text-sm outline-none transition focus:border-brand";
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<input name="name" required placeholder="نام و نام خانوادگی" className={input} />
<input name="cellNumber" placeholder="شماره موبایل" inputMode="tel" className={input} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<input name="email" type="email" placeholder="ایمیل (اختیاری)" className={input} />
<input name="title" required placeholder="موضوع پیام" className={input} />
</div>
<textarea
name="text"
required
rows={5}
placeholder="متن پیام شما…"
className="w-full rounded-xl border border-line bg-white p-4 text-sm outline-none transition focus:border-brand"
/>
{status === "error" && (
<p className="text-[12.5px] text-brand">ارسال پیام ناموفق بود. لطفاً دوباره تلاش کنید.</p>
)}
<button
type="submit"
disabled={status === "submitting"}
className="flex h-12 w-full items-center justify-center rounded-xl bg-brand text-sm font-bold text-white transition hover:bg-brand-dark disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto sm:px-10"
>
{status === "submitting" ? "در حال ارسال…" : "ارسال پیام"}
</button>
</form>
);
}
+33 -4
View File
@@ -1,17 +1,25 @@
import Link from "next/link";
import Image from "next/image";
import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME, SITE_TAGLINE } from "@/data/site";
import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME, SITE_TAGLINE, SOCIAL } from "@/data/site";
import { categoryHref } from "@/lib/slug";
import type { CategoryNode } from "@/lib/types";
import { PhoneIcon } from "./icons";
import { InstagramIcon, PhoneIcon, TelegramIcon } from "./icons";
import { toFaDigits } from "@/lib/format";
export default function Footer({ categories }: { categories: CategoryNode[] }) {
export default function Footer({
categories,
logoUrl,
}: {
categories: CategoryNode[];
logoUrl: string;
}) {
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} />
{/* h-9 w-auto so a square dashboard logo and the wide fallback
wordmark both render undistorted — see Header.tsx. */}
<Image src={logoUrl} alt={SITE_NAME} width={140} height={40} className="h-9 w-auto object-contain" />
<p className="mt-3 max-w-md text-[13px] leading-7 text-body">{SITE_TAGLINE}</p>
<a
href={PHONE_HREF}
@@ -20,6 +28,27 @@ export default function Footer({ categories }: { categories: CategoryNode[] }) {
<PhoneIcon className="size-4" />
{PHONE}
</a>
<div className="mt-4 flex items-center gap-2">
<a
href={SOCIAL.instagram}
target="_blank"
rel="noreferrer"
aria-label="اینستاگرام موبایل معلم"
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
>
<InstagramIcon className="size-4.5" />
</a>
<a
href={SOCIAL.telegram}
target="_blank"
rel="noreferrer"
aria-label="تلگرام موبایل معلم"
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
>
<TelegramIcon className="size-4.5" />
</a>
</div>
</div>
<div>
+12 -2
View File
@@ -13,7 +13,15 @@ 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 }) {
export default function Header({
menu,
user,
logoUrl,
}: {
menu: CategoryNode[];
user: AuthUser | null;
logoUrl: string;
}) {
const [megaOpen, setMegaOpen] = useState(false);
return (
@@ -23,7 +31,9 @@ export default function Header({ menu, user }: { menu: CategoryNode[]; user: Aut
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 />
{/* h-9 w-auto (not a fixed box) so a square dashboard logo mark
and the wide fallback wordmark both render undistorted. */}
<Image src={logoUrl} alt={SITE_NAME} width={140} height={40} className="h-9 w-auto object-contain" priority />
</Link>
<div className="flex-1">
+43
View File
@@ -110,3 +110,46 @@ export const RotateIcon = ({ className }: P) => (
<path d="M17.5 3.5V7H14M6.5 20.5V17H10" />
</svg>
);
export const LockIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
);
export const WalletIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<rect x="3.5" y="6" width="17" height="13" rx="2" />
<path d="M3.5 10h17" />
<circle cx="16.5" cy="14" r="1.2" />
</svg>
);
export const MapPinIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21z" />
<circle cx="12" cy="9.5" r="2.4" />
</svg>
);
export const MailIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="M3.5 6.5 12 13l8.5-6.5" />
</svg>
);
export const InstagramIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<rect x="3.5" y="3.5" width="17" height="17" rx="5" />
<circle cx="12" cy="12" r="4" />
<circle cx="17.2" cy="6.8" r="0.9" fill="currentColor" stroke="none" />
</svg>
);
export const TelegramIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M21 4 3 11.4l6 2.1M21 4l-3.3 15L9 13.5M21 4 9 13.5v5.4l3-3.3" />
</svg>
);
+26
View File
@@ -0,0 +1,26 @@
import type { ComponentType } from "react";
export interface TrustBadge {
icon: ComponentType<{ className?: string }>;
title: string;
desc: string;
}
// Shared trust-point row used on /about and /installments — same visual
// language as the badges in ProductBuyPanel, just laid out wider.
export default function TrustBadges({ items }: { items: TrustBadge[] }) {
return (
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-5">
{items.map((item) => (
<div
key={item.title}
className="flex flex-col items-center gap-2 rounded-xl border border-line bg-white p-5 text-center"
>
<item.icon className="size-7 text-brand" />
<span className="text-[13px] font-bold text-ink">{item.title}</span>
<span className="text-[11.5px] leading-5 text-body">{item.desc}</span>
</div>
))}
</div>
);
}
+26 -2
View File
@@ -9,8 +9,32 @@ export const PROMO = {
href: "/installments",
};
export const PHONE = "۰۲۱-۳۳۹۰۰۰۰۰";
export const PHONE_HREF = "tel:+982133900000";
// Real contact data — pulled from the live moalem.shop theme (footer +
// support badge), not invented. website/business-info on the API is still
// blank for phones/addresses/social, so this stays hand-maintained here
// until that's filled in; update both together when it is.
export const PHONE = "۰۲۵-۳۲۱۰۶";
export const PHONE_HREF = "tel:+982532106";
export const MOBILE = "۰۹۳۵۷۵۳۲۱۰۶";
export const MOBILE_HREF = "tel:+989357532106";
export const SOCIAL = {
instagram: "https://instagram.com/mobilemoalem",
telegram: "https://t.me/mobilemoalem",
};
export const BRANCHES = [
{
title: "شعبه ۱",
address: "قم، تقاطع عماریاسر، جنب پاساژ الزهرا، موبایل معلم",
mapQuery: "قم تقاطع عماریاسر جنب پاساژ الزهرا موبایل معلم",
},
{
title: "شعبه ۲",
address: "قم، عماریاسر، نبش بازار سلام، فروشگاه معلم پلاس",
mapQuery: "قم عماریاسر نبش بازار سلام فروشگاه معلم پلاس",
},
];
export const MAIN_NAV = [
{ label: "برندها", href: "/brands" },
+8
View File
@@ -4,6 +4,7 @@ import type {
ApiCategory,
ApiProduct,
AuthUser,
BrandAssets,
BusinessInfo,
ContactFormInput,
EntityComment,
@@ -86,6 +87,13 @@ export function getBusinessInfo(): Promise<BusinessInfo> {
return apiGet<BusinessInfo>(t("/website/business-info"));
}
// Single documented source for header/footer logos and the browser-tab
// icon — see AGENTS.md "Branding". Prefer this over reading logoUrl off
// getTenant()/getBusinessInfo() directly.
export function getFavicon(): Promise<BrandAssets> {
return apiGet<BrandAssets>(t("/website/favicon"));
}
export function getSliders(): Promise<SliderItem[]> {
return apiGet<{ items: SliderItem[] }>(t("/website/sliders")).then((r) => r.items);
}
+10
View File
@@ -23,6 +23,16 @@ export interface Tenant {
faviconUrl: string | null;
}
// GET /tenants/{domain}/website/favicon — the documented single source for
// header/footer logos and the browser-tab icon (falls back to the light
// logo when hasDedicatedFavicon is false).
export interface BrandAssets {
faviconUrl: string | null;
logoUrl: string | null;
logoDarkUrl: string | null;
hasDedicatedFavicon: boolean;
}
// This tenant has no emails/phones/addresses filled in yet, so the loose
// shapes are read defensively at render time.
export interface BusinessInfo {