Add shopping cart, store-items pages, contact form toast; skip workshops+portfolio

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 <noreply@anthropic.com>
This commit is contained in:
amirhosein.ashourloo
2026-09-06 08:56:04 +03:30
co-authored by Claude Sonnet 5
parent e676397bae
commit d624ad8a32
14 changed files with 828 additions and 96 deletions
+61 -9
View File
@@ -23,10 +23,11 @@ fallbacks should update it.
| Endpoint | State | | 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 | | `/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`). | | `/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/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. | | `/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) | | `/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 — - Admin detection (`isAdmin()`) reads `user.isAdmin`/`user.role` defensively —
the OpenAPI schema doesn't document a role field on the user object, so 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. 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 — `storeItemVariantId`, which only exists once a product has store-items —
today that's none of them, so the button always shows "این محصول هنوز برای today that's none of them, so the button always shows "این محصول هنوز برای
خرید آنلاین فعال نشده است" regardless of login state. That's correct given خرید آنلاین فعال نشده است" regardless of login state, and the quantity
the data, not a bug either. 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 ## Static-image slots
@@ -177,7 +205,10 @@ carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **o
- `/contact` — `ContactForm.tsx` posts to the real `POST /contact-submissions` - `/contact` — `ContactForm.tsx` posts to the real `POST /contact-submissions`
(no bearer required); branch cards use `BRANCHES` from `src/data/site.ts` (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 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 - `/installments` — flat, brand-colored hero (no photo, no gradient — this
site stays flat) instead of the reference design's photographic banner; site stays flat) instead of the reference design's photographic banner;
category highlights are a fixed editorial list (`HIGHLIGHT_IDS`), unlike 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 same curated set. One reference category ("خانه هوشمند") has no equivalent
on this tenant and is swapped for "لوازم برقی". 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 - `/products/category/{id}/{slug}` — category listing; merges every leaf in
the subtree (see the leaf-category note above) and paginates in memory. the subtree (see the leaf-category note above) and paginates in memory.
- `/products/{id}/{slug}` — detail: `ProductGallery.tsx` (click-to-lightbox + - `/products/{id}/{slug}` — detail: `ProductGallery.tsx` (click-to-lightbox +
hover-zoom via a background-position overlay, desktop only), 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 (توضیحات / مشخصات فنی / دیدگاه کاربران), related products from
`relatedProducts` on the by-id response. `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 + - `/blog`, `/blog/{id}/{slug}` — same shape, no variants/tabs; just content +
`CommentSection.tsx`. Fallback posts (see table above) only exist in the `CommentSection.tsx`. Fallback posts (see table above) only exist in the
listing grid, not as real detail pages. listing grid, not as real detail pages.
@@ -212,8 +255,17 @@ carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **o
- Product `/products/{id}/{nameFaSlug}` - Product `/products/{id}/{nameFaSlug}`
- Category `/products/category/{id}/{nameFaSlug}` - Category `/products/category/{id}/{nameFaSlug}`
- Blog `/blog/{id}/{titleSlug}` - 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. - 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 ## Published for the platform
- `GET /meshkee/static-image-slots` — slot catalog for the dashboard Refresh button - `GET /meshkee/static-image-slots` — slot catalog for the dashboard Refresh button
+10 -2
View File
@@ -6,11 +6,13 @@ import Header from "@/components/Header";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import TopBar from "@/components/TopBar"; import TopBar from "@/components/TopBar";
import MobileBottomNav from "@/components/MobileBottomNav"; 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 { buildCategoryTree, carouselCategories } from "@/lib/categoryTree";
import { getSession } from "@/lib/auth"; import { getSession } from "@/lib/auth";
import { SITE_NAME } from "@/data/site"; import { SITE_NAME } from "@/data/site";
import { SITE_URL } from "@/lib/config"; 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 }; const NO_BRAND_ASSETS = { faviconUrl: null, logoUrl: null, logoDarkUrl: null, hasDedicatedFavicon: false };
@@ -58,17 +60,21 @@ export async function generateMetadata(): Promise<Metadata> {
} }
export default async function RootLayout({ children }: LayoutProps<"/">) { 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), []), safe(getCategories().then(buildCategoryTree), []),
getSession(), getSession(),
safe(getFavicon(), NO_BRAND_ASSETS), safe(getFavicon(), NO_BRAND_ASSETS),
safe(getTenant(), null),
]); ]);
const menu = carouselCategories(categories); const menu = carouselCategories(categories);
const logoUrl = brand.logoUrl || "/images/logo.png"; const logoUrl = brand.logoUrl || "/images/logo.png";
const businessId = tenant ? String(tenant.id) : "";
return ( return (
<html lang="fa" dir="rtl" className={`${iranYekan.variable} ${montserrat.variable} h-full antialiased`}> <html lang="fa" dir="rtl" className={`${iranYekan.variable} ${montserrat.variable} h-full antialiased`}>
<body className="flex min-h-full flex-col"> <body className="flex min-h-full flex-col">
<ToastProvider>
<CartProvider businessId={businessId} user={user}>
<TopBar /> <TopBar />
<Header menu={menu} user={user} logoUrl={logoUrl} /> <Header menu={menu} user={user} logoUrl={logoUrl} />
<main className="flex-1 pb-6">{children}</main> <main className="flex-1 pb-6">{children}</main>
@@ -76,6 +82,8 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
{/* Spacer so the fixed mobile bar never covers the end of the footer. */} {/* Spacer so the fixed mobile bar never covers the end of the footer. */}
<div className="h-[68px] lg:hidden" /> <div className="h-[68px] lg:hidden" />
<MobileBottomNav categories={menu} user={user} /> <MobileBottomNav categories={menu} user={user} />
</CartProvider>
</ToastProvider>
</body> </body>
</html> </html>
); );
+122
View File
@@ -0,0 +1,122 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Breadcrumbs from "@/components/Breadcrumbs";
import ProductGallery from "@/components/ProductGallery";
import ProductBuyPanel from "@/components/ProductBuyPanel";
import ProductInfoTabs from "@/components/ProductInfoTabs";
import ProductCard from "@/components/ui/ProductCard";
import SectionHeader from "@/components/ui/SectionHeader";
import {
getComments,
getProductById,
getProductTechnicalInfo,
getStoreItemById,
getStoreItemsByProduct,
safe,
} from "@/lib/api";
import { getSession } from "@/lib/auth";
import { buildSpecRows } from "@/lib/technicalInfo";
import { productToCard } from "@/lib/cards";
import { categoryHref } from "@/lib/slug";
// A store-item is a specific purchasable variant, not its own content
// entity — there's no slug, no separate gallery/description/comments for
// it. This page resolves the variant's productId and reuses the product's
// real content (images, description, technical specs, comments), the same
// way ProductBuyPanel's own variant switcher already works — just entered
// from a variant-first URL with that variant preselected.
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
try {
const { variant } = await getStoreItemById(id);
const title = variant.productNameFa || variant.productTitle;
return {
title,
description: title.slice(0, 160),
openGraph: variant.productImage ? { images: [{ url: variant.productImage }] } : undefined,
};
} catch {
return {};
}
}
export default async function StoreItemDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let variant;
try {
({ variant } = await getStoreItemById(id));
} catch {
notFound();
}
let data;
try {
data = await getProductById(variant.productId);
} catch {
notFound();
}
const { product, relatedProducts } = data;
const title = product.nameFa || product.title;
const [{ items: storeItems }, technicalInfo, { items: comments }, user] = await Promise.all([
safe(getStoreItemsByProduct(product.id), { items: [] }),
safe(getProductTechnicalInfo(product.id), { form: null, values: [] }),
safe(getComments("product", product.id), { items: [] }),
getSession(),
]);
const images = [
...(product.thumbnailUrl ? [product.thumbnailUrl] : []),
...product.images.map((img) => img.url),
].filter((url, i, all) => all.indexOf(url) === i);
const specRows = buildSpecRows(technicalInfo.form, technicalInfo.values);
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs
items={[
{ label: "کالاهای موجود", href: "/store-items" },
...(product.categoryId && product.categoryNameFa
? [{ label: product.categoryNameFa, href: categoryHref(product.categoryId, product.categoryNameFa) }]
: []),
{ label: title },
]}
/>
<div className="grid grid-cols-1 gap-8 md:gap-10 lg:grid-cols-[380px_1fr]">
<ProductGallery images={images} title={title} />
<ProductBuyPanel product={product} items={storeItems} user={user} initialVariantId={variant.id} />
</div>
<ProductInfoTabs
title={title}
descriptionHtml={product.descriptionHtml}
specRows={specRows}
comments={comments}
productId={product.id}
user={user}
/>
{relatedProducts.length > 0 && (
<div className="mt-14 md:mt-20">
<SectionHeader
title="محصولات مشابه"
href={product.categoryId ? categoryHref(product.categoryId, product.categoryNameFa) : "/products"}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:gap-5 lg:grid-cols-4">
{relatedProducts.map((p) => (
<ProductCard key={p.id} product={productToCard(p)} />
))}
</div>
</div>
)}
</main>
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { Metadata } from "next";
import Breadcrumbs from "@/components/Breadcrumbs";
import ProductCard from "@/components/ui/ProductCard";
import Pagination from "@/components/ui/Pagination";
import { getStoreItems, safe } from "@/lib/api";
import { storeItemToCard } from "@/lib/cards";
const PAGE_SIZE = 24;
export const metadata: Metadata = {
title: "کالاهای موجود",
description: "فهرست کامل کالاهای قابل خرید فروشگاه موبایل معلم با قیمت و موجودی نهایی.",
};
// Unlike /products (the full catalog, including items with no price yet),
// every card here always has a real price and stock count — this lists
// /store-items directly, the purchasable-variant layer. Empty on this
// tenant today (see AGENTS.md "What the tenant actually returns").
export default async function StoreItemsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; name?: string }>;
}) {
const params = await searchParams;
const page = Math.max(1, Number(params.page) || 1);
const name = params.name?.trim();
const result = await safe(getStoreItems({ page, pageSize: PAGE_SIZE, name }), {
items: [],
total: 0,
page,
pageSize: PAGE_SIZE,
});
const buildHref = (p: number) => {
const qs = new URLSearchParams();
if (name) qs.set("name", name);
if (p > 1) qs.set("page", String(p));
const query = qs.toString();
return `/store-items${query ? `?${query}` : ""}`;
};
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs items={[{ label: "کالاهای موجود" }]} />
<h1 className="mb-1 text-xl font-bold text-ink md:text-2xl">
{name ? `نتایج جستجوی «${name}»` : "کالاهای موجود"}
</h1>
<p className="mb-8 text-[13px] text-body">{result.total.toLocaleString("fa-IR")} کالا</p>
{result.items.length > 0 ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:gap-5 lg:grid-cols-4 xl:grid-cols-5">
{result.items.map((item) => (
<ProductCard key={item.id} product={storeItemToCard(item)} />
))}
</div>
) : (
<p className="rounded-xl border border-dashed border-line bg-surface py-16 text-center text-sm text-body">
کالایی برای نمایش ثبت نشده است.
</p>
)}
<Pagination page={page} pageSize={PAGE_SIZE} total={result.total} buildHref={buildHref} />
</main>
);
}
+150
View File
@@ -0,0 +1,150 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { CartIcon as CartGlyph } from "./icons";
import { useCart } from "./providers/CartProvider";
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
import { formatPrice, toFaDigits } from "@/lib/format";
import type { AuthUser } from "@/lib/types";
// Header/bottom-nav cart control: badge with the live item count, a popup
// previewing the cart, and a "ادامه" button that hands off to the customer
// portal to actually complete checkout (address + payment aren't built on
// this storefront — see AGENTS.md "Shopping cart").
export default function CartButton({
user,
className = "",
labelClassName = "",
showLabel = false,
placement = "down",
wrapperClassName = "relative",
}: {
user: AuthUser | null;
className?: string;
labelClassName?: string;
showLabel?: boolean;
/** "up" for the mobile bottom nav, where the button sits at the screen edge. */
placement?: "down" | "up";
/** The bottom nav needs `flex-1` on this wrapper (not just the button) to
* match its three sibling nav items' width — the header doesn't. */
wrapperClassName?: string;
}) {
const { cart, count, loading } = useCart();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
const items = cart?.items ?? [];
return (
<div ref={ref} className={wrapperClassName}>
<button
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label="سبد خرید"
className={className}
>
<span className="relative inline-flex">
<CartGlyph className="size-5" />
{count > 0 && (
<span className="absolute -left-2 -top-2 flex size-4 items-center justify-center rounded-full bg-white text-[10px] font-bold text-brand">
{toFaDigits(count > 9 ? "9+" : count)}
</span>
)}
</span>
{showLabel && <span className={labelClassName}>سبد خرید</span>}
</button>
{open && (
<div
// "up" (mobile bottom nav) uses `fixed` against the viewport, not
// `absolute` against this button's own flex-1 column (~1/4 of a
// 4-item row) — an inset anchored to that narrow column would
// squeeze the whole cart preview into it instead of spanning
// near-full width above the bar.
className={`z-50 overflow-hidden rounded-xl border border-line bg-white shadow-xl ${
placement === "up"
? "fixed inset-x-4 bottom-[76px]"
: "absolute left-0 top-full mt-2 w-80"
}`}
>
<div className="border-b border-line px-4 py-3">
<h3 className="text-[14px] font-bold text-ink">سبد خرید</h3>
</div>
{!user ? (
<div className="flex flex-col items-center gap-3 p-6 text-center">
<p className="text-[13px] text-body">برای مشاهده سبد خرید ابتدا وارد شوید.</p>
<Link
href={CUSTOMER_PORTAL_URL}
onClick={() => setOpen(false)}
className="rounded-lg bg-brand px-5 py-2 text-[13px] font-bold text-white transition hover:bg-brand-dark"
>
ورود | ثبت‌نام
</Link>
</div>
) : loading ? (
<p className="p-6 text-center text-[13px] text-body">در حال بارگذاری…</p>
) : items.length === 0 ? (
<p className="p-6 text-center text-[13px] text-body">سبد خرید شما خالی است.</p>
) : (
<>
<ul className="max-h-80 divide-y divide-line overflow-auto">
{items.map((item) => {
const title = item.productNameFa || item.productTitle || "کالا";
const price = item.discountedPrice ?? item.price ?? null;
return (
<li key={item.id} className="flex items-center gap-3 px-4 py-3">
<span className="relative size-12 shrink-0 overflow-hidden rounded-lg bg-surface">
{item.image && (
<Image src={item.image} alt={title} fill sizes="48px" className="object-contain" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="clamp-2 block text-[12.5px] leading-5 text-ink-soft">{title}</span>
<span className="mt-0.5 block text-[11px] text-body">
{toFaDigits(item.quantity)} عدد
</span>
</span>
{price !== null && (
<span className="shrink-0 text-[12px] font-bold text-ink">{formatPrice(price)}</span>
)}
</li>
);
})}
</ul>
<div className="border-t border-line p-4">
{cart?.total != null && (
<div className="mb-3 flex items-center justify-between text-[13px]">
<span className="text-body">جمع کل</span>
<span className="font-bold text-ink">
{formatPrice(cart.total)}
<span className="mr-1 font-normal text-body">تومان</span>
</span>
</div>
)}
<Link
href={`${CUSTOMER_PORTAL_URL}/cart`}
onClick={() => setOpen(false)}
className="flex h-11 w-full items-center justify-center rounded-lg bg-brand text-[13px] font-bold text-white transition hover:bg-brand-dark"
>
ادامه
</Link>
</div>
</>
)}
</div>
)}
</div>
);
}
+22 -24
View File
@@ -2,22 +2,28 @@
import { useState } from "react"; import { useState } from "react";
import { submitContactForm } from "@/lib/api"; import { submitContactForm } from "@/lib/api";
import { useToast } from "./providers/ToastProvider";
import { CheckIcon } from "./icons";
type Status = "idle" | "submitting" | "sent" | "error"; type Status = "idle" | "submitting" | "sent" | "error";
// Posts straight to POST /tenants/moalem.shop/contact-submissions — a real, // Posts straight to POST /tenants/moalem.shop/contact-submissions — a real,
// documented endpoint, no bearer required. // documented endpoint, no bearer required. On success: a toast, inline
// success text, and the form resets and stays put (not swapped for a
// separate "thank you" panel) so another message can be sent right away.
export default function ContactForm() { export default function ContactForm() {
const [status, setStatus] = useState<Status>("idle"); const [status, setStatus] = useState<Status>("idle");
const { showToast } = useToast();
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
const form = new FormData(e.currentTarget); const form = e.currentTarget;
const name = String(form.get("name") || "").trim(); const data = new FormData(form);
const title = String(form.get("title") || "").trim(); const name = String(data.get("name") || "").trim();
const text = String(form.get("text") || "").trim(); const title = String(data.get("title") || "").trim();
const email = String(form.get("email") || "").trim(); const text = String(data.get("text") || "").trim();
const cellNumber = String(form.get("cellNumber") || "").trim(); const email = String(data.get("email") || "").trim();
const cellNumber = String(data.get("cellNumber") || "").trim();
if (!name || !title || !text) return; if (!name || !title || !text) return;
setStatus("submitting"); setStatus("submitting");
@@ -29,33 +35,19 @@ export default function ContactForm() {
email: email || undefined, email: email || undefined,
cellNumber: cellNumber || undefined, cellNumber: cellNumber || undefined,
}); });
form.reset();
setStatus("sent"); setStatus("sent");
e.currentTarget.reset(); showToast("پیام شما با موفقیت ارسال شد");
} catch { } catch {
setStatus("error"); setStatus("error");
} }
} }
if (status === "sent") {
return (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-line bg-surface p-10 text-center">
<p className="text-[15px] font-bold text-ink">پیام شما با موفقیت ارسال شد.</p>
<p className="text-[13px] text-body">همکاران ما در اسرع وقت با شما تماس می‌گیرند.</p>
<button
onClick={() => setStatus("idle")}
className="mt-2 rounded-lg border border-line px-4 py-2 text-[13px] text-ink-soft transition hover:border-brand hover:text-brand"
>
ارسال پیام دیگر
</button>
</div>
);
}
const input = const input =
"h-12 w-full rounded-xl border border-line bg-white px-4 text-sm outline-none transition focus:border-brand"; "h-12 w-full rounded-xl border border-line bg-white px-4 text-sm outline-none transition focus:border-brand";
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} onChange={() => status === "sent" && setStatus("idle")} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<input name="name" required placeholder="نام و نام خانوادگی" className={input} /> <input name="name" required placeholder="نام و نام خانوادگی" className={input} />
<input name="cellNumber" placeholder="شماره موبایل" inputMode="tel" className={input} /> <input name="cellNumber" placeholder="شماره موبایل" inputMode="tel" className={input} />
@@ -75,6 +67,12 @@ export default function ContactForm() {
{status === "error" && ( {status === "error" && (
<p className="text-[12.5px] text-brand">ارسال پیام ناموفق بود. لطفاً دوباره تلاش کنید.</p> <p className="text-[12.5px] text-brand">ارسال پیام ناموفق بود. لطفاً دوباره تلاش کنید.</p>
)} )}
{status === "sent" && (
<p className="flex items-center gap-1.5 text-[12.5px] font-bold text-brand">
<CheckIcon className="size-4" />
پیام شما ارسال شد. همکاران ما در اسرع وقت با شما تماس می‌گیرند.
</p>
)}
<button <button
type="submit" type="submit"
+5 -9
View File
@@ -5,9 +5,9 @@ import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import SearchBox from "./SearchBox"; import SearchBox from "./SearchBox";
import AccountMenu from "./AccountMenu"; import AccountMenu from "./AccountMenu";
import { CartIcon, ChevronDown, GridIcon, PhoneIcon } from "./icons"; import CartButton from "./CartButton";
import { ChevronDown, GridIcon, PhoneIcon } from "./icons";
import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME } from "@/data/site"; import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME } from "@/data/site";
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
import { categoryHref } from "@/lib/slug"; import { categoryHref } from "@/lib/slug";
import type { AuthUser, CategoryNode } from "@/lib/types"; import type { AuthUser, CategoryNode } from "@/lib/types";
@@ -51,14 +51,10 @@ export default function Header({
<div className="mr-auto hidden items-center gap-2 lg:flex"> <div className="mr-auto hidden items-center gap-2 lg:flex">
<AccountMenu user={user} /> <AccountMenu user={user} />
<Link <CartButton
href={`${CUSTOMER_PORTAL_URL}/cart`} user={user}
className="flex h-11 items-center gap-2 rounded-xl bg-brand px-3 text-[13px] text-white transition hover:bg-brand-dark" className="flex h-11 items-center gap-2 rounded-xl bg-brand px-3 text-[13px] text-white transition hover:bg-brand-dark"
aria-label="سبد خرید" />
>
<CartIcon className="size-5" />
<span className="font-bold">۰</span>
</Link>
</div> </div>
</div> </div>
+9 -5
View File
@@ -2,7 +2,8 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { CartIcon, ChevronDown, GridIcon, UserIcon } from "./icons"; import { ChevronDown, GridIcon, UserIcon } from "./icons";
import CartButton from "./CartButton";
import { BUSINESS_PORTAL_URL, CUSTOMER_PORTAL_URL } from "@/lib/config"; import { BUSINESS_PORTAL_URL, CUSTOMER_PORTAL_URL } from "@/lib/config";
import { categoryHref } from "@/lib/slug"; import { categoryHref } from "@/lib/slug";
import { displayName, isAdmin } from "@/lib/authUser"; import { displayName, isAdmin } from "@/lib/authUser";
@@ -130,10 +131,13 @@ export default function MobileBottomNav({
خانه خانه
</Link> </Link>
<Link href={`${CUSTOMER_PORTAL_URL}/cart`} className={`${item} text-ink`}> <CartButton
<CartIcon className="size-6" /> user={user}
سبد خرید placement="up"
</Link> wrapperClassName="relative flex-1"
className={`${item} w-full text-ink`}
showLabel
/>
<button <button
onClick={() => setSheetOpen((v) => !v)} onClick={() => setSheetOpen((v) => !v)}
+44 -20
View File
@@ -3,8 +3,10 @@
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { CartIcon, CheckIcon, RotateIcon, ShieldIcon, TruckIcon } from "./icons"; import { CartIcon, CheckIcon, RotateIcon, ShieldIcon, TruckIcon } from "./icons";
import { formatPrice } from "@/lib/format"; import { useCart } from "./providers/CartProvider";
import { AUTH_TOKEN_COOKIE, CUSTOMER_PORTAL_URL } from "@/lib/config"; import { useToast } from "./providers/ToastProvider";
import { formatPrice, toFaDigits } from "@/lib/format";
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
import { DEMO_PRICING, demoPrice } from "@/lib/demoPricing"; import { DEMO_PRICING, demoPrice } from "@/lib/demoPricing";
import type { ApiProduct, AuthUser, StoreItem } from "@/lib/types"; import type { ApiProduct, AuthUser, StoreItem } from "@/lib/types";
@@ -18,15 +20,22 @@ export default function ProductBuyPanel({
product, product,
items = [], items = [],
user, user,
initialVariantId,
}: { }: {
product: ApiProduct; product: ApiProduct;
items?: StoreItem[]; items?: StoreItem[];
user: AuthUser | null; user: AuthUser | null;
/** Preselect a specific variant — used when entering via its own
* /store-items/{id} URL rather than the product page. */
initialVariantId?: string;
}) { }) {
const [selectedId, setSelectedId] = useState(items[0]?.id); const [selectedId, setSelectedId] = useState(initialVariantId ?? items[0]?.id);
const [quantity, setQuantity] = useState(1);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [added, setAdded] = useState(false); const [added, setAdded] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { addItem } = useCart();
const { showToast } = useToast();
const selected = items.find((i) => i.id === selectedId) ?? items[0]; const selected = items.find((i) => i.id === selectedId) ?? items[0];
const title = product.nameFa || product.title; const title = product.nameFa || product.title;
@@ -55,28 +64,16 @@ export default function ProductBuyPanel({
// is nothing a bearer token could add to a cart, logged in or not. // is nothing a bearer token could add to a cart, logged in or not.
const purchasable = items.length > 0 && Boolean(selected); const purchasable = items.length > 0 && Boolean(selected);
const inStock = selected ? selected.stockQuantity > 0 : product.store.inStock; const inStock = selected ? selected.stockQuantity > 0 : product.store.inStock;
const maxQuantity = selected ? Math.max(1, selected.stockQuantity) : 99;
async function handleAddToCart() { async function handleAddToCart() {
if (!selected || !user) return; if (!selected || !user) return;
setAdding(true); setAdding(true);
setError(null); setError(null);
try { try {
const token = document.cookie await addItem(selected.storeItemId, quantity);
.split("; ")
.find((c) => c.startsWith(`${AUTH_TOKEN_COOKIE}=`))
?.split("=")[1];
if (!token) throw new Error("no-token");
const res = await fetch(
`https://api.meshkee.com/api/v1/businesses/${product.businessId}/cart/items`,
{
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ storeItemVariantId: selected.storeItemId, quantity: 1 }),
}
);
if (!res.ok) throw new Error(String(res.status));
setAdded(true); setAdded(true);
showToast(`${quantity > 1 ? `${toFaDigits(quantity)} عدد ` : ""}به سبد خرید اضافه شد`);
setTimeout(() => setAdded(false), 1800); setTimeout(() => setAdded(false), 1800);
} catch { } catch {
setError("افزودن به سبد خرید ناموفق بود. لطفاً دوباره تلاش کنید."); setError("افزودن به سبد خرید ناموفق بود. لطفاً دوباره تلاش کنید.");
@@ -109,7 +106,10 @@ export default function ProductBuyPanel({
<button <button
key={item.id} key={item.id}
type="button" type="button"
onClick={() => setSelectedId(item.id)} onClick={() => {
setSelectedId(item.id);
setQuantity(1);
}}
disabled={item.stockQuantity <= 0} disabled={item.stockQuantity <= 0}
title={item.label} title={item.label}
className={`flex items-center justify-center transition disabled:cursor-not-allowed disabled:opacity-40 ${ className={`flex items-center justify-center transition disabled:cursor-not-allowed disabled:opacity-40 ${
@@ -194,11 +194,34 @@ export default function ProductBuyPanel({
برای خرید وارد شوید برای خرید وارد شوید
</Link> </Link>
) : ( ) : (
<div className="flex items-stretch gap-2">
<div className="flex h-12 items-center rounded-lg border border-line">
<button
type="button"
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
disabled={quantity <= 1}
aria-label="کاهش تعداد"
className="flex h-full w-9 items-center justify-center text-lg text-ink-soft transition hover:text-brand disabled:opacity-30"
>
−
</button>
<span className="w-8 text-center text-sm font-bold text-ink">{toFaDigits(quantity)}</span>
<button
type="button"
onClick={() => setQuantity((q) => Math.min(maxQuantity, q + 1))}
disabled={quantity >= maxQuantity}
aria-label="افزایش تعداد"
className="flex h-full w-9 items-center justify-center text-lg text-ink-soft transition hover:text-brand disabled:opacity-30"
>
+
</button>
</div>
<button <button
type="button" type="button"
onClick={handleAddToCart} onClick={handleAddToCart}
disabled={!inStock || adding} disabled={!inStock || adding}
className={`flex h-12 w-full items-center justify-center gap-2 rounded-lg text-sm font-bold transition disabled:cursor-not-allowed disabled:opacity-40 ${ className={`flex h-12 flex-1 items-center justify-center gap-2 rounded-lg text-sm font-bold transition disabled:cursor-not-allowed disabled:opacity-40 ${
added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark" added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark"
}`} }`}
> >
@@ -214,6 +237,7 @@ export default function ProductBuyPanel({
</> </>
)} )}
</button> </button>
</div>
)} )}
{error && <p className="mt-2 text-center text-[12px] text-brand">{error}</p>} {error && <p className="mt-2 text-center text-[12px] text-brand">{error}</p>}
</div> </div>
+119
View File
@@ -0,0 +1,119 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { addCartItem, getCart, removeCartItem, updateCartItem } from "@/lib/api";
import { AUTH_TOKEN_COOKIE } from "@/lib/config";
import type { AuthUser, Cart } from "@/lib/types";
function readToken(): string | null {
if (typeof document === "undefined") return null;
return (
document.cookie
.split("; ")
.find((c) => c.startsWith(`${AUTH_TOKEN_COOKIE}=`))
?.split("=")[1] ?? null
);
}
interface CartContextValue {
cart: Cart | null;
count: number;
loading: boolean;
refresh: () => Promise<void>;
addItem: (storeItemVariantId: string, quantity?: number) => Promise<void>;
updateItem: (itemId: string, quantity: number) => Promise<void>;
removeItem: (itemId: string) => Promise<void>;
}
const CartContext = createContext<CartContextValue | null>(null);
// Cart is not guest-accessible at the API level (every verb needs a bearer
// token — see api.ts), so this only ever fetches once a session exists.
// Anonymous visitors always see an empty cart, which is correct, not a bug.
export function CartProvider({
businessId,
user,
children,
}: {
businessId: string;
user: AuthUser | null;
children: ReactNode;
}) {
const [cart, setCart] = useState<Cart | null>(null);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
const token = readToken();
if (!token) {
setCart(null);
return;
}
setLoading(true);
try {
const { cart } = await getCart(businessId, token);
setCart(cart);
} catch {
setCart(null);
} finally {
setLoading(false);
}
}, [businessId]);
useEffect(() => {
// refresh() calls setLoading synchronously as its first statement —
// deferred to a microtask so the effect body itself never calls
// setState synchronously.
if (user) queueMicrotask(() => void refresh());
}, [user, refresh]);
const addItem = useCallback(
async (storeItemVariantId: string, quantity = 1) => {
const token = readToken();
if (!token) throw new Error("not-authenticated");
const { cart } = await addCartItem(businessId, token, storeItemVariantId, quantity);
setCart(cart);
},
[businessId]
);
const updateItem = useCallback(
async (itemId: string, quantity: number) => {
const token = readToken();
if (!token) return;
const { cart } = await updateCartItem(businessId, token, itemId, quantity);
setCart(cart);
},
[businessId]
);
const removeItem = useCallback(
async (itemId: string) => {
const token = readToken();
if (!token) return;
const { cart } = await removeCartItem(businessId, token, itemId);
setCart(cart);
},
[businessId]
);
const count = cart?.itemCount ?? cart?.items?.reduce((sum, item) => sum + item.quantity, 0) ?? 0;
return (
<CartContext.Provider value={{ cart, count, loading, refresh, addItem, updateItem, removeItem }}>
{children}
</CartContext.Provider>
);
}
export function useCart(): CartContextValue {
const ctx = useContext(CartContext);
if (!ctx) throw new Error("useCart must be used within CartProvider");
return ctx;
}
@@ -0,0 +1,59 @@
"use client";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { CheckIcon } from "../icons";
interface ToastItem {
id: number;
message: string;
type: "success" | "error";
}
interface ToastContextValue {
showToast: (message: string, type?: "success" | "error") => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
// Minimal global toast — used by the contact form and cart actions. Sits
// above the mobile bottom nav (bottom-20) and drops down on lg where that
// bar doesn't exist.
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const showToast = useCallback((message: string, type: "success" | "error" = "success") => {
const id = Date.now() + Math.random();
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, 3500);
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div
className="pointer-events-none fixed inset-x-0 bottom-20 z-[100] flex flex-col items-center gap-2 px-4 lg:bottom-6"
aria-live="polite"
>
{toasts.map((toast) => (
<div
key={toast.id}
className={`pointer-events-auto flex max-w-sm items-center gap-2 rounded-xl px-4 py-3 text-[13px] font-bold text-white shadow-lg ${
toast.type === "error" ? "bg-ink" : "bg-brand"
}`}
>
{toast.type === "success" && <CheckIcon className="size-4 shrink-0" />}
{toast.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}
+87
View File
@@ -6,6 +6,7 @@ import type {
AuthUser, AuthUser,
BrandAssets, BrandAssets,
BusinessInfo, BusinessInfo,
Cart,
ContactFormInput, ContactFormInput,
EntityComment, EntityComment,
PagedResult, PagedResult,
@@ -67,6 +68,30 @@ async function apiGetAuthed<T>(path: string, token: string): Promise<T> {
return res.json() as Promise<T>; return res.json() as Promise<T>;
} }
// Cart lives under /businesses/{businessId}/..., not /tenants/{domain}/... —
// a different base path from every other function in this file — and every
// call needs the bearer token. All four verbs share this one helper.
async function apiBusiness<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
token: string,
body?: unknown
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
cache: "no-store",
});
if (!res.ok) {
throw new Error(`API request failed (${res.status}): ${path}`);
}
return res.json() as Promise<T>;
}
// Swallows errors so one empty/broken section degrades instead of taking // Swallows errors so one empty/broken section degrades instead of taking
// the whole page down — several modules on this tenant are still unfilled. // the whole page down — several modules on this tenant are still unfilled.
export async function safe<T>(promise: Promise<T>, fallback: T): Promise<T> { export async function safe<T>(promise: Promise<T>, fallback: T): Promise<T> {
@@ -175,6 +200,28 @@ export function getStoreItemsByProduct(productId: string): Promise<{ items: Stor
return apiGet(t("/store-items"), { productId, pageSize: 100 }); return apiGet(t("/store-items"), { productId, pageSize: 100 });
} }
// The full, flat catalog of purchasable variants — every card here always
// has a real price/stock, unlike /products which includes items with
// neither. Empty on this tenant today (see AGENTS.md "What the tenant
// actually returns").
export function getStoreItems(
params: {
page?: number;
pageSize?: number;
categoryId?: string;
brandId?: string;
productId?: string;
name?: string;
inStock?: boolean;
} = {}
): Promise<PagedResult<StoreItem>> {
return apiGet<PagedResult<StoreItem>>(t("/store-items"), params);
}
export function getStoreItemById(variantId: string): Promise<{ variant: StoreItem }> {
return apiGet(t(`/store-items/${variantId}`));
}
export function getComments( export function getComments(
entityType: "product" | "blog" | "portfolio" | "video", entityType: "product" | "blog" | "portfolio" | "video",
entityId: string entityId: string
@@ -206,6 +253,46 @@ export function submitContactForm(
return apiPost(t("/contact-submissions"), data); return apiPost(t("/contact-submissions"), data);
} }
// Cart — every verb requires a bearer token (see AUTH_TOKEN_COOKIE in
// config.ts) and there is no anonymous/guest cart; CartProvider only calls
// these once a session exists. `businessId` is the tenant's business id
// (27 for moalem.shop), read from getTenant()/getFavicon() at the root
// layout rather than hardcoded here.
export function getCart(businessId: string, token: string): Promise<{ cart: Cart }> {
return apiBusiness("GET", `/businesses/${businessId}/cart`, token);
}
export function addCartItem(
businessId: string,
token: string,
storeItemVariantId: string,
quantity = 1
): Promise<{ cart: Cart }> {
return apiBusiness("POST", `/businesses/${businessId}/cart/items`, token, {
storeItemVariantId,
quantity,
});
}
export function updateCartItem(
businessId: string,
token: string,
itemId: string,
quantity: number
): Promise<{ message: string; cart: Cart }> {
return apiBusiness("PATCH", `/businesses/${businessId}/cart/items/${itemId}`, token, {
quantity,
});
}
export function removeCartItem(
businessId: string,
token: string,
itemId: string
): Promise<{ message: string; cart: Cart }> {
return apiBusiness("DELETE", `/businesses/${businessId}/cart/items/${itemId}`, token);
}
export function findSlot(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined { export function findSlot(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined {
const slot = slots.find((s) => s.key === key); const slot = slots.find((s) => s.key === key);
if (!slot || slot.images.length === 0) return undefined; if (!slot || slot.images.length === 0) return undefined;
+20 -1
View File
@@ -1,7 +1,7 @@
import { discountPercent } from "./format"; import { discountPercent } from "./format";
import { productHref } from "./slug"; import { productHref } from "./slug";
import { DEMO_PRICING, demoPrice } from "./demoPricing"; import { DEMO_PRICING, demoPrice } from "./demoPricing";
import type { ApiProduct, ProductCardData, StoreSpecial } from "./types"; import type { ApiProduct, ProductCardData, StoreItem, StoreSpecial } from "./types";
export function productToCard(product: ApiProduct): ProductCardData { export function productToCard(product: ApiProduct): ProductCardData {
const base = { const base = {
@@ -29,6 +29,25 @@ export function productToCard(product: ApiProduct): ProductCardData {
return { ...base, price: null, oldPrice: null, percent: 0, inStock: false }; return { ...base, price: null, oldPrice: null, percent: 0, inStock: false };
} }
// Unlike products, every store-item always has a real price/stock — that's
// the whole point of the /store-items endpoint — so this never touches
// demoPricing. Links to the item's own /store-items/{id} detail page, not
// the parent product's page.
export function storeItemToCard(item: StoreItem): ProductCardData {
const price = item.discountedPrice ?? item.price;
const oldPrice = item.discountedPrice !== null ? item.price : null;
return {
id: item.id,
href: `/store-items/${item.id}`,
title: item.productNameFa || item.productTitle,
image: item.productImage,
price,
oldPrice,
percent: price !== null && oldPrice !== null ? discountPercent(price, oldPrice) : 0,
inStock: item.stockQuantity > 0,
};
}
// A curated store-special group carries its own variant pricing, so the // A curated store-special group carries its own variant pricing, so the
// discount shown there is the real one rather than a product-level guess. // discount shown there is the real one rather than a product-level guess.
export function specialToCards(special: StoreSpecial): ProductCardData[] { export function specialToCards(special: StoreSpecial): ProductCardData[] {
+28
View File
@@ -310,3 +310,31 @@ export interface AuthUser {
isAdmin?: boolean; isAdmin?: boolean;
role?: string; role?: string;
} }
// The cart endpoints (GET/POST/PATCH/DELETE .../cart) document their
// responses only as "{ cart }" / "{ message, cart }" — no field-level
// schema — and this tenant's cart has never been exercised (store-items are
// empty, so nothing has ever been added). Every field below is a best-effort
// guess at a conventional e-commerce cart shape; read all of them
// defensively (CartIcon.tsx does) and treat this as unverified until real
// cart data is seen. See AGENTS.md "Shopping cart".
export interface CartItem {
id: string;
storeItemVariantId?: string;
productId?: string;
productTitle?: string;
productNameFa?: string;
image?: string | null;
selections?: StoreItemSelection[];
price?: number | null;
discountedPrice?: number | null;
quantity: number;
}
export interface Cart {
id?: string;
items: CartItem[];
itemCount?: number;
subtotal?: number;
total?: number;
}