Files
moalem-shop/AGENTS.md
T
amirhosein.ashourlooandClaude Sonnet 5 7ee43e3e79 Header logo 32px; drop fixed row heights for pt-5
The first row's h-[70px] / lg:h-25 only existed to give the old large
logo room, so they are replaced with pt-5. Logo is h-8 (32px).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 00:19:39 +03:30

330 lines
21 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"]` — **neither `workshops` nor `portfolio` is enabled.** Do not build a page/nav entry for either until it is; `/portfolios` and `/workshops` both return empty lists today regardless, but the real signal is `enabledModules`, not an empty list (an enabled-but-unfilled module still gets its page, per "Fallbacks" below). |
| `/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`, and `/store-items` (the `getStoreItems()` list used by `/store-items`) has nothing to page through either. `/store-items/by-product/{id}` → `{ storeItem: { variants: [...] } \| null }` is the documented source for a product's full variant list — `getStoreItemByProduct()` in `src/lib/api.ts`. |
| Cart (`/businesses/{id}/cart*`) | **Off-limits from this storefront by design**, not a data gap — see "Shopping cart" below. Never call it. |
| `/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` point to a **different business's** mark and are deliberately unused (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 square-thumbnail cards that link to a real local `/blog/{id}/{slug}` page (`getFallbackBlogById()`); that page shows title/category/image plus a "coming soon" note since only those three fields are real, no scraped body content | `/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 |
## 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: **bundled, not from the API** — `public/images/logo.png` (200x48
wordmark, supplied by the business), referenced via `LOGO_SRC`/`LOGO_WIDTH`/
`LOGO_HEIGHT` in `src/lib/config.ts` by `Header.tsx`, `Footer.tsx` and the
og:image in `layout.tsx`. This was an explicit decision (2026-09-20): the
API's `/website/favicon` returned another business's mark, so
`getFavicon()` was removed entirely — don't re-add it. Tab icon is the
bundled `public/favicon.png`. To change the logo, replace the file (keep
the same name; update `LOGO_WIDTH`/`LOGO_HEIGHT` if its shape changes).
- Header (desktop) logo renders at `h-8` (32px), footer logo at `h-[70px]` — sized
independently, on request. The header's first row has no fixed height, just `pt-5`.
- 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`, `"meshkee_customer_access_token"`
— the documented name from AI_PROMPT.md's "Shared login" contract, not a
guess) holding the bearer access token, URL-encoded and possibly **chunked**
into `${name}_0`, `${name}_1`, … with `${name}_n` holding the chunk count
when the JWT is too long for one cookie. Until the dashboard sets it,
`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.
- `reassembleChunkedCookie()` in `src/lib/authUser.ts` — pure, takes a
`(name) => value` reader so both server and client call the same
reassembly logic: tries the bare cookie first, then falls back to the
`_n` + `_0..n-1` chunks.
- `src/lib/auth.ts` — **server-only** (`next/headers`): `getSession()` reads
the (possibly chunked) cookie via `reassembleChunkedCookie()`, URL-decodes
it, and calls `GET /auth/me`.
- `src/lib/authUser.ts` — pure helpers safe to import from client components:
`displayName`, `isAdmin`, `reassembleChunkedCookie`, and
`hasCustomerSessionCookie()` (reads `document.cookie` — used by
`CartButton.tsx`'s Continue logic, see "Shopping cart"). **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.
## Shopping cart
**Guest cart only — this storefront never calls the server cart API.**
Per AI_PROMPT.md's "Shopping cart" section, `/businesses/{id}/cart*` (and
`/cart/checkout`) are customer-dashboard-only; the site's whole cart is a
local, unauthenticated mini-cart that hands off to the dashboard on
"ادامه". Do not add a `businessId` prop, a bearer-authed cart call, or a
checkout/login/address/payment page here — that is explicitly out of scope
per the same doc.
- `src/lib/guestCart.ts` — the entire contract, copied verbatim from
AI_PROMPT.md, not invented: storage key **and** cookie name
`meshkee-guest-cart` (`GUEST_CART_STORAGE_KEY` in `config.ts`), persisted
to both `localStorage` and a `Domain=.{domain}; Path=/; SameSite=Lax;
Max-Age=~30d` cookie (skipped when `encodeURIComponent(json)` exceeds
~3500 chars — the URL param carries it instead on Continue). Each line is
`{ id, name, slug, price, originalPrice?, image, quantity }` where `id`
**must** be the `storeItemVariantId` — adding an id already in the cart
increments `quantity` instead of duplicating the line. `encodeGuestCart()`
is the exact base64url-of-JSON function from the doc — must stay
byte-for-byte identical since the customer dashboard decodes it.
- `src/components/providers/CartProvider.tsx` — thin React context wrapping
`guestCart.ts` (`items`, `count`, `total`, `addItem`/`updateItem`/
`removeItem`) so every consumer re-renders on change. No `user` or
`businessId` prop — works identically for anonymous and signed-in
shoppers, since the cart never touches the server. Reads localStorage
post-mount only (via `queueMicrotask`), never during the initial render,
so SSR markup (always empty) doesn't mismatch.
- `src/components/CartButton.tsx` — the header/bottom-nav icon: live count
badge, a popup with per-line qty/remove/total. "ادامه" builds the Continue
URL exactly per spec: `encodeGuestCart(items)` in the `guestCart` query
param, to `${CUSTOMER_PORTAL_URL}/checkout/cart?guestCart=<encoded>` when
`hasCustomerSessionCookie()` is true, else to
`${CUSTOMER_PORTAL_URL}/login?redirect=<url-encoded checkout path>`. This
storefront has no checkout/address/payment UI of its own — that whole flow
is the customer dashboard's, by design; don't build one here without an
explicit ask. `placement="up"` (bottom nav) renders the popup `fixed`
against the viewport rather than `absolute` against the button's own
narrow `flex-1` column — an `absolute` inset there would squeeze the whole
preview into ~1/4 of the screen width instead of spanning near it.
- `ProductBuyPanel.tsx`'s "افزودن به سبد خرید" — variants come from
`getStoreItemByProduct()` (`GET .../store-items/by-product/{productId}`).
In-stock means `stockQuantity === null` (**unlimited**, not
out-of-stock — see `isInStock()` in `src/lib/format.ts`) or `> 0`; with
more than one in-stock variant the shopper must pick one before the button
enables. No login is required to add — only "ادامه" cares about auth.
Today every product has zero store-items, so the button always shows "این
محصول هنوز برای خرید آنلاین فعال نشده است" and the quantity stepper never
renders — correct given the data, not a bug. Once store-items exist, add
→ badge update → popup line is the same guest-cart path already wired and
manually verified with injected `localStorage` data.
## 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). On success: a toast (`useToast()`), inline
success text, and the form **resets and stays on screen** rather than being
swapped for a separate "thank you" panel, so another message can be sent
right away.
- `/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 / store-item / blog pages
- `/products` — full catalog, `?name=` search, `?page=`. Includes items with
no price/stock yet (most of the catalog today).
- `/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, quantity, auth-aware cart), `ProductInfoTabs.tsx`
(توضیحات / مشخصات فنی / دیدگاه کاربران), related products from
`relatedProducts` on the by-id response.
- `/store-items` — flat listing of every purchasable **variant**
(`getStoreItems()`), unlike `/products`: every card here always has a real
price and stock count, by definition of what a store-item is.
- `/store-items/{id}` — detail for one variant. A store-item has no slug and
no content of its own (no gallery/description/comments in the API) — this
page resolves the variant's `productId` and reuses the **product's** real
content (`ProductGallery`, `ProductInfoTabs`, `relatedProducts`), the same
components the product page uses, just entered from a variant-first URL
with that variant preselected in `ProductBuyPanel` (`initialVariantId`).
Comments are attached to the product (`entityType: "product"`), not the
variant — the comments API has no `storeItem` entity type.
- `/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}`
- Store-item `/store-items/{id}` — no slug segment; `StoreItem` has no `slug` field.
- Helpers live in `src/lib/slug.ts` — build links with those, never by hand.
## Toasts
`src/components/providers/ToastProvider.tsx`, mounted once in `layout.tsx`
alongside `CartProvider`. `useToast().showToast(message, type?)` from any
client component — used by the contact form and `ProductBuyPanel`'s
add-to-cart. Sits above the mobile bottom nav (`bottom-20`, `lg:bottom-6`);
don't reposition it without checking that bar's height.
## 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.