# 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.`** (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 `

`, 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 `` 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). ## 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.