Wire the product CTA to store-loan-methods calculate so shoppers can pick a term and see the plan, and render English model runs under Montserrat so fanum fonts stop painting 0-9 as Persian digits. Co-authored-by: Cursor <cursoragent@cursor.com>
340 lines
22 KiB
Markdown
340 lines
22 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.
|
|
|
|
<!-- BEGIN:nextjs-agent-rules -->
|
|
|
|
# This is NOT the Next.js you know
|
|
|
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
|
|
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
|
|
|
<!-- END:nextjs-agent-rules -->
|