Neither workshops nor portfolio is in this tenant's enabledModules
(["products","blog","finance"]) - same reasoning as before, page/nav entry
withheld until either is actually enabled.
Shopping cart (new):
- CartProvider (client context): live cart state, add/update/remove/refresh,
reads AUTH_TOKEN_COOKIE, calls the real /businesses/{id}/cart* endpoints
(bearer-only, no guest cart - by design, not a gap)
- CartButton: header/bottom-nav icon with a live count badge and a popup
preview; "ادامه" hands off to the customer portal's own cart/checkout,
since this storefront has no checkout UI (address/payment) to hand off to
internally - out of scope for this pass
- ProductBuyPanel: quantity stepper, wired to CartProvider.addItem() instead
of its own ad-hoc fetch+token logic, toast on success
- Cart/CartItem types are a best-effort guess at an undocumented response
shape (OpenAPI gives no field-level schema) - unverified against real data
since store-items are still empty; read every field defensively
Store-items pages (new): /store-items (flat listing of purchasable variants,
always priced unlike /products) and /store-items/{id} (detail - no slug,
resolves the variant's productId and reuses the product's own gallery/
description/technical-info/comments/related-products, just with that
variant preselected).
Contact form: now shows a toast + inline success text and resets while
staying on screen, instead of swapping to a separate "thank you" panel.
New shared providers: ToastProvider (useToast) and CartProvider, both
mounted once in layout.tsx.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
19 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 inlinetokens insrc/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 insafe()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} 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. |
Cart (/businesses/{id}/cart*) |
Never exercised — nothing has ever been added, since there are no real store-items to add. Cart/CartItem in src/lib/types.ts are a best-effort guess at the response shape (the OpenAPI doc gives no field-level schema, only "{ cart }" / "{ message, cart }") — read every field defensively, and treat the shape as unverified until real cart data is seen. |
/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()insrc/lib/api.tsreadsGET /tenants/moalem.shop/website/favicon— the documented single source — andHeader.tsx/Footer.tsx/generateMetadata()inlayout.tsxall use it, falling back to the bundled/images/logo.png//favicon.pngonly 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 originalh-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-containcan't fix that, only the replacement asset can. - Contact data (phone, mobile, branch addresses, Instagram/Telegram) in
src/data/site.tsis real, pulled from the live theme's footer —GET /website/business-infostill returns emptyphoneNumbers/addresses/socialMediafor 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 theboxedutility. - 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 toSectionHeaderasnavso they sit beside the «مشاهده همه» pill on desktop; the copy under the track islg:hiddenfor 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 callsGET /auth/me.src/lib/authUser.ts— pure helpers (displayName,isAdmin), safe to import from client components. Never importauth.tsfrom a client component — it pulls innext/headersand breaks the build; that's the entire reason this file is split out.- Admin detection (
isAdmin()) readsuser.isAdmin/user.roledefensively — 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
Every cart verb (GET/POST/PATCH/DELETE /businesses/{businessId}/cart...)
requires a bearer token — there is no anonymous/guest cart at the API
level. businessId is the tenant's numeric id (27 for moalem.shop), fetched
once via getTenant() in the root layout and passed down as a prop, not
hardcoded.
src/components/providers/CartProvider.tsx— client context:cart,count,addItem/updateItem/removeItem/refresh. Reads the sameAUTH_TOKEN_COOKIEas everything else auth-related; only fetches whenuseris non-null (an anonymous visitor always sees an empty cart — by design, not a bug). Mounted once inlayout.tsx, wrapping everything.src/components/CartButton.tsx— the header/bottom-nav icon: live count badge, a popup previewing cart contents. Its "ادامه" (continue) button hands off to${CUSTOMER_PORTAL_URL}/cartrather than a page on this site — this storefront has no checkout UI (address entry, payment gateway selection); that whole flow lives on the customer portal per this project's established account/order architecture. Don't build a checkout page here without an explicit ask — it's a large, separate feature.placement="up"(bottom nav) renders the popupfixedagainst the viewport rather thanabsoluteagainst the button's own narrowflex-1column — anabsoluteinset there would squeeze the whole preview into ~1/4 of the screen width instead of spanning near it.ProductBuyPanel.tsx's "افزودن به سبد خرید" needs a realstoreItemVariantId, which only exists once a product has store-items — today that's none of them, so the button always shows "این محصول هنوز برای خرید آنلاین فعال نشده است" regardless of login state, and the quantity stepper never renders. That's correct given the data, not a bug — this whole path (add → badge updates → popup shows the item) is unverified against a real cart for the same reason.
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.tsxposts to the realPOST /contact-submissions(no bearer required); branch cards useBRANCHESfromsrc/data/site.tsplus a key-less Google Mapsoutput=embediframe 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 matchingcategories-slot image (bylinkUrlslug, 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 fromrelatedProductson 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'sproductIdand 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 inProductBuyPanel(initialVariantId). Comments are attached to the product (entityType: "product"), not the variant — the comments API has nostoreItementity 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[]fromtechnical-infocarries no label, only afieldId— never render it directly. Join againstform.fieldsfirst viasrc/lib/technicalInfo.ts#buildSpecRows. - Comments:
POST /commentsdoesn't require a bearer token at the API level, butCommentSection.tsxgates the form behindgetSession()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;StoreItemhas noslugfield. - 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 buttonGET /meshkee/sitemap-config.json— static pages only, auto-scanned fromsrc/appat 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.tsorapp/robots.ts: nginx proxies/sitemap.xmland/robots.txtto 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.