Compare commits

..
4 Commits
Author SHA1 Message Date
amirhosein.ashourlooandClaude Sonnet 5 0ffbd953b3 Show real titleFa captions, 3x logo size, fix category hover border
- CategoryCarousel: caption under each circle now comes from the slot
  image's own titleFa (as the business typed it) instead of the resolved
  category's API name - identity resolution via linkUrl slug is still used
  for the href, just not for the visible label
- Replace the hover ring (box-shadow) with a real 1px border - the ring
  rendered oddly against Swiper's transformed slide track, looking like it
  "leaked" into the row above; a border participates in normal box layout
  and doesn't have that problem (border-box sizing means zero layout shift)
- Header/footer logo: h-9 (36px) -> h-[108px], 3x on request; header row
  height bumped to fit. Note: the currently-uploaded (wrong-business) logo
  has a lot of baked-in padding, so it still reads small even at 3x - that's
  the asset, not the sizing
- Re-verified the static-image-slot wiring end to end against the original
  spec (url->img, linkUrl->href, aspectRatio from API, placeholder on
  empty/fail) - all still holds

Also add a Deployment section to AGENTS.md: this repo's whole point is
CI/CD from origin/main, and local commits sitting unpushed (as 3 commits
were, until now) look indistinguishable from unfixed bugs to whoever is
testing the deployed site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 07:53:20 +03:30
amirhosein.ashourlooandClaude Sonnet 5 2249a07dee Fix category-slot matching: identity by linkUrl slug, not array position
The homepage category carousel and installments highlights matched
slot.images[i] to categoryList[i] by array index, assuming the business's
CMS upload order lines up with this site's own category sort order. It
doesn't - live data showed a laptop photo landing on "موبایل" and "لوازم
برقی" appearing on the site despite no photo ever being uploaded for it,
purely from index luck. titleFa isn't reliable either: the same laptop image
was titled "تبلت" in the CMS, contradicting its own linkUrl.

