Files
sanihome/CONTEXT.md
T
amirhosein.ashourlooandClaude Sonnet 5 bc6cafaffa Make homepage carousels fully dynamic, drop brand carousels, dynamic logo/favicon
- Replace the two hardcoded homepage carousels (SpecialSaleCarousel
  pinned to store-specials group 0 only, TopSellingCarousel reading
  from an unrelated plain product query) with StoreSpecialCarousels:
  loops over every group GET /store-specials returns, using each
  group's own title. A new carousel created in the admin panel (e.g.
  the "بهترین‌ها از نگاه شما" one just added) shows up automatically,
  no code change needed.
- Remove the brand-groups integration and its "سامسونگ" carousel
  entirely (getBrandGroups, BrandGroupCarousels) — brand-name
  carousels aren't wanted on this site per explicit instruction.
- SectionTitle's enTitle is now optional, since dynamic store-specials
  groups only have one title string, not a Persian/English pair.
- Move featured categories above the hero-adjacent special carousels
  per the requested homepage order; drop the "دسته‌بندی‌های پرطرفدار"
  heading and its "همه دسته‌ها" button, per request.
- Wire the header/footer logo and the favicon to the real business
  data (logoUrl/logoDarkUrl/faviconUrl from GET business-info) instead
  of static files; removed src/app/favicon.ico, which was silently
  winning over the new dynamic <link rel="icon">.
- CONTEXT.md: document the dynamic-carousel contract and the
  logo/favicon wiring so neither regresses to hardcoded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 17:41:46 +03:30

15 KiB
Raw Blame History

sanihome.ir — project context

Keep this file current. Whenever a change touches architecture, conventions, API integration, or adds/removes a major feature, update the relevant section here in the same session. This file exists so any AI assistant or developer picking up the repo cold — without the conversation history that produced it — can get oriented in minutes instead of re-deriving everything from the code and from trial-and-error against the live API.

What this is

A Next.js 16 (App Router, Turbopack) storefront for سانی‌هوم (Sanihome), a Persian-language (RTL) home-appliance retailer, backed entirely by the Meshkee commerce platform's public API. There is no local database — every page reads live from https://api.meshkee.com/api/v1, tenant domain sanihome.ir. See AGENTS.md — Next.js 16 has breaking changes from what most training data assumes; read node_modules/next/dist/docs/ before touching App Router APIs you're not 100% sure of.

Meshkee API docs (always check before assuming a field/endpoint shape):

Stack

  • Next.js 16, Turbopack, TypeScript, Tailwind v4 (@tailwindcss/typography for rich HTML content).
  • Swiper for carousels (src/components/ui/Carousel.tsx for auto-width/free-mode rows; FeaturedCategories.tsx for a fixed slides-per-view row).
  • Two local font files supplied by the client — public/fonts/*.woff (IRANYekan Light + Bold), loaded via next/font/local in src/app/layout.tsx. Body text uses the light weight; every heading tag (h1–h6) uses the bold weight via a plain CSS rule in globals.css — don't reintroduce a Google-fonted Vazirmatn/etc., this was deliberately replaced.
  • No local dev-server port is guaranteed — .claude/launch.json has "autoPort": true because other projects on this machine also default to port 3000.

Data layer (src/lib/)

  • meshkee.ts — all public, unauthenticated reads (and the two unauthenticated writes: comments, contact form) live here. Every normalize* function reads defensively from multiple possible field names because different endpoints shape the "same" concept differently (see Data quirks below). Prefer extending an existing normalize*/type over adding a parallel one.
  • guestCart.ts — the storefront's entire cart story (see next section). No token, no backend call, no businessId — just localStorage + a shared cookie.

Auth, cart & checkout: this site does none of it

