- 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>
251 lines
15 KiB
Markdown
251 lines
15 KiB
Markdown
# moalem.shop — context for AI agents and tooling
|
|
|
|
Storefront for **فروشگاه موبایل معلم** built on the Meshkee CMS contract.
|
|
Keep this file current: every prompt that changes structure, data sources or
|
|
fallbacks should update it.
|
|
|
|
## Stack
|
|
|
|
- Next.js 16.3 (App Router, Turbopack) · React 19 · TypeScript
|
|
- Tailwind CSS v4 (`@theme inline` tokens in `src/app/globals.css`)
|
|
- Swiper 14 for every slider/carousel
|
|
- fa / RTL, flat design, no gradients
|
|
|
|
## Data — Meshkee Website API
|
|
|
|
- Base: `https://api.meshkee.com/api/v1`, docs: <https://api.meshkee.com/docs/website>
|
|
- Tenant: `moalem.shop` (business id 27, `specialProductsSource: store_item`)
|
|
- Every public read goes through `src/lib/api.ts`; wrap calls in `safe()` so an
|
|
empty or failing module degrades one section instead of the page.
|
|
- Never invent CMS/admin endpoints. If OpenAPI and any prose conflict, OpenAPI wins.
|
|
|
|
### What the tenant actually returns today (2026-09-06)
|
|
|
|
| Endpoint | State |
|
|
|---|---|
|
|
| `enabledModules` (`GET /tenants/moalem.shop`) | `["products", "blog", "finance"]` — **`workshops` is not enabled.** Do not build a workshops page/nav entry until it is. |
|
|
| `/products` | **479 items**, real titles, images, categories |
|
|
| `/categories?entityType=product` | **48 items**, 3 levels under «کالای دیجیتال». Products are filed on **leaf** categories only — a parent category page/carousel must query its whole subtree (`subtreeIds`/`subtreeIdsAll` in `src/lib/categoryTree.ts`). |
|
|
| `/store-items`, `/store-specials` | **empty** → no prices, no discounts, everything `inStock:false`. `/store-items/by-product/{id}` returns a single `{ storeItem }` (or null) — for a product's full variant list use `/store-items?productId=&pageSize=100` (`{ items }`) instead, see `getStoreItemsByProduct` in `src/lib/api.ts`. |
|
|
| `/website/sliders`, `/website/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` | 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) |
|
|
|
|
### Fallbacks in place until the CMS is filled
|
|
|
|
| Gap | Fallback | Remove when |
|
|
|---|---|---|
|
|
| No prices | `src/lib/demoPricing.ts`, gated by `NEXT_PUBLIC_DEMO_PRICING=1` — deterministic price per product id | store-items exist |
|
|
| No 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 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
|
|
|
|
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.
|
|
- Homepage order: promo strip → header → hero slider → category carousel →
|
|
hot deals → product carousels (interleaved with promo banners) → magazine
|
|
carousel → footer. Exact interleaving is in "Static-image slots" below.
|
|
- Section header (title + one-line desc on the right, «مشاهده همه» pill on the
|
|
left) is `src/components/ui/SectionHeader.tsx` — reuse it for every carousel.
|
|
- Product card: `ui/ProductCard.tsx`, 8px radius, discount badge top-right,
|
|
strikethrough old price above the current one. The price block is a fixed
|
|
44px row and the old-price line is always rendered (empty when there is no
|
|
discount) so **every card in a carousel is exactly the same height** — keep
|
|
that invariant when adding badges or meta lines.
|
|
- Carousel arrows: `ui/CarouselNav.tsx`, passed to `SectionHeader` as `nav` so
|
|
they sit beside the «مشاهده همه» pill on desktop; the copy under the track is
|
|
`lg:hidden` for thumbs.
|
|
- Prices are Toman and rendered with Persian digits (`src/lib/format.ts`).
|
|
|
|
## Navigation
|
|
|
|
- **Desktop (lg+):** full header — logo, search, account/cart, then the product
|
|
mega menu and the main nav.
|
|
- **Mobile:** no hamburger. The header carries the search box alone; everything
|
|
else lives in `MobileBottomNav.tsx`, a fixed bottom bar with خانه / سبد خرید /
|
|
دستهبندیها / ورود (or the account menu once signed in). «دستهبندیها» opens
|
|
a bottom sheet with the level-2 categories and an accordion for their
|
|
children. Any new mobile entry point belongs in that bar, not in a drawer.
|
|
|
|
## Auth / account menu
|
|
|
|
Login itself happens off-site, on the customer portal (`customer.{domain}`) —
|
|
this storefront never renders a login form. For the header/bottom-nav account
|
|
menu to show "نام عزیز" instead of "ورود | ثبتنام", the customer portal needs
|
|
to set a **non-HttpOnly cookie on the shared parent domain** (`.{domain}`)
|
|
named `AUTH_TOKEN_COOKIE` (`src/lib/config.ts`, currently `"meshkee_token"`)
|
|
holding the bearer access token. Until that exists, `getSession()` in
|
|
`src/lib/auth.ts` always resolves to `null` and the anonymous view is all
|
|
anyone will see — this is expected, not a bug, and nothing else breaks.
|
|
|
|
- `src/lib/auth.ts` — **server-only** (`next/headers`): `getSession()` reads
|
|
the cookie and calls `GET /auth/me`.
|
|
- `src/lib/authUser.ts` — pure helpers (`displayName`, `isAdmin`), safe to
|
|
import from client components. **Never import `auth.ts` from a client
|
|
component** — it pulls in `next/headers` and breaks the build; that's the
|
|
entire reason this file is split out.
|
|
- Admin detection (`isAdmin()`) reads `user.isAdmin`/`user.role` defensively —
|
|
the OpenAPI schema doesn't document a role field on the user object, so
|
|
this is a best guess. Update it once the real shape is confirmed.
|
|
- Cart ("افزودن به سبد خرید" in `ProductBuyPanel.tsx`) needs a real
|
|
`storeItemVariantId`, which only exists once a product has store-items —
|
|
today that's none of them, so the button always shows "این محصول هنوز برای
|
|
خرید آنلاین فعال نشده است" regardless of login state. That's correct given
|
|
the data, not a bug either.
|
|
|
|
## Static-image slots
|
|
|
|
Slot catalog: `src/app/meshkee/static-image-slots/route.ts`. Keys and where
|
|
each is consumed:
|
|
|
|
| Key | Kind | Consumed by |
|
|
|---|---|---|
|
|
| `slider` | list | `HeroSlider.tsx` — wins over `FALLBACK_SLIDES` when it has images |
|
|
| `categories` | list | `CategoryCarousel.tsx` — **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` |
|
|
|
|
**Rule:** every `aspectRatio`/`recommendedWidth` in that route file must match
|
|
what the component actually renders. If you change a banner's size or ratio,
|
|
update its slot entry in the same commit — that file is the only thing
|
|
telling the dashboard (and the designer) what to upload.
|
|
|
|
**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=`.
|
|
- `/products/category/{id}/{slug}` — category listing; merges every leaf in
|
|
the subtree (see the leaf-category note above) and paginates in memory.
|
|
- `/products/{id}/{slug}` — detail: `ProductGallery.tsx` (click-to-lightbox +
|
|
hover-zoom via a background-position overlay, desktop only),
|
|
`ProductBuyPanel.tsx` (variants, price, auth-aware cart), `ProductInfoTabs.tsx`
|
|
(توضیحات / مشخصات فنی / دیدگاه کاربران), related products from
|
|
`relatedProducts` on the by-id response.
|
|
- `/blog`, `/blog/{id}/{slug}` — same shape, no variants/tabs; just content +
|
|
`CommentSection.tsx`. Fallback posts (see table above) only exist in the
|
|
listing grid, not as real detail pages.
|
|
- **Technical specs**: `values[]` from `technical-info` carries no label, only
|
|
a `fieldId` — never render it directly. Join against `form.fields` first via
|
|
`src/lib/technicalInfo.ts#buildSpecRows`.
|
|
- **Comments**: `POST /comments` doesn't require a bearer token at the API
|
|
level, but `CommentSection.tsx` gates the form behind `getSession()` per the
|
|
page brief — anonymous visitors get a login prompt instead of a textarea.
|
|
|
|
## URLs (Meshkee canonical shapes)
|
|
|
|
- Product `/products/{id}/{nameFaSlug}`
|
|
- Category `/products/category/{id}/{nameFaSlug}`
|
|
- Blog `/blog/{id}/{titleSlug}`
|
|
- Helpers live in `src/lib/slug.ts` — build links with those, never by hand.
|
|
|
|
## Published for the platform
|
|
|
|
- `GET /meshkee/static-image-slots` — slot catalog for the dashboard Refresh button
|
|
- `GET /meshkee/sitemap-config.json` — static pages only, **auto-scanned from
|
|
`src/app` at request time** (`src/app/meshkee/sitemap-config.json/route.ts`)
|
|
rather than hand-listed, so it can never advertise a page that doesn't exist
|
|
yet. Product/category/blog/portfolio detail pages are excluded on purpose —
|
|
Meshkee resolves those straight from the API.
|
|
- Do **not** add `app/sitemap.ts` or `app/robots.ts`: nginx proxies
|
|
`/sitemap.xml` and `/robots.txt` to the API.
|
|
|
|
## SEO rules for every new page
|
|
|
|
Unique `<title>` + meta description, exactly one `<h1>`, real `<h2>` structure,
|
|
non-empty `alt` on every content image.
|
|
|
|
## Scripts
|
|
|
|
```bash
|
|
npm run dev # localhost:3000
|
|
npm run build
|
|
node scripts/gen-category-placeholders.mjs # refresh category thumb placeholders
|
|
```
|
|
|
|
## 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.
|