From d624ad8a32962a75b2c600f4691b3ecec554fd87 Mon Sep 17 00:00:00 2001 From: "amirhosein.ashourloo" Date: Sun, 6 Sep 2026 08:56:04 +0330 Subject: [PATCH] Add shopping cart, store-items pages, contact form toast; skip workshops+portfolio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither workshops nor portfolio is in this tenant's enabledModules (["products","blog","finance"]) - same reasoning as before, page/nav entry withheld until either is actually enabled. Shopping cart (new): - CartProvider (client context): live cart state, add/update/remove/refresh, reads AUTH_TOKEN_COOKIE, calls the real /businesses/{id}/cart* endpoints (bearer-only, no guest cart - by design, not a gap) - CartButton: header/bottom-nav icon with a live count badge and a popup preview; "ادامه" hands off to the customer portal's own cart/checkout, since this storefront has no checkout UI (address/payment) to hand off to internally - out of scope for this pass - ProductBuyPanel: quantity stepper, wired to CartProvider.addItem() instead of its own ad-hoc fetch+token logic, toast on success - Cart/CartItem types are a best-effort guess at an undocumented response shape (OpenAPI gives no field-level schema) - unverified against real data since store-items are still empty; read every field defensively Store-items pages (new): /store-items (flat listing of purchasable variants, always priced unlike /products) and /store-items/{id} (detail - no slug, resolves the variant's productId and reuses the product's own gallery/ description/technical-info/comments/related-products, just with that variant preselected). Contact form: now shows a toast + inline success text and resets while staying on screen, instead of swapping to a separate "thank you" panel. New shared providers: ToastProvider (useToast) and CartProvider, both mounted once in layout.tsx. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 70 ++++++++-- src/app/layout.tsx | 26 ++-- src/app/store-items/[id]/page.tsx | 122 +++++++++++++++++ src/app/store-items/page.tsx | 66 +++++++++ src/components/CartButton.tsx | 150 +++++++++++++++++++++ src/components/ContactForm.tsx | 46 +++---- src/components/Header.tsx | 14 +- src/components/MobileBottomNav.tsx | 14 +- src/components/ProductBuyPanel.tsx | 102 ++++++++------ src/components/providers/CartProvider.tsx | 119 ++++++++++++++++ src/components/providers/ToastProvider.tsx | 59 ++++++++ src/lib/api.ts | 87 ++++++++++++ src/lib/cards.ts | 21 ++- src/lib/types.ts | 28 ++++ 14 files changed, 828 insertions(+), 96 deletions(-) create mode 100644 src/app/store-items/[id]/page.tsx create mode 100644 src/app/store-items/page.tsx create mode 100644 src/components/CartButton.tsx create mode 100644 src/components/providers/CartProvider.tsx create mode 100644 src/components/providers/ToastProvider.tsx diff --git a/AGENTS.md b/AGENTS.md index 11813cb..b7db22a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,10 +23,11 @@ fallbacks should update it. | Endpoint | State | |---|---| -| `enabledModules` (`GET /tenants/moalem.shop`) | `["products", "blog", "finance"]` — **`workshops` is not enabled.** Do not build a workshops page/nav entry until it is. | +| `enabledModules` (`GET /tenants/moalem.shop`) | `["products", "blog", "finance"]` — **neither `workshops` nor `portfolio` is enabled.** Do not build a page/nav entry for either until it is; `/portfolios` and `/workshops` both return empty lists today regardless, but the real signal is `enabledModules`, not an empty list (an enabled-but-unfilled module still gets its page, per "Fallbacks" below). | | `/products` | **479 items**, real titles, images, categories | | `/categories?entityType=product` | **48 items**, 3 levels under «کالای دیجیتال». Products are filed on **leaf** categories only — a parent category page/carousel must query its whole subtree (`subtreeIds`/`subtreeIdsAll` in `src/lib/categoryTree.ts`). | -| `/store-items`, `/store-specials` | **empty** → no prices, no discounts, everything `inStock:false`. `/store-items/by-product/{id}` returns a single `{ storeItem }` (or null) — for a product's full variant list use `/store-items?productId=&pageSize=100` (`{ items }`) instead, see `getStoreItemsByProduct` in `src/lib/api.ts`. | +| `/store-items`, `/store-specials` | **empty** → no prices, no discounts, everything `inStock:false`, and `/store-items` (the `getStoreItems()` list used by `/store-items`) has nothing to page through either. `/store-items/by-product/{id}` returns a single `{ storeItem }` (or null) — for a product's full variant list use `/store-items?productId=&pageSize=100` (`{ items }`) instead, see `getStoreItemsByProduct` in `src/lib/api.ts`. | +| Cart (`/businesses/{id}/cart*`) | Never exercised — nothing has ever been added, since there are no real store-items to add. `Cart`/`CartItem` in `src/lib/types.ts` are a **best-effort guess** at the response shape (the OpenAPI doc gives no field-level schema, only "{ cart }" / "{ message, cart }") — read every field defensively, and treat the shape as unverified until real cart data is seen. | | `/website/sliders`, `/website/category-groups` | **empty** | | `/website/static-images` | **filled** — `slider` (2), `categories` (8 of 15), `one/two/three-banner-bg` all have real images. Verified live and rendering correctly; if a future check finds otherwise, suspect a stale build/deploy before suspecting the wiring. | | `/blogs` | **empty** (module enabled) | @@ -116,11 +117,38 @@ anyone will see — this is expected, not a bug, and nothing else breaks. - Admin detection (`isAdmin()`) reads `user.isAdmin`/`user.role` defensively — the OpenAPI schema doesn't document a role field on the user object, so this is a best guess. Update it once the real shape is confirmed. -- Cart ("افزودن به سبد خرید" in `ProductBuyPanel.tsx`) needs a real + +## Shopping cart + +Every cart verb (`GET/POST/PATCH/DELETE /businesses/{businessId}/cart...`) +requires a bearer token — there is **no anonymous/guest cart** at the API +level. `businessId` is the tenant's numeric id (27 for moalem.shop), fetched +once via `getTenant()` in the root layout and passed down as a prop, not +hardcoded. + +- `src/components/providers/CartProvider.tsx` — client context: `cart`, + `count`, `addItem`/`updateItem`/`removeItem`/`refresh`. Reads the same + `AUTH_TOKEN_COOKIE` as everything else auth-related; only fetches when + `user` is non-null (an anonymous visitor always sees an empty cart — by + design, not a bug). Mounted once in `layout.tsx`, wrapping everything. +- `src/components/CartButton.tsx` — the header/bottom-nav icon: live count + badge, a popup previewing cart contents. Its "ادامه" (continue) button + hands off to `${CUSTOMER_PORTAL_URL}/cart` rather than a page on this + site — **this storefront has no checkout UI** (address entry, payment + gateway selection); that whole flow lives on the customer portal per this + project's established account/order architecture. Don't build a checkout + page here without an explicit ask — it's a large, separate feature. + `placement="up"` (bottom nav) renders the popup `fixed` against the + viewport rather than `absolute` against the button's own narrow `flex-1` + column — an `absolute` inset there would squeeze the whole preview into + ~1/4 of the screen width instead of spanning near it. +- `ProductBuyPanel.tsx`'s "افزودن به سبد خرید" needs a real `storeItemVariantId`, which only exists once a product has store-items — today that's none of them, so the button always shows "این محصول هنوز برای - خرید آنلاین فعال نشده است" regardless of login state. That's correct given - the data, not a bug either. + خرید آنلاین فعال نشده است" regardless of login state, and the quantity + stepper never renders. That's correct given the data, not a bug — this + whole path (add → badge updates → popup shows the item) is unverified + against a real cart for the same reason. ## Static-image slots @@ -177,7 +205,10 @@ carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **o - `/contact` — `ContactForm.tsx` posts to the real `POST /contact-submissions` (no bearer required); branch cards use `BRANCHES` from `src/data/site.ts` plus a key-less Google Maps `output=embed` iframe per branch (no API key - needed for a query-based embed). + needed for a query-based embed). On success: a toast (`useToast()`), inline + success text, and the form **resets and stays on screen** rather than being + swapped for a separate "thank you" panel, so another message can be sent + right away. - `/installments` — flat, brand-colored hero (no photo, no gradient — this site stays flat) instead of the reference design's photographic banner; category highlights are a fixed editorial list (`HIGHLIGHT_IDS`), unlike @@ -187,16 +218,28 @@ carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **o same curated set. One reference category ("خانه هوشمند") has no equivalent on this tenant and is swapped for "لوازم برقی". -## Product / blog pages +## Product / store-item / blog pages -- `/products` — full catalog, `?name=` search, `?page=`. +- `/products` — full catalog, `?name=` search, `?page=`. Includes items with + no price/stock yet (most of the catalog today). - `/products/category/{id}/{slug}` — category listing; merges every leaf in the subtree (see the leaf-category note above) and paginates in memory. - `/products/{id}/{slug}` — detail: `ProductGallery.tsx` (click-to-lightbox + hover-zoom via a background-position overlay, desktop only), - `ProductBuyPanel.tsx` (variants, price, auth-aware cart), `ProductInfoTabs.tsx` + `ProductBuyPanel.tsx` (variants, quantity, auth-aware cart), `ProductInfoTabs.tsx` (توضیحات / مشخصات فنی / دیدگاه کاربران), related products from `relatedProducts` on the by-id response. +- `/store-items` — flat listing of every purchasable **variant** + (`getStoreItems()`), unlike `/products`: every card here always has a real + price and stock count, by definition of what a store-item is. +- `/store-items/{id}` — detail for one variant. A store-item has no slug and + no content of its own (no gallery/description/comments in the API) — this + page resolves the variant's `productId` and reuses the **product's** real + content (`ProductGallery`, `ProductInfoTabs`, `relatedProducts`), the same + components the product page uses, just entered from a variant-first URL + with that variant preselected in `ProductBuyPanel` (`initialVariantId`). + Comments are attached to the product (`entityType: "product"`), not the + variant — the comments API has no `storeItem` entity type. - `/blog`, `/blog/{id}/{slug}` — same shape, no variants/tabs; just content + `CommentSection.tsx`. Fallback posts (see table above) only exist in the listing grid, not as real detail pages. @@ -212,8 +255,17 @@ carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **o - Product `/products/{id}/{nameFaSlug}` - Category `/products/category/{id}/{nameFaSlug}` - Blog `/blog/{id}/{titleSlug}` +- Store-item `/store-items/{id}` — no slug segment; `StoreItem` has no `slug` field. - Helpers live in `src/lib/slug.ts` — build links with those, never by hand. +## Toasts + +`src/components/providers/ToastProvider.tsx`, mounted once in `layout.tsx` +alongside `CartProvider`. `useToast().showToast(message, type?)` from any +client component — used by the contact form and `ProductBuyPanel`'s +add-to-cart. Sits above the mobile bottom nav (`bottom-20`, `lg:bottom-6`); +don't reposition it without checking that bar's height. + ## Published for the platform - `GET /meshkee/static-image-slots` — slot catalog for the dashboard Refresh button diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ea3b49e..ae24cf1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,11 +6,13 @@ import Header from "@/components/Header"; import Footer from "@/components/Footer"; import TopBar from "@/components/TopBar"; import MobileBottomNav from "@/components/MobileBottomNav"; -import { getCategories, getFavicon, safe } from "@/lib/api"; +import { getCategories, getFavicon, getTenant, safe } from "@/lib/api"; import { buildCategoryTree, carouselCategories } from "@/lib/categoryTree"; import { getSession } from "@/lib/auth"; import { SITE_NAME } from "@/data/site"; import { SITE_URL } from "@/lib/config"; +import { ToastProvider } from "@/components/providers/ToastProvider"; +import { CartProvider } from "@/components/providers/CartProvider"; const NO_BRAND_ASSETS = { faviconUrl: null, logoUrl: null, logoDarkUrl: null, hasDedicatedFavicon: false }; @@ -58,24 +60,30 @@ export async function generateMetadata(): Promise { } export default async function RootLayout({ children }: LayoutProps<"/">) { - const [categories, user, brand] = await Promise.all([ + const [categories, user, brand, tenant] = await Promise.all([ safe(getCategories().then(buildCategoryTree), []), getSession(), safe(getFavicon(), NO_BRAND_ASSETS), + safe(getTenant(), null), ]); const menu = carouselCategories(categories); const logoUrl = brand.logoUrl || "/images/logo.png"; + const businessId = tenant ? String(tenant.id) : ""; return ( - -
-
{children}
-