# 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: - 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 `` + 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.