# 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`. - Detail pages use `src/components/ui/PageShell.tsx` so the max width, page padding, and breadcrumb position cannot drift between product and blog detail layouts. The blog detail page also renders category-matched related posts in the collapsible `SimilarArticles` sidebar; its DOM position follows the article so it naturally moves below the article body on smaller screens. - **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`). - **No emoji as icons** — `src/components/ui/icons.tsx` has the shared light/outline SVG set (`MenuIcon`, `UserIcon`, `CartIcon`, `CloseIcon`, `ChevronLeftIcon`), 24x24 viewBox, `stroke="currentColor"`. Use these instead of characters like "☰"/"👤"/"🛒"/"✕". Every "view more"-style link/button (`SectionTitle`'s `action`, the mega-menu's "مشاهده همه", `BlogCarousel`'s "ادامه مطلب") uses `ChevronLeftIcon`, not a literal "←" character — keep new ones consistent with that. - **Header account/cart cluster** (`Header.tsx`, desktop only — `hidden ... sm:flex`): a borderless `bg-surface` chip holding, in this exact DOM order, `CartButton`, a divider, then the login link. That DOM order is deliberate and easy to get backwards: under `dir="rtl"` the first DOM child renders rightmost, so cart-first-in-DOM is what actually puts the cart icon on the visual **right** and the login block on the **left** (this was built backwards once and corrected). Same rule inside the login link itself — the icon badge comes before the two-line text in DOM so the icon renders right of the text. Neither `CartButton` nor this wrapper has a border (removed on request — don't reintroduce one). `CartButton` is a fixed 44px (`h-11 w-11`) icon-only square everywhere it renders (also stands alone, un-wrapped, below `sm`); its unread-count badge sits at `-top-1.5 -right-1.5`, not the product-card cart badge's `-left-1.5` — don't copy that positioning between the two, they're intentionally different corners for different-looking buttons. ## 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`, `about-us-image`. `about-us-image` is a homepage `single` slot with a `14:9` ratio (the original 560×360 frame). `AboutSanihome` uses its first image, prefers `titleFa`, then `titleEn`, then `subtext` for alt text, wraps the image with `linkUrl` when present, and preserves `/images/about.svg` as the empty/error fallback. 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). **Static-image sizing rule:** whenever a slot's rendered image dimensions or aspect ratio change during development, update the matching `STATIC_IMAGE_SLOT_CATALOG` entry (`aspectRatio` and `recommendedWidth`) in the same change so `/meshkee/static-image-slots` stays accurate for the dashboard. ## Store-item filters `/store-items` has a desktop sidebar and a mobile filter drawer implemented by `components/product/ProductFilters.tsx`. Categories are rendered as a nested accordion (CMS parent categories with their children), not one long flat list. The three currently supported filters are category (`categoryId`), price range (`minPrice` / `maxPrice`), and in-stock only (`inStock=true`). They are submitted as URL search params and passed to `getStoreItems`, so filtering happens in the API before pagination. Pagination links must preserve every active filter. Categories come from `getCategoryTree` and therefore follow CMS data rather than a hardcoded list. On mobile the "فیلتر محصولات" button opens a right-side modal; its submit/reset actions stay fixed at the bottom while filter fields scroll. Desktop filters apply in real time: category and stock changes submit immediately, while price inputs use a short debounce; only the mobile drawer keeps an explicit apply button. Desktop submissions use `scroll={false}` and the client component must not be keyed by active filters, otherwise both the page and the category list jump back to the top. When a parent category or one of its children becomes active, its matching `details` element is opened imperatively without closing other accordions. Do not simulate technical-property filters by fetching every product's `technical-info`: the public API currently exposes technical schema/values only per product, and `/store-items` does not accept technical field filters. When a faceted category-filter endpoint and technical-field query parameters are added to the API, render those returned fields as additional accordions in the same component. ## Product detail tabs and technical specifications `ProductTabs` wraps the specifications, review, and comments UI in one white, rounded, bordered card. Technical specifications render as a responsive two-column table (field label and submitted value), not a loose card grid. `getProductTechnicalInfo()` must join `values[].fieldId` to `form.fields[].id`, resolve option labels from each field's `options`, and sort by the CMS field `sortOrder`; technical `values` do not reliably carry their own labels. If the form or submitted values are empty, keep the existing empty state rather than fabricating rows. ## 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. Each carousel still shows an English caption using that same `enTitle` slot (inline, beside the title — the same treatment as every other section heading on the site, e.g. BlogCarousel's "آخرین مطالب وبلاگ / LATEST BLOG"; there was a brief detour through a separate "below the title, opacity-50, own line" `enSubtitle` prop, which the user then asked to match the existing inline convention instead — don't reintroduce that below-title variant). `StoreSpecialCarousels.englishLabelFromKey()` formats the group's `key` (e.g. `top-selling` → "TOP SELLING") rather than inventing a translation of `title`; there's no real localized-English field to translate from, and a made-up translation would be fabricated data. Skips rendering if the key has no letters at all (fell back to a bare numeric id). **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. `SectionTitle`'s title/action row uses `items-center` (not `items-end`) — needed once carousel titles could carry more content (the inline `enTitle` caption) than a single line, so bottom-aligning the row left the action button visibly offset from the title block. The `action` button itself is styled like the site's other primary buttons (filled `bg-red`, white text, `rounded-lg` — the same look as `ProductCard`'s "افزودن به سبد"), not a plain text link. **"مشاهده همه" (view all)**: each carousel's action button links to `/store-items?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 `/store-items` with `specialId` set just re-renders that exact group's `items` in the full grid beside the same filter UI used by the main catalog. 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. The header's "فروش ویژه" link is `/store-items?special=sale`, and the store-items page resolves the real CMS group at request time with the shared `isSpecialSaleGroup()` matcher (`فروش ویژه` or `special-sale`) before rendering that group's exact items. Do not hardcode a group id in the header. `/store-items` is the one canonical catalog route. `/products` permanently redirects there while preserving all query parameters. Product detail URLs are also canonical under `/store-items/{variant-id|product-slug|product-id}`; the single detail page resolves all three forms and keeps an exact variant selected when a variant id was supplied. Legacy `/products/[slug]` and `/products/id/[id]` routes only redirect for backwards compatibility. ## 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 `` 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. ## Footer: light background, black text, sticky to viewport bottom The footer is `bg-[#f1f1f1]` (exact hex given by the user, same neutral gray as `CartButton`'s background — not swapped for a design-token color; an earlier version used a red-tinted `#9a21291a`, since replaced) with black text throughout (`text-black`, `/70`, `/50`, `/10`, `/20` opacity steps) — it used to be a solid dark-red block with white text; don't revert to that. The footer logo (`logoDarkUrl ?? logoUrl`) is `h-[72px]`, doubled from its original size on request. Because `public/images/meshkee-logo- white.png` (the credit-widget logo below) is a solid-white asset, it gets `brightness-0` on this light background to render as a black silhouette instead of invisible white-on-near-white — the same `opacity-60 → group-hover:opacity-100` fade still applies on top of that. `
` has `flex-1` in `layout.tsx` (`body` is `flex flex-col`) so the footer sits flush at the bottom of the viewport on short pages instead of leaving a gap of bare `background` color beneath it — this is the standard sticky-footer flex pattern. Don't remove `flex-1` from `main` without re-checking a short page (e.g. `/contact`) for that gap coming back. ## Footer: Meshkee credit widget The footer's bottom-right link ("Designed And Developed By Meshkee E-Commerce Team" → `https://meshkee.com`) intentionally matches the credit badge Meshkee's own storefronts use elsewhere (reference: safeteb.com) — same markup shape (frame + logo + two-line label), same animated corner border (four 1px lines that draw in on hover, staggered L→B→R→T, and un-draw in reverse on mouse-leave via matching `transition-delay`s on the resting state). `public/images/meshkee-logo-white.png` is that reference site's own logo asset, pulled down and committed here since it's the platform's shared credit-widget logo, not something unique to safeteb.com. If this ever needs to change, re-derive the exact hover-timing values from a live Meshkee site rather than guessing new ones — the staggered reverse un-draw is the distinctive part, not just "a border that appears." ## Route map | Route | Notes | |---|---| | `/` | Homepage — all the carousels/banners | | `/store-items`, `/store-items/[id]` | Canonical catalog and product detail; filters include category, price and stock; curated groups use `?specialId=` | | `/products`, `/products/[slug]`, `/products/id/[id]` | Backwards-compatible redirects to the canonical `/store-items` routes | | `/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.