Files
sanihome/CONTEXT.md
T
amirhosein.ashourlooandClaude Sonnet 5 7c04316f3a Interleave banners between carousels, real "view all" links, bigger desktop logo
- StoreSpecialCarousels now inserts one of the two banner sections
  (TwoBanners/ThreeBanners, cycling) between every pair of carousels
  instead of stacking all carousels together with the banners fixed
  elsewhere on the page — works for any number of dynamic carousels.
- SectionTitle's action button is a real link now (actionHref), not an
  inert <button>. Each carousel's "مشاهده همه" goes to
  /products?specialId={group.id}, which renders that exact
  admin-curated group in the full product grid — store-specials groups
  have no pagination, so this is genuinely the complete set, not a
  truncated preview. Also fixed BlogCarousel's dead "وبلاگ" button the
  same way, now linking to /blog.
- Reduced FeaturedCategories' vertical padding (py-10/14 -> py-4/6).
- Desktop header logo was smaller than the mobile one (36px vs 46px) —
  bumped to 40/48px so it reads properly at desktop width.
- CONTEXT.md: document the banner-interleaving and specialId patterns.

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

282 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](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):
- Hub: https://api.meshkee.com/docs/website
- OpenAPI: https://api.meshkee.com/docs/website/openapi.json
- AI brief: https://api.meshkee.com/docs/website/AI_PROMPT.md
- The OpenAPI spec's `responses` are often undocumented (just a one-line
description like `{ cart }`) — when a shape isn't documented, `curl` the
live endpoint rather than guessing. Real examples discovered this way are
noted below.
## 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.
**Banner interleaving**: the two static-image banner sections (`TwoBanners`,
`ThreeBanners`) are rendered *inside* `StoreSpecialCarousels`, one inserted
between every pair of carousels (never before the first or after the last),
cycling through the two if there end up being more than two carousels. Don't
move them back out to `page.tsx` as fixed-position siblings — that only
works for a known, fixed carousel count, which this deliberately isn't.
**"مشاهده همه" (view all)**: each carousel's action button links to
`/products?specialId={group.id}`. `store-specials` groups are admin-curated,
already-complete lists (the endpoint has no pagination) — there's no
"more items than the carousel shows" to fetch, so `/products` with
`specialId` set just re-renders that exact group's `items` in the full grid
instead of calling `getProducts()`. This is why the pattern is safe to keep
fully generic (any group id works, forever) rather than needing a
per-carousel filter mapping — there isn't a filter to map to; it's the same
list either way.
## 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=`, one store-specials group via `?specialId=` (see above) |
| `/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.