Rewrite cart as client-only guest cart per AI_PROMPT.md spec
Replaces the previous server-cart implementation (which wrongly called
the customer-dashboard-only /businesses/{id}/cart* API) with the
documented guest-cart architecture:
- src/lib/guestCart.ts: storage key/cookie name, cookie attributes,
line shape, and encodeGuestCart() copied verbatim from AI_PROMPT.md
- CartProvider/CartButton: pure client state (localStorage + mirrored
cookie), no bearer auth, no businessId
- CartButton's "ادامه" builds the exact documented Continue URL,
branching on hasCustomerSessionCookie() to either
customer.<domain>/checkout/cart?guestCart=<encoded> or
.../login?redirect=<encoded checkout path>
- Auth cookie corrected to the real documented name
(meshkee_customer_access_token, possibly chunked) via a shared
reassembleChunkedCookie() helper used by both auth.ts (server) and
authUser.ts (client)
- ProductBuyPanel/product pages switched to the documented
GET .../store-items/by-product/{id} endpoint (getStoreItemByProduct)
- isInStock() treats stockQuantity === null as unlimited stock, not
out-of-stock, per spec
- AGENTS.md updated to describe this architecture and remove the
incorrect server-cart documentation
Manually verified in-browser: badge count, popup lines/qty/remove/
total all update correctly against injected guest-cart data; tsc,
eslint, and next build all pass clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d624ad8a32
commit
4ffdf03727
@@ -26,8 +26,8 @@ fallbacks should update it.
|
|||||||
| `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). |
|
| `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`, 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`. |
|
| `/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}` → `{ storeItem: { variants: [...] } \| null }` is the documented source for a product's full variant list — `getStoreItemByProduct()` 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. |
|
| Cart (`/businesses/{id}/cart*`) | **Off-limits from this storefront by design**, not a data gap — see "Shopping cart" below. Never call it. |
|
||||||
| `/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) |
|
||||||
@@ -103,52 +103,83 @@ Login itself happens off-site, on the customer portal (`customer.{domain}`) —
|
|||||||
this storefront never renders a login form. For the header/bottom-nav account
|
this storefront never renders a login form. For the header/bottom-nav account
|
||||||
menu to show "نام عزیز" instead of "ورود | ثبتنام", the customer portal needs
|
menu to show "نام عزیز" instead of "ورود | ثبتنام", the customer portal needs
|
||||||
to set a **non-HttpOnly cookie on the shared parent domain** (`.{domain}`)
|
to set a **non-HttpOnly cookie on the shared parent domain** (`.{domain}`)
|
||||||
named `AUTH_TOKEN_COOKIE` (`src/lib/config.ts`, currently `"meshkee_token"`)
|
named `AUTH_TOKEN_COOKIE` (`src/lib/config.ts`, `"meshkee_customer_access_token"`
|
||||||
holding the bearer access token. Until that exists, `getSession()` in
|
— the documented name from AI_PROMPT.md's "Shared login" contract, not a
|
||||||
`src/lib/auth.ts` always resolves to `null` and the anonymous view is all
|
guess) holding the bearer access token, URL-encoded and possibly **chunked**
|
||||||
anyone will see — this is expected, not a bug, and nothing else breaks.
|
into `${name}_0`, `${name}_1`, … with `${name}_n` holding the chunk count
|
||||||
|
when the JWT is too long for one cookie. Until the dashboard sets it,
|
||||||
|
`getSession()` in `src/lib/auth.ts` always resolves to `null` and the
|
||||||
|
anonymous view is all anyone will see — this is expected, not a bug, and
|
||||||
|
nothing else breaks.
|
||||||
|
|
||||||
|
- `reassembleChunkedCookie()` in `src/lib/authUser.ts` — pure, takes a
|
||||||
|
`(name) => value` reader so both server and client call the same
|
||||||
|
reassembly logic: tries the bare cookie first, then falls back to the
|
||||||
|
`_n` + `_0..n-1` chunks.
|
||||||
- `src/lib/auth.ts` — **server-only** (`next/headers`): `getSession()` reads
|
- `src/lib/auth.ts` — **server-only** (`next/headers`): `getSession()` reads
|
||||||
the cookie and calls `GET /auth/me`.
|
the (possibly chunked) cookie via `reassembleChunkedCookie()`, URL-decodes
|
||||||
- `src/lib/authUser.ts` — pure helpers (`displayName`, `isAdmin`), safe to
|
it, and calls `GET /auth/me`.
|
||||||
import from client components. **Never import `auth.ts` from a client
|
- `src/lib/authUser.ts` — pure helpers safe to import from client components:
|
||||||
component** — it pulls in `next/headers` and breaks the build; that's the
|
`displayName`, `isAdmin`, `reassembleChunkedCookie`, and
|
||||||
entire reason this file is split out.
|
`hasCustomerSessionCookie()` (reads `document.cookie` — used by
|
||||||
|
`CartButton.tsx`'s Continue logic, see "Shopping cart"). **Never import
|
||||||
|
`auth.ts` from a client component** — it pulls in `next/headers` and
|
||||||
|
breaks the build; that's the entire reason this file is split out.
|
||||||
- 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.
|
||||||
|
|
||||||
## Shopping cart
|
## Shopping cart
|
||||||
|
|
||||||
Every cart verb (`GET/POST/PATCH/DELETE /businesses/{businessId}/cart...`)
|
**Guest cart only — this storefront never calls the server cart API.**
|
||||||
requires a bearer token — there is **no anonymous/guest cart** at the API
|
Per AI_PROMPT.md's "Shopping cart" section, `/businesses/{id}/cart*` (and
|
||||||
level. `businessId` is the tenant's numeric id (27 for moalem.shop), fetched
|
`/cart/checkout`) are customer-dashboard-only; the site's whole cart is a
|
||||||
once via `getTenant()` in the root layout and passed down as a prop, not
|
local, unauthenticated mini-cart that hands off to the dashboard on
|
||||||
hardcoded.
|
"ادامه". Do not add a `businessId` prop, a bearer-authed cart call, or a
|
||||||
|
checkout/login/address/payment page here — that is explicitly out of scope
|
||||||
|
per the same doc.
|
||||||
|
|
||||||
- `src/components/providers/CartProvider.tsx` — client context: `cart`,
|
- `src/lib/guestCart.ts` — the entire contract, copied verbatim from
|
||||||
`count`, `addItem`/`updateItem`/`removeItem`/`refresh`. Reads the same
|
AI_PROMPT.md, not invented: storage key **and** cookie name
|
||||||
`AUTH_TOKEN_COOKIE` as everything else auth-related; only fetches when
|
`meshkee-guest-cart` (`GUEST_CART_STORAGE_KEY` in `config.ts`), persisted
|
||||||
`user` is non-null (an anonymous visitor always sees an empty cart — by
|
to both `localStorage` and a `Domain=.{domain}; Path=/; SameSite=Lax;
|
||||||
design, not a bug). Mounted once in `layout.tsx`, wrapping everything.
|
Max-Age=~30d` cookie (skipped when `encodeURIComponent(json)` exceeds
|
||||||
|
~3500 chars — the URL param carries it instead on Continue). Each line is
|
||||||
|
`{ id, name, slug, price, originalPrice?, image, quantity }` where `id`
|
||||||
|
**must** be the `storeItemVariantId` — adding an id already in the cart
|
||||||
|
increments `quantity` instead of duplicating the line. `encodeGuestCart()`
|
||||||
|
is the exact base64url-of-JSON function from the doc — must stay
|
||||||
|
byte-for-byte identical since the customer dashboard decodes it.
|
||||||
|
- `src/components/providers/CartProvider.tsx` — thin React context wrapping
|
||||||
|
`guestCart.ts` (`items`, `count`, `total`, `addItem`/`updateItem`/
|
||||||
|
`removeItem`) so every consumer re-renders on change. No `user` or
|
||||||
|
`businessId` prop — works identically for anonymous and signed-in
|
||||||
|
shoppers, since the cart never touches the server. Reads localStorage
|
||||||
|
post-mount only (via `queueMicrotask`), never during the initial render,
|
||||||
|
so SSR markup (always empty) doesn't mismatch.
|
||||||
- `src/components/CartButton.tsx` — the header/bottom-nav icon: live count
|
- `src/components/CartButton.tsx` — the header/bottom-nav icon: live count
|
||||||
badge, a popup previewing cart contents. Its "ادامه" (continue) button
|
badge, a popup with per-line qty/remove/total. "ادامه" builds the Continue
|
||||||
hands off to `${CUSTOMER_PORTAL_URL}/cart` rather than a page on this
|
URL exactly per spec: `encodeGuestCart(items)` in the `guestCart` query
|
||||||
site — **this storefront has no checkout UI** (address entry, payment
|
param, to `${CUSTOMER_PORTAL_URL}/checkout/cart?guestCart=<encoded>` when
|
||||||
gateway selection); that whole flow lives on the customer portal per this
|
`hasCustomerSessionCookie()` is true, else to
|
||||||
project's established account/order architecture. Don't build a checkout
|
`${CUSTOMER_PORTAL_URL}/login?redirect=<url-encoded checkout path>`. This
|
||||||
page here without an explicit ask — it's a large, separate feature.
|
storefront has no checkout/address/payment UI of its own — that whole flow
|
||||||
`placement="up"` (bottom nav) renders the popup `fixed` against the
|
is the customer dashboard's, by design; don't build one here without an
|
||||||
viewport rather than `absolute` against the button's own narrow `flex-1`
|
explicit ask. `placement="up"` (bottom nav) renders the popup `fixed`
|
||||||
column — an `absolute` inset there would squeeze the whole preview into
|
against the viewport rather than `absolute` against the button's own
|
||||||
~1/4 of the screen width instead of spanning near it.
|
narrow `flex-1` column — an `absolute` inset there would squeeze the whole
|
||||||
- `ProductBuyPanel.tsx`'s "افزودن به سبد خرید" needs a real
|
preview into ~1/4 of the screen width instead of spanning near it.
|
||||||
`storeItemVariantId`, which only exists once a product has store-items —
|
- `ProductBuyPanel.tsx`'s "افزودن به سبد خرید" — variants come from
|
||||||
today that's none of them, so the button always shows "این محصول هنوز برای
|
`getStoreItemByProduct()` (`GET .../store-items/by-product/{productId}`).
|
||||||
خرید آنلاین فعال نشده است" regardless of login state, and the quantity
|
In-stock means `stockQuantity === null` (**unlimited**, not
|
||||||
stepper never renders. That's correct given the data, not a bug — this
|
out-of-stock — see `isInStock()` in `src/lib/format.ts`) or `> 0`; with
|
||||||
whole path (add → badge updates → popup shows the item) is unverified
|
more than one in-stock variant the shopper must pick one before the button
|
||||||
against a real cart for the same reason.
|
enables. No login is required to add — only "ادامه" cares about auth.
|
||||||
|
Today every product has zero store-items, so the button always shows "این
|
||||||
|
محصول هنوز برای خرید آنلاین فعال نشده است" and the quantity stepper never
|
||||||
|
renders — correct given the data, not a bug. Once store-items exist, add
|
||||||
|
→ badge update → popup line is the same guest-cart path already wired and
|
||||||
|
manually verified with injected `localStorage` data.
|
||||||
|
|
||||||
## Static-image slots
|
## Static-image slots
|
||||||
|
|
||||||
|
|||||||
+3
-5
@@ -6,7 +6,7 @@ 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, getTenant, safe } from "@/lib/api";
|
import { getCategories, getFavicon, 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";
|
||||||
@@ -60,21 +60,19 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
const [categories, user, brand, tenant] = await Promise.all([
|
const [categories, user, brand] = 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>
|
<ToastProvider>
|
||||||
<CartProvider businessId={businessId} user={user}>
|
<CartProvider>
|
||||||
<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>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
getComments,
|
getComments,
|
||||||
getProductById,
|
getProductById,
|
||||||
getProductTechnicalInfo,
|
getProductTechnicalInfo,
|
||||||
getStoreItemsByProduct,
|
getStoreItemByProduct,
|
||||||
safe,
|
safe,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { getSession } from "@/lib/auth";
|
import { getSession } from "@/lib/auth";
|
||||||
@@ -50,12 +50,13 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
|||||||
const { product, relatedProducts } = data;
|
const { product, relatedProducts } = data;
|
||||||
const title = product.nameFa || product.title;
|
const title = product.nameFa || product.title;
|
||||||
|
|
||||||
const [{ items: storeItems }, technicalInfo, { items: comments }, user] = await Promise.all([
|
const [{ storeItem }, technicalInfo, { items: comments }, user] = await Promise.all([
|
||||||
safe(getStoreItemsByProduct(product.id), { items: [] }),
|
safe(getStoreItemByProduct(product.id), { storeItem: null }),
|
||||||
safe(getProductTechnicalInfo(product.id), { form: null, values: [] }),
|
safe(getProductTechnicalInfo(product.id), { form: null, values: [] }),
|
||||||
safe(getComments("product", product.id), { items: [] }),
|
safe(getComments("product", product.id), { items: [] }),
|
||||||
getSession(),
|
getSession(),
|
||||||
]);
|
]);
|
||||||
|
const variants = storeItem?.variants ?? [];
|
||||||
|
|
||||||
const images = [
|
const images = [
|
||||||
...(product.thumbnailUrl ? [product.thumbnailUrl] : []),
|
...(product.thumbnailUrl ? [product.thumbnailUrl] : []),
|
||||||
@@ -78,7 +79,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-8 md:gap-10 lg:grid-cols-[380px_1fr]">
|
<div className="grid grid-cols-1 gap-8 md:gap-10 lg:grid-cols-[380px_1fr]">
|
||||||
<ProductGallery images={images} title={title} />
|
<ProductGallery images={images} title={title} />
|
||||||
<ProductBuyPanel product={product} items={storeItems} user={user} />
|
<ProductBuyPanel product={product} variants={variants} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProductInfoTabs
|
<ProductInfoTabs
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
getProductById,
|
getProductById,
|
||||||
getProductTechnicalInfo,
|
getProductTechnicalInfo,
|
||||||
getStoreItemById,
|
getStoreItemById,
|
||||||
getStoreItemsByProduct,
|
getStoreItemByProduct,
|
||||||
safe,
|
safe,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { getSession } from "@/lib/auth";
|
import { getSession } from "@/lib/auth";
|
||||||
@@ -64,12 +64,13 @@ export default async function StoreItemDetailPage({ params }: { params: Promise<
|
|||||||
const { product, relatedProducts } = data;
|
const { product, relatedProducts } = data;
|
||||||
const title = product.nameFa || product.title;
|
const title = product.nameFa || product.title;
|
||||||
|
|
||||||
const [{ items: storeItems }, technicalInfo, { items: comments }, user] = await Promise.all([
|
const [{ storeItem }, technicalInfo, { items: comments }, user] = await Promise.all([
|
||||||
safe(getStoreItemsByProduct(product.id), { items: [] }),
|
safe(getStoreItemByProduct(product.id), { storeItem: null }),
|
||||||
safe(getProductTechnicalInfo(product.id), { form: null, values: [] }),
|
safe(getProductTechnicalInfo(product.id), { form: null, values: [] }),
|
||||||
safe(getComments("product", product.id), { items: [] }),
|
safe(getComments("product", product.id), { items: [] }),
|
||||||
getSession(),
|
getSession(),
|
||||||
]);
|
]);
|
||||||
|
const variants = storeItem?.variants ?? [];
|
||||||
|
|
||||||
const images = [
|
const images = [
|
||||||
...(product.thumbnailUrl ? [product.thumbnailUrl] : []),
|
...(product.thumbnailUrl ? [product.thumbnailUrl] : []),
|
||||||
@@ -92,7 +93,7 @@ export default async function StoreItemDetailPage({ params }: { params: Promise<
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-8 md:gap-10 lg:grid-cols-[380px_1fr]">
|
<div className="grid grid-cols-1 gap-8 md:gap-10 lg:grid-cols-[380px_1fr]">
|
||||||
<ProductGallery images={images} title={title} />
|
<ProductGallery images={images} title={title} />
|
||||||
<ProductBuyPanel product={product} items={storeItems} user={user} initialVariantId={variant.id} />
|
<ProductBuyPanel product={product} variants={variants} initialVariantId={variant.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProductInfoTabs
|
<ProductInfoTabs
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { CartIcon as CartGlyph } from "./icons";
|
import { CartIcon as CartGlyph } from "./icons";
|
||||||
import { useCart } from "./providers/CartProvider";
|
import { useCart } from "./providers/CartProvider";
|
||||||
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
|
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
|
||||||
|
import { encodeGuestCart } from "@/lib/guestCart";
|
||||||
|
import { hasCustomerSessionCookie } from "@/lib/authUser";
|
||||||
import { formatPrice, toFaDigits } from "@/lib/format";
|
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
|
// Header/bottom-nav mini-cart per AI_PROMPT.md "Shopping cart": badge with
|
||||||
// previewing the cart, and a "ادامه" button that hands off to the customer
|
// the live item count, a popup previewing the (local-only) cart, and a
|
||||||
// portal to actually complete checkout (address + payment aren't built on
|
// "ادامه" button that hands the guest cart off to the customer dashboard —
|
||||||
// this storefront — see AGENTS.md "Shopping cart").
|
// this storefront has no login/checkout/address/payment pages of its own,
|
||||||
|
// by design (see AGENTS.md).
|
||||||
export default function CartButton({
|
export default function CartButton({
|
||||||
user,
|
|
||||||
className = "",
|
className = "",
|
||||||
labelClassName = "",
|
labelClassName = "",
|
||||||
showLabel = false,
|
showLabel = false,
|
||||||
placement = "down",
|
placement = "down",
|
||||||
wrapperClassName = "relative",
|
wrapperClassName = "relative",
|
||||||
}: {
|
}: {
|
||||||
user: AuthUser | null;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
labelClassName?: string;
|
labelClassName?: string;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
@@ -31,7 +30,7 @@ export default function CartButton({
|
|||||||
* match its three sibling nav items' width — the header doesn't. */
|
* match its three sibling nav items' width — the header doesn't. */
|
||||||
wrapperClassName?: string;
|
wrapperClassName?: string;
|
||||||
}) {
|
}) {
|
||||||
const { cart, count, loading } = useCart();
|
const { items, count, total, updateItem, removeItem } = useCart();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -43,7 +42,14 @@ export default function CartButton({
|
|||||||
return () => document.removeEventListener("mousedown", onClick);
|
return () => document.removeEventListener("mousedown", onClick);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const items = cart?.items ?? [];
|
function handleContinue() {
|
||||||
|
const encoded = encodeGuestCart(items);
|
||||||
|
const checkoutPath = `/checkout/cart?guestCart=${encoded}`;
|
||||||
|
const url = hasCustomerSessionCookie()
|
||||||
|
? `${CUSTOMER_PORTAL_URL}${checkoutPath}`
|
||||||
|
: `${CUSTOMER_PORTAL_URL}/login?redirect=${encodeURIComponent(checkoutPath)}`;
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={wrapperClassName}>
|
<div ref={ref} className={wrapperClassName}>
|
||||||
@@ -72,74 +78,73 @@ export default function CartButton({
|
|||||||
// squeeze the whole cart preview into it instead of spanning
|
// squeeze the whole cart preview into it instead of spanning
|
||||||
// near-full width above the bar.
|
// near-full width above the bar.
|
||||||
className={`z-50 overflow-hidden rounded-xl border border-line bg-white shadow-xl ${
|
className={`z-50 overflow-hidden rounded-xl border border-line bg-white shadow-xl ${
|
||||||
placement === "up"
|
placement === "up" ? "fixed inset-x-4 bottom-[76px]" : "absolute left-0 top-full mt-2 w-80"
|
||||||
? "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">
|
<div className="border-b border-line px-4 py-3">
|
||||||
<h3 className="text-[14px] font-bold text-ink">سبد خرید</h3>
|
<h3 className="text-[14px] font-bold text-ink">سبد خرید</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!user ? (
|
{items.length === 0 ? (
|
||||||
<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>
|
<p className="p-6 text-center text-[13px] text-body">سبد خرید شما خالی است.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ul className="max-h-80 divide-y divide-line overflow-auto">
|
<ul className="max-h-80 divide-y divide-line overflow-auto">
|
||||||
{items.map((item) => {
|
{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">
|
<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">
|
<span className="relative size-12 shrink-0 overflow-hidden rounded-lg bg-surface">
|
||||||
{item.image && (
|
{item.image && (
|
||||||
<Image src={item.image} alt={title} fill sizes="48px" className="object-contain" />
|
<Image src={item.image} alt={item.name} fill sizes="48px" className="object-contain" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="clamp-2 block text-[12.5px] leading-5 text-ink-soft">{title}</span>
|
<span className="clamp-2 block text-[12.5px] leading-5 text-ink-soft">{item.name}</span>
|
||||||
<span className="mt-0.5 block text-[11px] text-body">
|
<span className="mt-1 flex items-center gap-1.5">
|
||||||
{toFaDigits(item.quantity)} عدد
|
<button
|
||||||
|
onClick={() => updateItem(item.id, item.quantity - 1)}
|
||||||
|
aria-label="کاهش تعداد"
|
||||||
|
className="flex size-5 items-center justify-center rounded border border-line text-[12px] text-ink-soft transition hover:border-brand hover:text-brand"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="w-5 text-center text-[11px] text-body">{toFaDigits(item.quantity)}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => updateItem(item.id, item.quantity + 1)}
|
||||||
|
aria-label="افزایش تعداد"
|
||||||
|
className="flex size-5 items-center justify-center rounded border border-line text-[12px] text-ink-soft transition hover:border-brand hover:text-brand"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{price !== null && (
|
<span className="flex shrink-0 flex-col items-end gap-2">
|
||||||
<span className="shrink-0 text-[12px] font-bold text-ink">{formatPrice(price)}</span>
|
<span className="text-[12px] font-bold text-ink">{formatPrice(item.price)}</span>
|
||||||
)}
|
<button
|
||||||
|
onClick={() => removeItem(item.id)}
|
||||||
|
aria-label="حذف از سبد خرید"
|
||||||
|
className="text-[11px] text-body underline-offset-2 transition hover:text-brand hover:underline"
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="border-t border-line p-4">
|
<div className="border-t border-line p-4">
|
||||||
{cart?.total != null && (
|
|
||||||
<div className="mb-3 flex items-center justify-between text-[13px]">
|
<div className="mb-3 flex items-center justify-between text-[13px]">
|
||||||
<span className="text-body">جمع کل</span>
|
<span className="text-body">جمع کل</span>
|
||||||
<span className="font-bold text-ink">
|
<span className="font-bold text-ink">
|
||||||
{formatPrice(cart.total)}
|
{formatPrice(total)}
|
||||||
<span className="mr-1 font-normal text-body">تومان</span>
|
<span className="mr-1 font-normal text-body">تومان</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<button
|
||||||
<Link
|
onClick={handleContinue}
|
||||||
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -51,10 +51,7 @@ 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} />
|
||||||
<CartButton
|
<CartButton className="flex h-11 items-center gap-2 rounded-xl bg-brand px-3 text-[13px] text-white transition hover:bg-brand-dark" />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ export default function MobileBottomNav({
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<CartButton
|
<CartButton
|
||||||
user={user}
|
|
||||||
placement="up"
|
placement="up"
|
||||||
wrapperClassName="relative flex-1"
|
wrapperClassName="relative flex-1"
|
||||||
className={`${item} w-full text-ink`}
|
className={`${item} w-full text-ink`}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
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 { useCart } from "./providers/CartProvider";
|
import { useCart } from "./providers/CartProvider";
|
||||||
import { useToast } from "./providers/ToastProvider";
|
import { useToast } from "./providers/ToastProvider";
|
||||||
import { formatPrice, toFaDigits } from "@/lib/format";
|
import { formatPrice, isInStock, 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, StoreSpecialVariant } from "@/lib/types";
|
||||||
|
|
||||||
const trustPoints = [
|
const trustPoints = [
|
||||||
{ icon: ShieldIcon, title: "ضمانت اصالت کالا" },
|
{ icon: ShieldIcon, title: "ضمانت اصالت کالا" },
|
||||||
@@ -16,29 +14,34 @@ const trustPoints = [
|
|||||||
{ icon: RotateIcon, title: "تا ۷ روز امکان مرجوعی" },
|
{ icon: RotateIcon, title: "تا ۷ روز امکان مرجوعی" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Add-to-cart follows AI_PROMPT.md "Shopping cart" exactly: variants come
|
||||||
|
// from GET .../store-items/by-product/{id}, "in stock" means
|
||||||
|
// stockQuantity === null (unlimited) or > 0, a shopper with more than one
|
||||||
|
// in-stock variant must pick one before adding, and the cart line's `id` is
|
||||||
|
// the variant id (storeItemVariantId) — never the product id. No login is
|
||||||
|
// required to add; the mini-cart is a local guest cart (see
|
||||||
|
// CartProvider/guestCart.ts) — only "ادامه" in CartButton cares about auth.
|
||||||
export default function ProductBuyPanel({
|
export default function ProductBuyPanel({
|
||||||
product,
|
product,
|
||||||
items = [],
|
variants = [],
|
||||||
user,
|
|
||||||
initialVariantId,
|
initialVariantId,
|
||||||
}: {
|
}: {
|
||||||
product: ApiProduct;
|
product: ApiProduct;
|
||||||
items?: StoreItem[];
|
variants?: StoreSpecialVariant[];
|
||||||
user: AuthUser | null;
|
|
||||||
/** Preselect a specific variant — used when entering via its own
|
/** Preselect a specific variant — used when entering via its own
|
||||||
* /store-items/{id} URL rather than the product page. */
|
* /store-items/{id} URL rather than the product page. */
|
||||||
initialVariantId?: string;
|
initialVariantId?: string;
|
||||||
}) {
|
}) {
|
||||||
const [selectedId, setSelectedId] = useState(initialVariantId ?? items[0]?.id);
|
const inStockVariants = variants.filter((v) => isInStock(v.stockQuantity));
|
||||||
|
const [selectedId, setSelectedId] = useState(initialVariantId ?? inStockVariants[0]?.id);
|
||||||
const [quantity, setQuantity] = useState(1);
|
const [quantity, setQuantity] = useState(1);
|
||||||
const [adding, setAdding] = useState(false);
|
|
||||||
const [added, setAdded] = useState(false);
|
const [added, setAdded] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const { addItem } = useCart();
|
const { addItem } = useCart();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const selected = items.find((i) => i.id === selectedId) ?? items[0];
|
const selected = inStockVariants.find((v) => v.id === selectedId) ?? inStockVariants[0];
|
||||||
|
|
||||||
const title = product.nameFa || product.title;
|
const title = product.nameFa || product.title;
|
||||||
|
const image = product.thumbnailUrl || product.image || product.thumbnail;
|
||||||
const variationName = selected?.selections[0]?.variationName;
|
const variationName = selected?.selections[0]?.variationName;
|
||||||
|
|
||||||
let price: number | null = null;
|
let price: number | null = null;
|
||||||
@@ -55,31 +58,31 @@ export default function ProductBuyPanel({
|
|||||||
oldPrice = demo.oldPrice;
|
oldPrice = demo.oldPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
const percent = price !== null && oldPrice !== null && oldPrice > price
|
const percent =
|
||||||
|
price !== null && oldPrice !== null && oldPrice > price
|
||||||
? Math.round(((oldPrice - price) / oldPrice) * 100)
|
? Math.round(((oldPrice - price) / oldPrice) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Purchasing needs a real store-item variant (cart items are added by
|
// A shopper with more than one in-stock variant must choose before adding
|
||||||
// storeItemVariantId, not productId) — until this product has one, there
|
// — the variant picker below doubles as that requirement, so "ready to
|
||||||
// is nothing a bearer token could add to a cart, logged in or not.
|
// add" just means a selection exists.
|
||||||
const purchasable = items.length > 0 && Boolean(selected);
|
const purchasable = inStockVariants.length > 0 && Boolean(selected);
|
||||||
const inStock = selected ? selected.stockQuantity > 0 : product.store.inStock;
|
const maxQuantity = selected?.stockQuantity == null ? 99 : Math.max(1, selected.stockQuantity);
|
||||||
const maxQuantity = selected ? Math.max(1, selected.stockQuantity) : 99;
|
|
||||||
|
|
||||||
async function handleAddToCart() {
|
function handleAddToCart() {
|
||||||
if (!selected || !user) return;
|
if (!selected) return;
|
||||||
setAdding(true);
|
addItem({
|
||||||
setError(null);
|
id: selected.id,
|
||||||
try {
|
name: title,
|
||||||
await addItem(selected.storeItemId, quantity);
|
slug: product.slug,
|
||||||
|
price: selected.discountedPrice ?? selected.price ?? 0,
|
||||||
|
originalPrice: selected.discountedPrice != null ? (selected.price ?? undefined) : undefined,
|
||||||
|
image,
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
setAdded(true);
|
setAdded(true);
|
||||||
showToast(`${quantity > 1 ? `${toFaDigits(quantity)} عدد ` : ""}به سبد خرید اضافه شد`);
|
showToast(`${quantity > 1 ? `${toFaDigits(quantity)} عدد ` : ""}به سبد خرید اضافه شد`);
|
||||||
setTimeout(() => setAdded(false), 1800);
|
setTimeout(() => setAdded(false), 1800);
|
||||||
} catch {
|
|
||||||
setError("افزودن به سبد خرید ناموفق بود. لطفاً دوباره تلاش کنید.");
|
|
||||||
} finally {
|
|
||||||
setAdding(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -95,28 +98,27 @@ export default function ProductBuyPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{items.length > 0 && (
|
{inStockVariants.length > 0 && (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<p className="mb-2.5 text-sm font-bold text-ink">{variationName || "انتخاب گزینه"}</p>
|
<p className="mb-2.5 text-sm font-bold text-ink">{variationName || "انتخاب گزینه"}</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{items.map((item) => {
|
{inStockVariants.map((variant) => {
|
||||||
const rawValue = item.selections[0]?.value;
|
const rawValue = variant.selections[0]?.value;
|
||||||
const colorHex = rawValue?.startsWith("#") ? rawValue : null;
|
const colorHex = rawValue?.startsWith("#") ? rawValue : null;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={variant.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedId(item.id);
|
setSelectedId(variant.id);
|
||||||
setQuantity(1);
|
setQuantity(1);
|
||||||
}}
|
}}
|
||||||
disabled={item.stockQuantity <= 0}
|
title={variant.label}
|
||||||
title={item.label}
|
className={`flex items-center justify-center transition ${
|
||||||
className={`flex items-center justify-center transition disabled:cursor-not-allowed disabled:opacity-40 ${
|
|
||||||
colorHex
|
colorHex
|
||||||
? `size-9 rounded-full border-2 ${item.id === selectedId ? "border-brand" : "border-line"}`
|
? `size-9 rounded-full border-2 ${variant.id === selectedId ? "border-brand" : "border-line"}`
|
||||||
: `h-10 rounded-lg border px-4 text-sm font-bold ${
|
: `h-10 rounded-lg border px-4 text-sm font-bold ${
|
||||||
item.id === selectedId
|
variant.id === selectedId
|
||||||
? "border-brand bg-brand text-white"
|
? "border-brand bg-brand text-white"
|
||||||
: "border-line text-ink hover:border-brand hover:text-brand"
|
: "border-line text-ink hover:border-brand hover:text-brand"
|
||||||
}`
|
}`
|
||||||
@@ -125,7 +127,7 @@ export default function ProductBuyPanel({
|
|||||||
{colorHex ? (
|
{colorHex ? (
|
||||||
<span className="block size-6 rounded-full" style={{ background: colorHex }} />
|
<span className="block size-6 rounded-full" style={{ background: colorHex }} />
|
||||||
) : (
|
) : (
|
||||||
item.label
|
variant.label
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -149,10 +151,10 @@ export default function ProductBuyPanel({
|
|||||||
<div className="mb-4 flex items-center justify-between border-b border-line pb-4">
|
<div className="mb-4 flex items-center justify-between border-b border-line pb-4">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-bold ${
|
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-bold ${
|
||||||
inStock ? "bg-brand-soft text-brand" : "bg-surface text-body"
|
purchasable ? "bg-brand-soft text-brand" : "bg-surface text-body"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{inStock ? "آماده ارسال" : "ناموجود"}
|
{purchasable ? "آماده ارسال" : "ناموجود"}
|
||||||
</span>
|
</span>
|
||||||
<TruckIcon className="size-5 text-body" />
|
<TruckIcon className="size-5 text-body" />
|
||||||
</div>
|
</div>
|
||||||
@@ -186,13 +188,6 @@ export default function ProductBuyPanel({
|
|||||||
<p className="rounded-lg bg-surface px-3 py-2.5 text-center text-[12.5px] text-body">
|
<p className="rounded-lg bg-surface px-3 py-2.5 text-center text-[12.5px] text-body">
|
||||||
این محصول هنوز برای خرید آنلاین فعال نشده است.
|
این محصول هنوز برای خرید آنلاین فعال نشده است.
|
||||||
</p>
|
</p>
|
||||||
) : !user ? (
|
|
||||||
<Link
|
|
||||||
href={CUSTOMER_PORTAL_URL}
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-brand text-sm font-bold text-white transition hover:bg-brand-dark"
|
|
||||||
>
|
|
||||||
برای خرید وارد شوید
|
|
||||||
</Link>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-stretch gap-2">
|
<div className="flex items-stretch gap-2">
|
||||||
<div className="flex h-12 items-center rounded-lg border border-line">
|
<div className="flex h-12 items-center rounded-lg border border-line">
|
||||||
@@ -220,8 +215,7 @@ export default function ProductBuyPanel({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddToCart}
|
onClick={handleAddToCart}
|
||||||
disabled={!inStock || adding}
|
className={`flex h-12 flex-1 items-center justify-center gap-2 rounded-lg text-sm font-bold transition ${
|
||||||
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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -233,13 +227,12 @@ export default function ProductBuyPanel({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CartIcon className="size-4.5" />
|
<CartIcon className="size-4.5" />
|
||||||
{adding ? "در حال افزودن…" : "افزودن به سبد خرید"}
|
افزودن به سبد خرید
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && <p className="mt-2 text-center text-[12px] text-brand">{error}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4 rounded-2xl border border-dashed border-line p-5">
|
<div className="grid grid-cols-3 gap-4 rounded-2xl border border-dashed border-line p-5">
|
||||||
|
|||||||
@@ -1,112 +1,57 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
createContext,
|
addGuestCartItem,
|
||||||
useCallback,
|
readGuestCart,
|
||||||
useContext,
|
removeGuestCartItem,
|
||||||
useEffect,
|
updateGuestCartItem,
|
||||||
useState,
|
type GuestCartItem,
|
||||||
type ReactNode,
|
} from "@/lib/guestCart";
|
||||||
} 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 {
|
interface CartContextValue {
|
||||||
cart: Cart | null;
|
items: GuestCartItem[];
|
||||||
count: number;
|
count: number;
|
||||||
loading: boolean;
|
total: number;
|
||||||
refresh: () => Promise<void>;
|
addItem: (item: GuestCartItem) => void;
|
||||||
addItem: (storeItemVariantId: string, quantity?: number) => Promise<void>;
|
updateItem: (id: string, quantity: number) => void;
|
||||||
updateItem: (itemId: string, quantity: number) => Promise<void>;
|
removeItem: (id: string) => void;
|
||||||
removeItem: (itemId: string) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CartContext = createContext<CartContextValue | null>(null);
|
const CartContext = createContext<CartContextValue | null>(null);
|
||||||
|
|
||||||
// Cart is not guest-accessible at the API level (every verb needs a bearer
|
// The mini-cart is local-only per AI_PROMPT.md "Shopping cart" — no server
|
||||||
// token — see api.ts), so this only ever fetches once a session exists.
|
// call, no auth required. State lives in localStorage (mirrored to a cookie
|
||||||
// Anonymous visitors always see an empty cart, which is correct, not a bug.
|
// for the customer dashboard to read); this provider is just a thin React
|
||||||
export function CartProvider({
|
// wrapper around src/lib/guestCart.ts so every consumer re-renders on change.
|
||||||
businessId,
|
export function CartProvider({ children }: { children: ReactNode }) {
|
||||||
user,
|
const [items, setItems] = useState<GuestCartItem[]>([]);
|
||||||
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]);
|
|
||||||
|
|
||||||
|
// Reading localStorage during the initial render would mismatch the
|
||||||
|
// server-rendered (empty) markup, so the real cart loads post-mount —
|
||||||
|
// deferred to a microtask so the effect body itself never calls setState
|
||||||
|
// synchronously.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// refresh() calls setLoading synchronously as its first statement —
|
queueMicrotask(() => setItems(readGuestCart()));
|
||||||
// deferred to a microtask so the effect body itself never calls
|
}, []);
|
||||||
// setState synchronously.
|
|
||||||
if (user) queueMicrotask(() => void refresh());
|
|
||||||
}, [user, refresh]);
|
|
||||||
|
|
||||||
const addItem = useCallback(
|
const addItem = useCallback((item: GuestCartItem) => {
|
||||||
async (storeItemVariantId: string, quantity = 1) => {
|
setItems(addGuestCartItem(item));
|
||||||
const token = readToken();
|
}, []);
|
||||||
if (!token) throw new Error("not-authenticated");
|
|
||||||
const { cart } = await addCartItem(businessId, token, storeItemVariantId, quantity);
|
|
||||||
setCart(cart);
|
|
||||||
},
|
|
||||||
[businessId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateItem = useCallback(
|
const updateItem = useCallback((id: string, quantity: number) => {
|
||||||
async (itemId: string, quantity: number) => {
|
setItems(updateGuestCartItem(id, quantity));
|
||||||
const token = readToken();
|
}, []);
|
||||||
if (!token) return;
|
|
||||||
const { cart } = await updateCartItem(businessId, token, itemId, quantity);
|
|
||||||
setCart(cart);
|
|
||||||
},
|
|
||||||
[businessId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeItem = useCallback(
|
const removeItem = useCallback((id: string) => {
|
||||||
async (itemId: string) => {
|
setItems(removeGuestCartItem(id));
|
||||||
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;
|
const count = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
|
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CartContext.Provider value={{ cart, count, loading, refresh, addItem, updateItem, removeItem }}>
|
<CartContext.Provider value={{ items, count, total, addItem, updateItem, removeItem }}>
|
||||||
{children}
|
{children}
|
||||||
</CartContext.Provider>
|
</CartContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
+14
-70
@@ -6,7 +6,6 @@ import type {
|
|||||||
AuthUser,
|
AuthUser,
|
||||||
BrandAssets,
|
BrandAssets,
|
||||||
BusinessInfo,
|
BusinessInfo,
|
||||||
Cart,
|
|
||||||
ContactFormInput,
|
ContactFormInput,
|
||||||
EntityComment,
|
EntityComment,
|
||||||
PagedResult,
|
PagedResult,
|
||||||
@@ -14,6 +13,7 @@ import type {
|
|||||||
SliderItem,
|
SliderItem,
|
||||||
StaticImageSlot,
|
StaticImageSlot,
|
||||||
StoreItem,
|
StoreItem,
|
||||||
|
StoreItemVariants,
|
||||||
StoreSpecial,
|
StoreSpecial,
|
||||||
Tenant,
|
Tenant,
|
||||||
TechnicalInfoForm,
|
TechnicalInfoForm,
|
||||||
@@ -68,30 +68,6 @@ 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> {
|
||||||
@@ -192,12 +168,15 @@ export function getProductTechnicalInfo(
|
|||||||
return apiGet(t(`/products/by-id/${productId}/technical-info`));
|
return apiGet(t(`/products/by-id/${productId}/technical-info`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not /store-items/by-product/{id} — that one returns a single { storeItem }
|
// The documented source for a product's purchasable variants (AI_PROMPT.md
|
||||||
// (or null), not the full variant list a buy panel needs. The paginated
|
// "Shopping cart", step 1) — { storeItem: null } when the product has no
|
||||||
// /store-items list, filtered by productId, is the one that returns every
|
// store-item at all. storeItem.variants is what the buy panel and the
|
||||||
// purchasable variant.
|
// add-to-cart flow both read; never call /store-items?productId= for this,
|
||||||
export function getStoreItemsByProduct(productId: string): Promise<{ items: StoreItem[] }> {
|
// that's a different, unrelated listing (see getStoreItems below).
|
||||||
return apiGet(t("/store-items"), { productId, pageSize: 100 });
|
export function getStoreItemByProduct(
|
||||||
|
productId: string
|
||||||
|
): Promise<{ storeItem: StoreItemVariants | null }> {
|
||||||
|
return apiGet(t(`/store-items/by-product/${productId}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The full, flat catalog of purchasable variants — every card here always
|
// The full, flat catalog of purchasable variants — every card here always
|
||||||
@@ -253,45 +232,10 @@ 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
|
// No cart functions here on purpose — AI_PROMPT.md "Hard rules" #3 and
|
||||||
// config.ts) and there is no anonymous/guest cart; CartProvider only calls
|
// "Shopping cart" are explicit that `/businesses/{id}/cart*` is
|
||||||
// these once a session exists. `businessId` is the tenant's business id
|
// customer-dashboard-only. The mini-cart is local-only; see
|
||||||
// (27 for moalem.shop), read from getTenant()/getFavicon() at the root
|
// src/lib/guestCart.ts.
|
||||||
// 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);
|
||||||
|
|||||||
+9
-6
@@ -1,18 +1,21 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getMe, safe } from "./api";
|
import { getMe, safe } from "./api";
|
||||||
import { AUTH_TOKEN_COOKIE } from "./config";
|
import { AUTH_TOKEN_COOKIE } from "./config";
|
||||||
|
import { reassembleChunkedCookie } from "./authUser";
|
||||||
import type { AuthUser } from "./types";
|
import type { AuthUser } from "./types";
|
||||||
|
|
||||||
export { displayName, isAdmin } from "./authUser";
|
export { displayName, isAdmin } from "./authUser";
|
||||||
|
|
||||||
// Server-side "am I logged in" check for the header/account menu. See the
|
// Server-side "am I logged in" check for the header/account menu. Reads the
|
||||||
// AUTH_TOKEN_COOKIE comment in config.ts for the one external dependency
|
// cookie the customer dashboard writes on login (see AUTH_TOKEN_COOKIE in
|
||||||
// this relies on. Resolves to null whenever the cookie is absent or /auth/me
|
// config.ts and AI_PROMPT.md "Shared login") — this storefront never issues
|
||||||
// fails — never throws, so a missing session degrades to the anonymous view.
|
// it. Resolves to null whenever the cookie is absent or /auth/me fails —
|
||||||
|
// never throws, so a missing session degrades to the anonymous view.
|
||||||
export async function getSession(): Promise<AuthUser | null> {
|
export async function getSession(): Promise<AuthUser | null> {
|
||||||
const store = await cookies();
|
const store = await cookies();
|
||||||
const token = store.get(AUTH_TOKEN_COOKIE)?.value;
|
const raw = reassembleChunkedCookie((name) => store.get(name)?.value, AUTH_TOKEN_COOKIE);
|
||||||
if (!token) return null;
|
if (!raw) return null;
|
||||||
|
const token = decodeURIComponent(raw);
|
||||||
|
|
||||||
const result = await safe(getMe(token), null);
|
const result = await safe(getMe(token), null);
|
||||||
return result?.user ?? null;
|
return result?.user ?? null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Pure AuthUser helpers with no server-only dependency (unlike auth.ts,
|
// Pure AuthUser helpers with no server-only dependency (unlike auth.ts,
|
||||||
// which reads next/headers) — safe to import from client components.
|
// which reads next/headers) — safe to import from client components.
|
||||||
|
import { AUTH_TOKEN_COOKIE } from "./config";
|
||||||
import type { AuthUser } from "./types";
|
import type { AuthUser } from "./types";
|
||||||
|
|
||||||
// "علی عزیز" — first name (or the fullest name field available) + عزیز.
|
// "علی عزیز" — first name (or the fullest name field available) + عزیز.
|
||||||
@@ -14,3 +15,44 @@ export function displayName(user: AuthUser): string {
|
|||||||
export function isAdmin(user: AuthUser): boolean {
|
export function isAdmin(user: AuthUser): boolean {
|
||||||
return Boolean(user.isAdmin) || user.role === "admin" || user.role === "owner";
|
return Boolean(user.isAdmin) || user.role === "admin" || user.role === "owner";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per AI_PROMPT.md "Shared login": the access-token cookie "may be chunked
|
||||||
|
// as name_n + name_0…" when the JWT is too long for one cookie — name_n
|
||||||
|
// holds the chunk count, name_0.._n-1 the pieces, joined in order. Shared by
|
||||||
|
// the server (auth.ts, reading next/headers) and the client (CartButton's
|
||||||
|
// Continue, reading document.cookie) — both just need to hand this a
|
||||||
|
// `(name) => value | undefined` reader for whichever cookie source they have.
|
||||||
|
export function reassembleChunkedCookie(
|
||||||
|
getCookie: (name: string) => string | undefined,
|
||||||
|
baseName: string
|
||||||
|
): string | undefined {
|
||||||
|
const direct = getCookie(baseName);
|
||||||
|
if (direct) return direct;
|
||||||
|
|
||||||
|
const count = Number(getCookie(`${baseName}_n`));
|
||||||
|
if (!Number.isInteger(count) || count <= 0) return undefined;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const part = getCookie(`${baseName}_${i}`);
|
||||||
|
if (!part) return undefined; // a chunk is missing — treat as absent rather than truncated
|
||||||
|
parts.push(part);
|
||||||
|
}
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCookieFromDocument(name: string): string | undefined {
|
||||||
|
if (typeof document === "undefined") return undefined;
|
||||||
|
return document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find((c) => c.startsWith(`${name}=`))
|
||||||
|
?.slice(name.length + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-side "am I logged in" check — just the cookie's presence, per
|
||||||
|
// AI_PROMPT.md ("if the access-token cookie exists, treat them as logged
|
||||||
|
// in"). Used by CartButton to decide the Continue URL without needing the
|
||||||
|
// full session fetch getSession() does server-side.
|
||||||
|
export function hasCustomerSessionCookie(): boolean {
|
||||||
|
return Boolean(reassembleChunkedCookie(readCookieFromDocument, AUTH_TOKEN_COOKIE));
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import { discountPercent } from "./format";
|
import { discountPercent, isInStock } 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, StoreItem, StoreSpecial } from "./types";
|
import type { ApiProduct, ProductCardData, StoreItem, StoreSpecial } from "./types";
|
||||||
@@ -65,7 +65,7 @@ export function specialToCards(special: StoreSpecial): ProductCardData[] {
|
|||||||
price,
|
price,
|
||||||
oldPrice,
|
oldPrice,
|
||||||
percent: price !== null && oldPrice !== null ? discountPercent(price, oldPrice) : 0,
|
percent: price !== null && oldPrice !== null ? discountPercent(price, oldPrice) : 0,
|
||||||
inStock: (variant?.stockQuantity ?? 0) > 0,
|
inStock: isInStock(variant?.stockQuantity),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-8
@@ -7,14 +7,19 @@ export const CUSTOMER_PORTAL_URL = `https://customer.${SITE_DOMAIN}`;
|
|||||||
// "مدیریت وبسایت" account-menu item shown to admins.
|
// "مدیریت وبسایت" account-menu item shown to admins.
|
||||||
export const BUSINESS_PORTAL_URL = `https://business.${SITE_DOMAIN}`;
|
export const BUSINESS_PORTAL_URL = `https://business.${SITE_DOMAIN}`;
|
||||||
|
|
||||||
// Login happens on the customer portal (a separate app), not on this site.
|
// Written by the customer dashboard on login, on the shared parent domain
|
||||||
// For the account menu to know a visitor is signed in, that portal needs to
|
// (".{SITE_DOMAIN}", not HttpOnly) — this storefront only ever reads it, per
|
||||||
// set this cookie on the shared parent domain (".{SITE_DOMAIN}", not
|
// the official AI_PROMPT.md "Shared login" contract. May be chunked into
|
||||||
// HttpOnly) after login, so requests to this site carry it too. Until it
|
// `${AUTH_TOKEN_COOKIE}_0`, `_1`, … with `${AUTH_TOKEN_COOKIE}_n` holding the
|
||||||
// does, getSession() in src/lib/auth.ts always resolves to null and the
|
// chunk count, when the JWT is too long for one cookie — see readAuthToken()
|
||||||
// header simply shows the anonymous "ورود | ثبتنام" link — nothing breaks,
|
// in src/lib/auth.ts / src/lib/authUser.ts, never read this raw.
|
||||||
// the logged-in state just never activates.
|
export const AUTH_TOKEN_COOKIE = "meshkee_customer_access_token";
|
||||||
export const AUTH_TOKEN_COOKIE = "meshkee_token";
|
|
||||||
|
// The mini-cart is local-only per AI_PROMPT.md "Shopping cart" — never a
|
||||||
|
// server cart. Same storage key is used for both localStorage and the
|
||||||
|
// mirrored cookie, and must match the customer dashboard's own key exactly
|
||||||
|
// so it can read a guest cart handed off via the Continue button.
|
||||||
|
export const GUEST_CART_STORAGE_KEY = "meshkee-guest-cart";
|
||||||
|
|
||||||
// Pulled from the live moalem.shop theme (--wb-primary-color / --wb-secondary-color).
|
// Pulled from the live moalem.shop theme (--wb-primary-color / --wb-secondary-color).
|
||||||
export const BRAND = {
|
export const BRAND = {
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export function discountPercent(price: number, oldPrice: number): number {
|
|||||||
return Math.round(((oldPrice - price) / oldPrice) * 100);
|
return Math.round(((oldPrice - price) / oldPrice) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per AI_PROMPT.md "Shopping cart" step 3: a variant's stockQuantity of
|
||||||
|
// `null` means unlimited stock (in stock), not zero/out of stock — never
|
||||||
|
// treat it as falsy.
|
||||||
|
export function isInStock(stockQuantity: number | null | undefined): boolean {
|
||||||
|
return stockQuantity === null || (stockQuantity ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// The static-image-slot API documents aspectRatio as "16:9", but CSS's
|
// The static-image-slot API documents aspectRatio as "16:9", but CSS's
|
||||||
// aspect-ratio property needs a slash ("16/9"), not a colon.
|
// aspect-ratio property needs a slash ("16/9"), not a colon.
|
||||||
export function toCssAspectRatio(value: string | number | null | undefined): string | undefined {
|
export function toCssAspectRatio(value: string | number | null | undefined): string | undefined {
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// The mini-cart per AI_PROMPT.md "Shopping cart": local-only, never the
|
||||||
|
// server cart (`/businesses/{id}/cart*` is off-limits from this site). Must
|
||||||
|
// match the customer dashboard's own guest-cart contract exactly — storage
|
||||||
|
// key, cookie attributes, line shape, and the encoding function below are
|
||||||
|
// all copied verbatim from that spec, not invented.
|
||||||
|
import { GUEST_CART_STORAGE_KEY, SITE_DOMAIN } from "./config";
|
||||||
|
|
||||||
|
export interface GuestCartItem {
|
||||||
|
id: string; // storeItemVariantId — never a product id or store-item id
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
price: number;
|
||||||
|
originalPrice?: number;
|
||||||
|
image: string | null;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verbatim from AI_PROMPT.md — base64url of the UTF-8 JSON, no padding.
|
||||||
|
export function encodeGuestCart(items: GuestCartItem[]): string {
|
||||||
|
const json = JSON.stringify(items);
|
||||||
|
return btoa(unescape(encodeURIComponent(json)))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie mirror is capped ~3500 chars (skip it and rely on the URL param
|
||||||
|
// instead) — the spec's own number, not a guess.
|
||||||
|
const COOKIE_SIZE_LIMIT = 3500;
|
||||||
|
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // ~30 days
|
||||||
|
|
||||||
|
function readLocalStorage(): GuestCartItem[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(GUEST_CART_STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStorage(items: GuestCartItem[]): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const json = JSON.stringify(items);
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(GUEST_CART_STORAGE_KEY, json);
|
||||||
|
} catch {
|
||||||
|
// localStorage can throw (private mode, quota) — the cookie mirror below
|
||||||
|
// still gets a chance to persist the cart.
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoded = encodeURIComponent(json);
|
||||||
|
if (encoded.length > COOKIE_SIZE_LIMIT) return; // rely on the URL param on Continue instead
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.cookie = `${GUEST_CART_STORAGE_KEY}=${encoded}; Domain=.${SITE_DOMAIN}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
|
||||||
|
} catch {
|
||||||
|
// Cookie writes can fail under strict privacy settings — localStorage is
|
||||||
|
// the primary store, this is just the cross-subdomain mirror.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readGuestCart(): GuestCartItem[] {
|
||||||
|
return readLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// `id` is the storeItemVariantId — the one field the dashboard resolves to
|
||||||
|
// stock/price server-side. If it's already in the cart, increment quantity
|
||||||
|
// instead of adding a second line (per AI_PROMPT.md step 6).
|
||||||
|
export function addGuestCartItem(item: GuestCartItem): GuestCartItem[] {
|
||||||
|
const items = readGuestCart();
|
||||||
|
const existing = items.find((i) => i.id === item.id);
|
||||||
|
const next = existing
|
||||||
|
? items.map((i) => (i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i))
|
||||||
|
: [...items, item];
|
||||||
|
writeStorage(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGuestCartItem(id: string, quantity: number): GuestCartItem[] {
|
||||||
|
const items = readGuestCart();
|
||||||
|
const next =
|
||||||
|
quantity <= 0 ? items.filter((i) => i.id !== id) : items.map((i) => (i.id === id ? { ...i, quantity } : i));
|
||||||
|
writeStorage(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeGuestCartItem(id: string): GuestCartItem[] {
|
||||||
|
const next = readGuestCart().filter((i) => i.id !== id);
|
||||||
|
writeStorage(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
+15
-28
@@ -175,11 +175,21 @@ export interface StoreSpecialVariant {
|
|||||||
selections: StoreItemSelection[];
|
selections: StoreItemSelection[];
|
||||||
price: number | null;
|
price: number | null;
|
||||||
discountedPrice: number | null;
|
discountedPrice: number | null;
|
||||||
stockQuantity: number;
|
stockQuantity: number | null;
|
||||||
isFestival: boolean;
|
isFestival: boolean;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /store-items/by-product/{productId} → { storeItem: StoreItemVariants |
|
||||||
|
// null } — the documented source for a product's purchasable variants (see
|
||||||
|
// AI_PROMPT.md "Shopping cart"). `stockQuantity: null` means unlimited
|
||||||
|
// stock, not out of stock — check `=== 0`, not falsiness.
|
||||||
|
export interface StoreItemVariants {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
variants: StoreSpecialVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoreSpecialItem {
|
export interface StoreSpecialItem {
|
||||||
id: string;
|
id: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
@@ -311,30 +321,7 @@ export interface AuthUser {
|
|||||||
role?: string;
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The cart endpoints (GET/POST/PATCH/DELETE .../cart) document their
|
// The mini-cart is local-only (see AGENTS.md "Shopping cart" and
|
||||||
// responses only as "{ cart }" / "{ message, cart }" — no field-level
|
// src/lib/guestCart.ts for GuestCartItem) — there is no server Cart/CartItem
|
||||||
// schema — and this tenant's cart has never been exercised (store-items are
|
// type here on purpose, calling /businesses/{id}/cart* from this site is
|
||||||
// empty, so nothing has ever been added). Every field below is a best-effort
|
// against the documented contract.
|
||||||
// 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