Add customer auth, real cart/checkout, store-items pages, comments, contact form
- Real in-site login/register (password + SMS OTP) against Meshkee's /auth API, with self-healing access-token refresh (15 min lifetime) and an in-flight-refresh guard so AuthContext and CartContext don't race each other into a failed concurrent refresh. - Real cart: header cart button/badge, a cart popup, and a working "add to cart" on both product cards (where a specific variant id is known) and the product/store-item detail buy-box (always resolves a purchasable variant, even for single-SKU products with no selectable variation). - /checkout: address + cash/bank-transfer payment, posts to the real checkout API and creates an order; refreshes the cart afterward so the header badge doesn't go stale. - New /store-items and /store-items/[id] pages (variant-level browsing, separate from the product-level /products catalog), cross-referencing the parent product for images/description/technical-info. - Product gallery: click-to-open fullscreen lightbox alongside the existing hover-zoom. - Comments tab wired to the real comments API, gated to logged-in users (a product decision — the API itself doesn't require auth) with a login prompt otherwise. - Similar-products row on product/store-item detail pages. - Contact page wired to the real contact-submissions API, with a toast and inline confirmation on success, and the form resetting. - CONTEXT.md: a maintained architecture/conventions doc for this repo, now auto-loaded via CLAUDE.md, covering the Meshkee integration quirks and UI conventions established across this project. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c2552af079
commit
66f92b634e
+230
@@ -0,0 +1,230 @@
|
||||
# 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.
|
||||
- **`auth.ts`** — client-only (uses `localStorage`) Meshkee customer auth:
|
||||
register/login/OTP/refresh. Access tokens last **~15 minutes** — this is
|
||||
short enough that you *will* hit expiry during normal use, so every
|
||||
authenticated call must self-heal (see Auth below), not just fail.
|
||||
- **`cart.ts`** — cart/checkout, needs a Bearer token + `businessId`
|
||||
(`GET /tenants/{domain}` and `.../website/business-info` both return the
|
||||
same numeric id as their `id` field — `getBusinessInfo().id` is enough,
|
||||
no separate tenant-resolve call needed).
|
||||
|
||||
## Auth
|
||||
|
||||
Real in-site login (not a redirect to `customer.sanihome.ir` — that link
|
||||
still exists in the header/footer as a legacy "ورود" pointer but is
|
||||
independent of this system). `AuthProvider` (`src/context/AuthContext.tsx`)
|
||||
+ `LoginModal` (`src/components/auth/LoginModal.tsx`), mounted once in
|
||||
`src/context/Providers.tsx` inside the root layout.
|
||||
|
||||
- Login methods: password (`POST /auth/login`) and SMS OTP
|
||||
(`POST /auth/send-otp` → `POST /auth/login-otp`). Register:
|
||||
`POST /auth/register` (requires `domain: "sanihome.ir"`).
|
||||
- **Confirmed live response shape** (undocumented in OpenAPI, verified by
|
||||
actually registering a test account): `{ accessToken, refreshToken, user }`
|
||||
from register/login/login-otp/refresh alike. `GET /auth/me` returns `{ user }`.
|
||||
- **Password login requires a verified phone number**
|
||||
(`cellNumber is not verified` 401) — a freshly-registered account can still
|
||||
use the tokens *from that register response* immediately, but if the
|
||||
session is lost before verifying, password login is blocked until OTP
|
||||
verification. There's no verify-OTP UI built yet (see Known gaps).
|
||||
- **Token refresh race**: `AuthContext` and `CartContext` each independently
|
||||
notice a 401 and try to refresh. Without coordination this fires two
|
||||
concurrent `/auth/refresh` calls and the second one fails (likely
|
||||
refresh-token rotation server-side) — observed as 500/400 errors. Fixed
|
||||
with an in-flight-promise singleton in `auth.ts`'s `refreshAccessToken()`
|
||||
— **do not** add another independent refresh call anywhere; always go
|
||||
through that function.
|
||||
- `cart.ts` functions take no token parameter — they read the freshest token
|
||||
from storage internally and self-refresh-and-retry once on 401. Gate UI on
|
||||
`useAuth().user` (session identity), not on a captured `accessToken` value.
|
||||
|
||||
## Cart & checkout
|
||||
|
||||
`CartProvider` (`src/context/CartContext.tsx`) fetches the real backend cart
|
||||
whenever `user` changes and exposes `addItem/updateItem/removeItem/refresh`.
|
||||
`CartButton` + `CartPopup` (`src/components/cart/`) live in the header via
|
||||
`Providers`. `/checkout` (`src/app/checkout/page.tsx`) posts to
|
||||
`POST /businesses/{businessId}/cart/checkout` with `payment.type` of `cash`
|
||||
or `transfer` only — no online payment gateway (Zarinpal/Mellat/etc.) is
|
||||
wired up, by explicit scope decision (see Known gaps).
|
||||
|
||||
**Important:** a successful checkout clears the cart server-side. Any flow
|
||||
that calls `checkout()` must also call `useCart().refresh()` afterward, or
|
||||
the header badge and cart state go stale until next reload.
|
||||
|
||||
**Which store-item variant does "add to cart" target?**
|
||||
- On the product/store-item **detail** pages, `ProductPurchasePanel` always
|
||||
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 renders as a link to the detail page instead of a cart action —
|
||||
never fabricate a variant id to force a quick-add.
|
||||
|
||||
## Comments
|
||||
|
||||
`GET/POST /tenants/{domain}/comments` needs no auth on Meshkee's side
|
||||
(`authorName`/`authorEmail` are plain body fields) — the login requirement is
|
||||
a **product decision by this site**, not an API constraint. Gate the comment
|
||||
form in `ProductTabs`' comments panel on `useAuth().user`; when posting, use
|
||||
the logged-in user's name/email. `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).
|
||||
|
||||
## 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 |
|
||||
| `/checkout` | Cart → order, cash/transfer only |
|
||||
| `/contact` | Contact form → `POST /contact-submissions`, toast + reset on success |
|
||||
|
||||
## Known gaps / explicit scope decisions
|
||||
|
||||
- No OTP-verification UI for a registered-but-unverified phone number — if a
|
||||
returning shopper's session is lost pre-verification, password login fails
|
||||
with a clear error but there's no in-app way to complete verification yet
|
||||
(OTP *login* still works as a workaround).
|
||||
- No online payment gateway integration (Zarinpal/Mellat/etc.) — checkout
|
||||
only supports cash-on-delivery and bank transfer, by explicit user choice
|
||||
over building the bank-redirect + return-URL callback flow.
|
||||
- A throwaway test customer account exists in the live business database
|
||||
from building/verifying this integration: `+989120000001` / "Test Dev".
|
||||
Safe to delete from the Meshkee admin panel.
|
||||
Reference in New Issue
Block a user