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:
co-authored by
Claude Sonnet 5
parent
e676397bae
commit
d624ad8a32
@@ -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
|
||||||
|
|||||||
+17
-9
@@ -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,24 +60,30 @@ 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">
|
||||||
<TopBar />
|
<ToastProvider>
|
||||||
<Header menu={menu} user={user} logoUrl={logoUrl} />
|
<CartProvider businessId={businessId} user={user}>
|
||||||
<main className="flex-1 pb-6">{children}</main>
|
<TopBar />
|
||||||
<Footer categories={menu} logoUrl={logoUrl} />
|
<Header menu={menu} user={user} logoUrl={logoUrl} />
|
||||||
{/* Spacer so the fixed mobile bar never covers the end of the footer. */}
|
<main className="flex-1 pb-6">{children}</main>
|
||||||
<div className="h-[68px] lg:hidden" />
|
<Footer categories={menu} logoUrl={logoUrl} />
|
||||||
<MobileBottomNav categories={menu} user={user} />
|
{/* Spacer so the fixed mobile bar never covers the end of the footer. */}
|
||||||
|
<div className="h-[68px] lg:hidden" />
|
||||||
|
<MobileBottomNav categories={menu} user={user} />
|
||||||
|
</CartProvider>
|
||||||
|
</ToastProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 +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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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,26 +194,50 @@ export default function ProductBuyPanel({
|
|||||||
برای خرید وارد شوید
|
برای خرید وارد شوید
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div className="flex items-stretch gap-2">
|
||||||
type="button"
|
<div className="flex h-12 items-center rounded-lg border border-line">
|
||||||
onClick={handleAddToCart}
|
<button
|
||||||
disabled={!inStock || adding}
|
type="button"
|
||||||
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 ${
|
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||||
added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark"
|
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"
|
||||||
{added ? (
|
>
|
||||||
<>
|
−
|
||||||
<CheckIcon className="size-4.5" />
|
</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))}
|
||||||
<CartIcon className="size-4.5" />
|
disabled={quantity >= maxQuantity}
|
||||||
{adding ? "در حال افزودن…" : "افزودن به سبد خرید"}
|
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>
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddToCart}
|
||||||
|
disabled={!inStock || adding}
|
||||||
|
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 ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon className="size-4.5" />
|
||||||
|
افزوده شد
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CartIcon className="size-4.5" />
|
||||||
|
{adding ? "در حال افزودن…" : "افزودن به سبد خرید"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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[] {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user