367 lines
22 KiB
Markdown
367 lines
22 KiB
Markdown
# 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`).
|
||
- **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.
|
||
|
||
## 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
|
||
`/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.
|
||
|
||
## 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.
|
||
|
||
`<main>` 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 |
|
||
| `/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.
|