This was built wrong once already — an earlier pass added real in-site login (password/OTP), a real backend cart (/businesses/{businessId}/cart), and a /checkout page. That's explicitly not how Meshkee storefronts work, per https://api.meshkee.com/docs/website/AI_PROMPT.md's hard rules: login, register, OTP, the real cart, addresses, and payment all belong to https://customer.<domain> (the customer dashboard) — a separate app, not a page on this site. If you're about to add a /login, /register, /checkout, /cart, or /account route, or call /auth/* or /businesses/{id}/cart* from this codebase: stop, re-read the AI_PROMPT.md "Shopping cart on the storefront" and "Shared login" sections first.

  • Detecting a logged-in shopper: read the meshkee_customer_access_token cookie (written by the customer dashboard with Domain=.sanihome.ir, so it's readable here too) — see isCustomerLoggedIn() in guestCart.ts. Never build a login UI or call /auth/login from this site.
  • Guest cart (guestCart.ts): lines are { id, name, slug, price, originalPrice?, image, quantity } where id is always a storeItemVariantId — never a product id or store-item id. Persisted to localStorage (key meshkee-guest-cart) and a cookie of the same name (Domain=.sanihome.ir, skipped if the encoded JSON is

    ~3500 chars — rely on the URL param instead). CartProvider (src/context/CartContext.tsx) just wraps this in React state; there is no server round-trip, so addItem/updateItem/removeItem are synchronous.

  • "Continue" in the mini-cart (CartPopup) builds a URL with continueToCheckoutUrl(): the guest cart, base64url-encoded (exact algorithm from the spec) into a guestCart query param, pointed at https://customer.sanihome.ir/checkout/cart?guestCart=... if the shopper looks logged in, or .../login?redirect=... (wrapping that same cart URL) if not. The dashboard is responsible for decoding it and syncing it into the real server cart after login — this site's job ends at building that URL correctly.
  • Which variant does "add to cart" target?
    • On the product/store-item detail pages, ProductPurchasePanel resolves a matchedVariant from storeVariants (even for a single-SKU product with no selectable variations — it just uses the one variant) and adds matchedVariant.id.
    • On cards (ProductCard), only sources that already carry a specific variant id can quick-add: Product.variantId, populated in normalizeProduct from raw.variants[0].id (store-specials, brand-groups). Cards from the plain /products list endpoint have no variant info, so the button links to the detail page instead — never fabricate a variant id to force a quick-add.
    • The spec's fuller flow (GET /store-items/by-product/{productId}, then: hide the button if no in-stock variant exists, force a picker when more than one is in stock) isn't separately re-implemented per card — the existing variation-picker UI on the detail page already satisfies "shopper must choose before adding."

Comments

GET/POST /tenants/{domain}/comments needs no auth on Meshkee's side — authorName/authorEmail are plain body fields, same as the contact form. ProductTabs' comments panel is a guest name+email(optional)+text form, no login involved (this also isn't gated on the dashboard cookie — keep it that way; there's no reason to require a customer-dashboard session just to leave a comment). GET /comments only returns approved comments, so a freshly-posted comment won't reappear on refetch until an admin approves it — the UI appends it optimistically to local state instead of re-fetching, which is correct, not a bug.

Data quirks (verified against the live catalog)

  • Persian name always wins: every name-ish field prefers nameFa / productNameFa over the English title/name — this is a Persian storefront front-to-back.
  • Price/discount fields differ by source. Some endpoints (plain /products list) only ever give store.minPrice/maxPrice with no original/discounted distinction. Others (store-specials, brand-groups, store-items) nest price/discountedPrice one level down, in variants[0] or on the item directly. normalizeProduct computes a discount percent from real price/oldPrice numbers when the API doesn't send one explicitly — never invent a percent without two real prices behind it.
  • Most of the catalog has no price at all (store.minPrice: null) and technical-info is empty for almost every product — this is real data state on the client's side, not a bug. The honest-empty-state path (a disabled/"تماس با فروشگاه" control, or an EmptyState message) is the one actually exercised for most products; don't "fix" it by fabricating values.
  • Group-source id vs productId: store-specials/brand-groups/store-items wrap a store-item row whose own id is not the product id — the real product id is raw.productId. normalizeProduct prefers productId, and cards/links that only have this id (no slug) route through /products/id/[id] (src/app/products/id/[id]/page.tsx), which resolves GET /products/by-id/{id} and redirects to the canonical slug URL.
  • Store-items vs Products: a "store item" is one purchasable variant/SKU (id = storeItemVariantId for the cart API). /store-items and /store-items/[id] (variant-level browsing/detail) are a separate surface from /products and /products/[slug] (product-level, with descriptions/technical-info/full variation picker) — the store-item detail page cross-references its parent product (getProductById) for images/description/specs, since the variant endpoint alone only has price/stock/selections.

UI conventions (apply these by default; also noted in this session's

persistent memory as "for all the user's sites", not just this one)

  • Breadcrumb on every page except the homepage — above the <h1>, via src/components/ui/Breadcrumb.tsx.
  • Product detail layout: 3-column grid (desktop) — gallery (ProductGallery, ~1/3, includes hover-zoom + click-to-open lightbox) → title + variation pickers (~5/12) → sticky buy-box (~1/4). One ProductPurchasePanel client component owns the shared selection/quantity/cart state and renders as a Fragment with two grid-column-span children so it can span two non-adjacent grid cells while the server renders the static header content into it via a prop.
  • Discount price display (cards and buy-box both): old price strikethrough + green percent badge on the same line, badge to the old price's left — current price alone, bold, on the next line. Never a min–max range once a real variant-driven price exists.
  • Equal-height cards in a slidesPerView: "auto" + freeMode Swiper row: Swiper's own .swiper-slide { height: 100% } doesn't stretch to the tallest sibling in this mode (no definite ancestor height for the percentage to resolve against). Fix applied in Carousel.tsx: override the <SwiperSlide> to height: "auto" via inline style (wins over the stylesheet regardless of import order) so normal flex-stretch equalizes the row; pair with flex h-full flex-col on the card root and put the button last so it pins to the bottom.
  • Tabbed product-detail section (ProductTabs): مشخصات فنی / نقد و بررسی / نظرات کاربران, in that RTL order (rightmost/default-active first in the array — first DOM child renders rightmost under dir="rtl").
  • Category tile images: 110px, no border, no border-radius (FeaturedCategories.tsx).

Static-image slots

Admin-managed marketing images (banners, category tiles) — not tied to real catalog data. STATIC_IMAGE_SLOT_CATALOG in meshkee.ts is the source of truth for which slots exist; publishing it via src/app/meshkee/static-image-slots/route.ts (/meshkee/static-image-slots) is how the admin dashboard's "refresh" imports new keys. Current keys: home-hero, two-banner, three-banner-row, featured-categories. Adding a slot here does not make images appear — someone still has to upload them in the dashboard; the code path just needs to fall back to whatever the "current" behavior was before the slot existed when it's empty (see FeaturedCategories.tsx for the pattern: real slot images when present, else the pre-existing UI unchanged).

Homepage product carousels — fully dynamic, don't hardcode a name/key

GET /tenants/{domain}/store-specials returns every admin-managed named carousel as one flat list — e.g. "Special Sale", "پرفروش‌ها", "بهترین‌ها از نگاه شما" all come back from this one call, each with its own title and items. StoreSpecialCarousels.tsx maps over the whole list (sorted by sortOrder, empty groups skipped) and renders one titled carousel section per group, using group.title from the API as the heading — no group id/key/title is special-cased in code. This means a brand-new carousel created in the Meshkee dashboard (any name) appears on the homepage the next time the page renders, with zero code changes.

Do not:

  • Hardcode a Persian/English title pair for one of these carousels (that was the old SpecialSaleCarousel/TopSellingCarousel mistake — two separate components each pinned to one group, with a made-up title instead of the API's own title, and TopSellingCarousel didn't even read from store-specials, it re-queried the plain product list).
  • Reintroduce a brand-name carousel (GET /website/brand-groups — used to render a "سامسونگ" section). The user explicitly removed this: brand carousels aren't wanted on this site at all. If similar per-brand curation is wanted later, it should go through admin-managed store-specials groups (a group can be curated by brand already), not a separate brand-groups integration.

SectionTitle's enTitle prop is optional for exactly this reason — these dynamic groups only have one title string from the API, not a Persian/ English pair, so the small English-caps subtitle just doesn't render for them.

Branding: logo & favicon are dynamic, not static files

BusinessInfo (getBusinessInfo(), already fetched in layout.tsx) carries logoUrl, logoDarkUrl, faviconUrl straight from the API — the same values GET /tenants/{domain} and GET .../website/favicon also expose. Header's Logo takes a src prop (falls back to public/images/logo.png only if the API value is null) threaded down from layout.tsx; Footer prefers logoDarkUrl (falls back to logoUrl) since it sits on a dark background. The favicon is set via generateMetadata()'s icons field — there is no src/app/favicon.ico file (deliberately removed): Next.js's static-file favicon convention and a dynamic metadata.icons entry both render a <link rel="icon"> and browsers aren't guaranteed to prefer the second one, so keeping both was silently serving the stale static icon. Don't add app/favicon.ico (or public/favicon.ico) back.

Route map

Route Notes
/ Homepage — all the carousels/banners
/products, /products/[slug] Catalog browsing, category filter via ?categoryId=
/products/id/[id] Redirect-only resolver, see "Group-source id vs productId" above
/store-items, /store-items/[id] Variant-level browsing/detail, see above
/blog, /blog/[slug] Blog listing/detail
/contact Contact form → POST /contact-submissions, toast + reset on success

No /login, /register, /checkout, /cart, or /account route exists here on purpose — see "Auth, cart & checkout" above.

Known gaps / explicit scope decisions

  • Login, registration, OTP, the real cart, addresses, and payment are all out of scope for this codebase by design — they live on customer.sanihome.ir. Don't reintroduce them here.
  • A throwaway test customer account was created against the live business database while an earlier (since-reverted) pass tested a real backend cart: +989120000001 / "Test Dev". Safe to delete from the Meshkee admin panel — it's not used by anything in this codebase anymore.