Fix: resolve each image's category from the `category` query param on its
own linkUrl (categorySlugFromLinkUrl() in slug.ts, matched against
ApiCategory.slug via the new findNodeBySlug()). The homepage carousel is now
fully slot-driven - it iterates slot.images (in the CMS's own sortOrder) and
simply omits any category with no matching image, per explicit product
direction, rather than falling back to a placeholder. Installments keeps its
curated HIGHLIGHT_IDS list with a placeholder for uncovered categories,
since that page always shows the same fixed set, but now uses the same
identity-based lookup instead of position.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 07:36:24 +03:30
amirhosein.ashourlooandClaude Sonnet 5 db7d00e473 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>
2026-09-06 07:22:54 +03:30
amirhosein.ashourlooandClaude Sonnet 5 09a409b3e7 Update banners.psd design source
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 14:02:15 +03:30
50 changed files with 1045 additions and 85 deletions
+80 -9
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,35 @@ 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.
- Header/footer logo renders at `h-[108px]` (3x the original `h-9`/36px, on
request). If the wrong logo above still looks small at that size, it's
because that specific PNG has a lot of baked-in transparent padding around
the mark — `object-contain` can't fix that, only the replacement asset can.
- 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.
@@ -112,7 +130,7 @@ 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 |
| `categories` | list | `CategoryCarousel.tsx` — **fully slot-driven**: iterates `slot.images` and resolves each one's category via the `category` query param on its own `linkUrl` (`categorySlugFromLinkUrl()` in `src/lib/slug.ts`, matched against `ApiCategory.slug` with `findNodeBySlug()`). A category with no matching image simply isn't shown — **no placeholder fallback here**, per an explicit product decision (2026-09-06). Position and `titleFa` are **not** used for matching — both were tried and found unreliable in practice (see the history note below) |
| `one-banner-bg` | single | `PromoBanners.tsx` → `OneBanner` |
| `two-banner-bg` | list (2) | `PromoBanners.tsx` → `TwoBanners` |
| `three-banner-bg` | list (3) | `PromoBanners.tsx` → `ThreeBanners` |
@@ -122,11 +140,53 @@ 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.
**Why `categories` matches by `linkUrl` slug, not position:** the first version
matched `slot.images[i]` to `categoryList[i]` by array index, assuming the
business's upload order lines up with this site's category sort order. It
doesn't, and there's no contract that it ever would — live data showed a
laptop photo (`linkUrl` correctly said `?category=laptop`) landing on
"موبایل" and "لوازم برقی" appearing with no photo uploaded for it at all,
purely because of where it fell in the array. The visible caption under each
circle is that image's own `titleFa`, taken as typed — even on the one
sample where it disagreed with the photo (that same laptop image was titled
"تبلت"), it's still the business's own editorial text for the tile, and the
`href` (built from the `linkUrl` slug, resolved to this site's own canonical
`/products/category/{id}/{slug}` rather than linking off-site to the literal
`linkUrl`) is what actually has to be correct for navigation to work.
**Category circle hover:** use a real `border` (1px, transparent → `border-brand`
on `group-hover`), not a `ring`/box-shadow. A ring rendered inside a Swiper
slide painted like it was "leaking" into the row above — box-shadow doesn't
participate in normal layout the way a border does, and interacts oddly with
Swiper's transformed `.swiper-wrapper` track. `border` with this site's
global `box-sizing: border-box` reset adds zero layout shift, so it's the
safer default for any future hover-outline effect inside a carousel.
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** →
کنسول بازی → مجله.
## 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 are a fixed editorial list (`HIGHLIGHT_IDS`), unlike
the homepage carousel — a highlight with no matching `categories`-slot
image (by `linkUrl` slug, same lookup as the carousel) keeps its SVG
placeholder rather than disappearing, since this page always shows the
same curated set. One reference category ("خانه هوشمند") has no equivalent
on this tenant and is swapped for "لوازم برقی".
## Product / blog pages
- `/products` — full catalog, `?name=` search, `?page=`.
@@ -177,3 +237,14 @@ npm run dev # localhost:3000
npm run build
node scripts/gen-category-placeholders.mjs # refresh category thumb placeholders
```
## Deployment
`origin` is `gitea.meshkee.com/meshkee-websites/moalem-shop.git`, `main` branch.
Whatever serves the business's preview/live URL deploys from `origin/main` —
**committing locally is not enough**, push too. A whole session's worth of
work (2026-09-06: about/contact/installments pages, the category-slot
identity fix, live logo/favicon wiring) once sat 3 commits ahead of
`origin/main` unpushed, and the business reported bugs that were already
fixed locally — they were looking at the old deployed code. Check
`git status` for "ahead of origin" before telling anyone a fix is live.
BIN
View File
Binary file not shown.
+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>
);
}
+144
View File
@@ -0,0 +1,144 @@
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, findNode } from "@/lib/categoryTree";
import { categoryHref, categorySlugFromLinkUrl } 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 categoriesSlot = findSlot(slots, "categories");
// Match by the slug in each image's own linkUrl (…?category=mobile), not
// by array position — position assumes the business's upload order
// matches this page's HIGHLIGHT_IDS order, which isn't a real contract and
// was proven wrong in practice (a laptop photo landing on "موبایل"). See
// AGENTS.md "Static-image slots".
const artBySlug = new Map<string, string>();
for (const img of categoriesSlot?.images ?? []) {
const slug = categorySlugFromLinkUrl(img.linkUrl);
if (slug) artBySlug.set(slug, img.url);
}
const highlights = HIGHLIGHT_IDS.map((id) => {
// findNode searches the whole tree, not just level2 — "هندزفری و هدفون"
// (1232) is a level-3 category, not level-2.
const cat = findNode(roots, id);
if (!cat) return null;
const art = artBySlug.get(cat.slug) || `/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>
);
}
+29 -11
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 = {
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:
"خرید اینترنتی گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم.",
icons: [{ url: "/favicon.png", type: "image/png" }],
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:
"خرید اینترنتی گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم.",
description: BASE_DESCRIPTION,
url: SITE_URL,
images: [{ url: "/images/logo.png", width: 130, height: 36, alt: SITE_NAME }],
// 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} />
+2 -4
View File
@@ -14,7 +14,7 @@ import {
getStoreSpecials,
safe,
} from "@/lib/api";
import { buildCategoryTree, carouselCategories, findNode, subtreeIds } from "@/lib/categoryTree";
import { buildCategoryTree, findNode, subtreeIds } from "@/lib/categoryTree";
import { productToCard, specialToCards } from "@/lib/cards";
import { blogHref, categoryHref } from "@/lib/slug";
import { HOME_CAROUSELS } from "@/data/site";
@@ -57,8 +57,6 @@ export default async function HomePage() {
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");
@@ -141,7 +139,7 @@ export default async function HomePage() {
<>
<h1 className="sr-only">فروشگاه موبایل معلم | خرید موبایل، لپ‌تاپ و لوازم جانبی</h1>
<HeroSlider slot={sliderSlot} slides={slides} />
<CategoryCarousel categories={carouselCats} slot={categoriesSlot} />
<CategoryCarousel roots={categories} slot={categoriesSlot} />
<HotDeals products={deals} endsAt={endOfDay.getTime() + 2 * 86_400_000} />
<ThreeBanners slot={threeBannerSlot} />
{mobileCarousel && (
+45 -26
View File
@@ -6,24 +6,36 @@ 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 { findNodeBySlug } from "@/lib/categoryTree";
import { categoryHref, categorySlugFromLinkUrl } 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.
// Fully slot-driven: each image in the "categories" slot carries its own
// linkUrl (e.g. "...?category=mobile"), whose `category` param is the real
// DB slug — that's what identifies which category a photo is FOR (used to
// build the real, in-site link target). The visible caption is the
// business's own titleFa for that image, taken as typed — a category the
// business hasn't uploaded a photo for simply isn't shown here, no
// placeholder, per the "only what's in the panel" brief.
// See AGENTS.md "Static-image slots" for the mismatch this replaced.
export default function CategoryCarousel({
categories,
roots,
slot,
}: {
categories: CategoryNode[];
roots: CategoryNode[];
slot?: StaticImageSlot;
}) {
if (categories.length === 0) return null;
const slotImages = slot?.images ?? [];
const items = (slot?.images ?? [])
.map((img) => {
const catSlug = categorySlugFromLinkUrl(img.linkUrl);
const cat = catSlug ? findNodeBySlug(roots, catSlug) : undefined;
if (!cat) return null;
const label = img.titleFa || img.titleEn || cat.nameFa || cat.name;
return { key: img.id, art: img.url, label, href: categoryHref(cat.id, cat.nameFa, cat.name) };
})
.filter((x): x is { key: string; art: string; label: string; href: string } => Boolean(x));
if (items.length === 0) return null;
return (
<section className="boxed mt-8" aria-label="دسته‌بندی محصولات">
@@ -31,30 +43,37 @@ 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) => {
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" />
{items.map(({ key, art, label, href }) => (
<SwiperSlide key={key}>
<Link href={href} className="group flex flex-col items-center gap-2">
{/* A real border, not ring/box-shadow — box-shadow rings can
paint oddly against Swiper's transformed slide track
(looked like the border "leaking" into the row above).
border-box sizing (this site's global reset) means the
1px border adds no layout shift. */}
<span className="relative size-[104px] overflow-hidden rounded-full border border-transparent bg-surface transition-all duration-300 group-hover:border-brand group-hover:shadow-md sm:size-[124px] lg:size-[140px]">
<Image
src={art}
alt={label}
fill
sizes="140px"
className="rounded-full object-contain p-3 transition-transform duration-300 group-hover:scale-110"
/>
</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>
</SwiperSlide>
);
})}
))}
</Swiper>
</section>
);
+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>
);
}
+39 -4
View File
@@ -1,17 +1,31 @@
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-[108px] 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={280}
height={108}
className="h-[108px] 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 +34,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>
+23 -4
View File
@@ -13,17 +13,36 @@ 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 (
<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">
cart and browsing live in the fixed bottom bar instead. The row
is taller on lg so a prominent 108px logo (3x the old 36px) has
room without cramming the search box/buttons beside it. */}
<div className="flex h-[70px] items-center gap-4 lg:h-32">
<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-[108px] 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={280}
height={108}
className="h-[108px] 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);
}
+9
View File
@@ -35,6 +35,15 @@ export function findNode(nodes: CategoryNode[], id: string): CategoryNode | unde
return undefined;
}
export function findNodeBySlug(nodes: CategoryNode[], slug: string): CategoryNode | undefined {
for (const node of nodes) {
if (node.slug === slug) return node;
const hit = findNodeBySlug(node.children, slug);
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
+15
View File
@@ -22,3 +22,18 @@ export function categoryHref(id: string, nameFa?: string | null, name?: string |
export function blogHref(id: string, title?: string | null): string {
return `/blog/${id}/${faSlug(title)}`;
}
// Static-image-slot items carry a business-picked linkUrl like
// "https://moalem.shop/products?category=mobile" — the `category` query
// param is the DB slug (ApiCategory.slug), the one reliable way to identify
// which category an uploaded photo is actually for. Position in the list
// and the item's own titleFa are NOT reliable — see AGENTS.md "Static-image
// slots" for the real mismatch this was built to fix.
export function categorySlugFromLinkUrl(linkUrl: string | null | undefined): string | null {
if (!linkUrl) return null;
try {
return new URL(linkUrl).searchParams.get("category");
} catch {
return null;
}
}
+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 {