From 66f92b634ef035ebf19bef213015360589607103 Mon Sep 17 00:00:00 2001 From: "amirhosein.ashourloo" Date: Sun, 6 Sep 2026 08:06:30 +0330 Subject: [PATCH] Add customer auth, real cart/checkout, store-items pages, comments, contact form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/launch.json | 3 +- .gitignore | 1 + CLAUDE.md | 1 + CONTEXT.md | 230 +++++++++++++++ src/app/checkout/page.tsx | 192 +++++++++++++ src/app/contact/page.tsx | 111 ++++++++ src/app/layout.tsx | 11 +- src/app/products/[slug]/page.tsx | 20 +- src/app/products/page.tsx | 3 + src/app/store-items/[id]/page.tsx | 103 +++++++ src/app/store-items/page.tsx | 105 +++++++ src/components/auth/LoginModal.tsx | 262 ++++++++++++++++++ src/components/cart/CartButton.tsx | 26 ++ src/components/cart/CartPopup.tsx | 132 +++++++++ src/components/home/BrandGroupCarousels.tsx | 3 + src/components/home/Header.tsx | 15 +- src/components/home/SpecialSaleCarousel.tsx | 1 + src/components/home/TopSellingCarousel.tsx | 3 + src/components/product/ProductGallery.tsx | 121 +++++++- .../product/ProductPurchasePanel.tsx | 44 ++- src/components/product/ProductTabs.tsx | 100 ++++++- src/components/product/SimilarProducts.tsx | 45 +++ src/components/ui/ProductCard.tsx | 41 ++- src/components/ui/Toast.tsx | 13 + src/context/AuthContext.tsx | 147 ++++++++++ src/context/CartContext.tsx | 121 ++++++++ src/context/Providers.tsx | 18 ++ src/lib/auth.ts | 211 ++++++++++++++ src/lib/cart.ts | 171 ++++++++++++ src/lib/meshkee.ts | 127 ++++++++- 30 files changed, 2344 insertions(+), 37 deletions(-) create mode 100644 CONTEXT.md create mode 100644 src/app/checkout/page.tsx create mode 100644 src/app/contact/page.tsx create mode 100644 src/app/store-items/[id]/page.tsx create mode 100644 src/app/store-items/page.tsx create mode 100644 src/components/auth/LoginModal.tsx create mode 100644 src/components/cart/CartButton.tsx create mode 100644 src/components/cart/CartPopup.tsx create mode 100644 src/components/product/SimilarProducts.tsx create mode 100644 src/components/ui/Toast.tsx create mode 100644 src/context/AuthContext.tsx create mode 100644 src/context/CartContext.tsx create mode 100644 src/context/Providers.tsx create mode 100644 src/lib/auth.ts create mode 100644 src/lib/cart.ts diff --git a/.claude/launch.json b/.claude/launch.json index b209e6a..b061b90 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -5,7 +5,8 @@ "name": "sanihome-dev", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], - "port": 3000 + "port": 3000, + "autoPort": true } ] } diff --git a/.gitignore b/.gitignore index 5ef6a52..59764df 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +banners.psd \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c..ea1f895 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,2 @@ @AGENTS.md +@CONTEXT.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..782dfe6 --- /dev/null +++ b/CONTEXT.md @@ -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 `

`, 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 | +| `/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. diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx new file mode 100644 index 0000000..a4b2943 --- /dev/null +++ b/src/app/checkout/page.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useState } from "react"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { useAuth } from "@/context/AuthContext"; +import { useCart } from "@/context/CartContext"; +import { checkout, type Order } from "@/lib/cart"; +import { formatPrice } from "@/data/sample"; + +type PaymentType = "cash" | "transfer"; + +const inputClass = + "w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus:border-red"; + +export default function CheckoutPage() { + const { user, openLoginModal } = useAuth(); + const { cart, businessId, refresh } = useCart(); + const [province, setProvince] = useState(""); + const [city, setCity] = useState(""); + const [address, setAddress] = useState(""); + const [postalCode, setPostalCode] = useState(""); + const [paymentType, setPaymentType] = useState("cash"); + const [transferRefNumber, setTransferRefNumber] = useState(""); + const [customerNotes, setCustomerNotes] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [order, setOrder] = useState(null); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + const res = await checkout(businessId, { + shippingAddress: { province, city, address, postalCode: postalCode || undefined }, + customerNotes: customerNotes || undefined, + payment: + paymentType === "transfer" + ? { type: "transfer", transferRefNumber: transferRefNumber || undefined } + : { type: "cash" }, + }); + setBusy(false); + if (!res.ok) { + setError(res.message); + return; + } + setOrder(res.data); + refresh(); + }; + + return ( +
+ +

تسویه حساب

+ + {!user ? ( +
+ برای تسویه حساب باید وارد حساب کاربری خود شوید. + +
+ ) : order ? ( +
+

سفارش شما با موفقیت ثبت شد ✓

+

شماره سفارش: {order.orderNumber}

+

+ مبلغ کل: {formatPrice(order.total)} تومان +

+ + بازگشت به فروشگاه ← + +
+ ) : !cart || cart.items.length === 0 ? ( + + ) : ( +
+
+
+

آدرس ارسال

+
+ setProvince(e.target.value)} + className={inputClass} + /> + setCity(e.target.value)} + className={inputClass} + /> +
+