Files
moalem-shop/AGENTS.md
T
Alireza HassaniandCursor e625354dc9 Add installment calculator and Latin digits in English product text.
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>
2026-09-25 00:21:40 +03:30

22 KiB

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

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.

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.