diff --git a/AGENTS.md b/AGENTS.md index b7db22a..31e55b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). | | `/products` | **479 items**, real titles, images, categories | | `/categories?entityType=product` | **48 items**, 3 levels under «کالای دیجیتال». Products are filed on **leaf** categories only — a parent category page/carousel must query its whole subtree (`subtreeIds`/`subtreeIdsAll` in `src/lib/categoryTree.ts`). | -| `/store-items`, `/store-specials` | **empty** → no prices, no discounts, everything `inStock:false`, 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. | +| `/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*`) | **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/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) | @@ -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 menu to show "نام عزیز" instead of "ورود | ثبت‌نام", the customer portal needs to set a **non-HttpOnly cookie on the shared parent domain** (`.{domain}`) -named `AUTH_TOKEN_COOKIE` (`src/lib/config.ts`, currently `"meshkee_token"`) -holding the bearer access token. Until that exists, `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. +named `AUTH_TOKEN_COOKIE` (`src/lib/config.ts`, `"meshkee_customer_access_token"` +— the documented name from AI_PROMPT.md's "Shared login" contract, not a +guess) holding the bearer access token, URL-encoded and possibly **chunked** +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 - the cookie and calls `GET /auth/me`. -- `src/lib/authUser.ts` — pure helpers (`displayName`, `isAdmin`), safe to - import from client components. **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. + the (possibly chunked) cookie via `reassembleChunkedCookie()`, URL-decodes + it, and calls `GET /auth/me`. +- `src/lib/authUser.ts` — pure helpers safe to import from client components: + `displayName`, `isAdmin`, `reassembleChunkedCookie`, and + `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 — 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. ## 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. +**Guest cart only — this storefront never calls the server cart API.** +Per AI_PROMPT.md's "Shopping cart" section, `/businesses/{id}/cart*` (and +`/cart/checkout`) are customer-dashboard-only; the site's whole cart is a +local, unauthenticated mini-cart that hands off to the dashboard on +"ادامه". 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`, - `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/lib/guestCart.ts` — the entire contract, copied verbatim from + AI_PROMPT.md, not invented: storage key **and** cookie name + `meshkee-guest-cart` (`GUEST_CART_STORAGE_KEY` in `config.ts`), persisted + to both `localStorage` and a `Domain=.{domain}; Path=/; SameSite=Lax; + 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 - badge, a popup previewing cart contents. Its "ادامه" (continue) button - hands off to `${CUSTOMER_PORTAL_URL}/cart` rather than a page on this - site — **this storefront has no checkout UI** (address entry, payment - gateway selection); that whole flow lives on the customer portal per this - project's established account/order architecture. Don't build a checkout - page here without an explicit ask — it's a large, separate feature. - `placement="up"` (bottom nav) renders the popup `fixed` against the - viewport rather than `absolute` against the button's own narrow `flex-1` - column — an `absolute` inset there would squeeze the whole preview into - ~1/4 of the screen width instead of spanning near it. -- `ProductBuyPanel.tsx`'s "افزودن به سبد خرید" needs a real - `storeItemVariantId`, which only exists once a product has store-items — - today that's none of them, so the button always shows "این محصول هنوز برای - خرید آنلاین فعال نشده است" regardless of login state, and the quantity - stepper never renders. That's correct given the data, not a bug — this - whole path (add → badge updates → popup shows the item) is unverified - against a real cart for the same reason. + badge, a popup with per-line qty/remove/total. "ادامه" builds the Continue + URL exactly per spec: `encodeGuestCart(items)` in the `guestCart` query + param, to `${CUSTOMER_PORTAL_URL}/checkout/cart?guestCart=` when + `hasCustomerSessionCookie()` is true, else to + `${CUSTOMER_PORTAL_URL}/login?redirect=`. This + storefront has no checkout/address/payment UI of its own — that whole flow + is the customer dashboard's, by design; don't build one here without an + explicit ask. `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 "افزودن به سبد خرید" — variants come from + `getStoreItemByProduct()` (`GET .../store-items/by-product/{productId}`). + In-stock means `stockQuantity === null` (**unlimited**, not + out-of-stock — see `isInStock()` in `src/lib/format.ts`) or `> 0`; with + more than one in-stock variant the shopper must pick one before the button + 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 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ae24cf1..aae9216 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,7 +6,7 @@ import Header from "@/components/Header"; import Footer from "@/components/Footer"; import TopBar from "@/components/TopBar"; 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 { getSession } from "@/lib/auth"; import { SITE_NAME } from "@/data/site"; @@ -60,21 +60,19 @@ export async function generateMetadata(): Promise { } 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), []), getSession(), safe(getFavicon(), NO_BRAND_ASSETS), - safe(getTenant(), null), ]); const menu = carouselCategories(categories); const logoUrl = brand.logoUrl || "/images/logo.png"; - const businessId = tenant ? String(tenant.id) : ""; return ( - +
{children}
diff --git a/src/app/products/[id]/[slug]/page.tsx b/src/app/products/[id]/[slug]/page.tsx index 4fdd39b..663f853 100644 --- a/src/app/products/[id]/[slug]/page.tsx +++ b/src/app/products/[id]/[slug]/page.tsx @@ -10,7 +10,7 @@ import { getComments, getProductById, getProductTechnicalInfo, - getStoreItemsByProduct, + getStoreItemByProduct, safe, } from "@/lib/api"; import { getSession } from "@/lib/auth"; @@ -50,12 +50,13 @@ export default async function ProductDetailPage({ params }: { params: Promise<{ 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: [] }), + const [{ storeItem }, technicalInfo, { items: comments }, user] = await Promise.all([ + safe(getStoreItemByProduct(product.id), { storeItem: null }), safe(getProductTechnicalInfo(product.id), { form: null, values: [] }), safe(getComments("product", product.id), { items: [] }), getSession(), ]); + const variants = storeItem?.variants ?? []; const images = [ ...(product.thumbnailUrl ? [product.thumbnailUrl] : []), @@ -78,7 +79,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
- +
- + (null); @@ -43,7 +42,14 @@ export default function CartButton({ 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 (
@@ -72,74 +78,73 @@ export default function CartButton({ // 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" + placement === "up" ? "fixed inset-x-4 bottom-[76px]" : "absolute left-0 top-full mt-2 w-80" }`} >

سبد خرید

- {!user ? ( -
-

برای مشاهده سبد خرید ابتدا وارد شوید.

- setOpen(false)} - className="rounded-lg bg-brand px-5 py-2 text-[13px] font-bold text-white transition hover:bg-brand-dark" - > - ورود | ثبت‌نام - -
- ) : loading ? ( -

در حال بارگذاری…

- ) : items.length === 0 ? ( + {items.length === 0 ? (

سبد خرید شما خالی است.

) : ( <>
    - {items.map((item) => { - const title = item.productNameFa || item.productTitle || "کالا"; - const price = item.discountedPrice ?? item.price ?? null; - return ( -
  • - - {item.image && ( - {title} - )} - - - {title} - - {toFaDigits(item.quantity)} عدد - - - {price !== null && ( - {formatPrice(price)} + {items.map((item) => ( +
  • + + {item.image && ( + {item.name} )} -
  • - ); - })} + + + {item.name} + + + {toFaDigits(item.quantity)} + + + + + {formatPrice(item.price)} + + + + ))}
- {cart?.total != null && ( -
- جمع کل - - {formatPrice(cart.total)} - تومان - -
- )} - setOpen(false)} +
+ جمع کل + + {formatPrice(total)} + تومان + +
+
)} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 4942e96..5b43229 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -51,10 +51,7 @@ export default function Header({
- +
diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx index d6cffa5..100bce7 100644 --- a/src/components/MobileBottomNav.tsx +++ b/src/components/MobileBottomNav.tsx @@ -132,7 +132,6 @@ export default function MobileBottomNav({ 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({ product, - items = [], - user, + variants = [], initialVariantId, }: { product: ApiProduct; - items?: StoreItem[]; - user: AuthUser | null; + variants?: StoreSpecialVariant[]; /** 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(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 [adding, setAdding] = useState(false); const [added, setAdded] = useState(false); - const [error, setError] = useState(null); const { addItem } = useCart(); 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 image = product.thumbnailUrl || product.image || product.thumbnail; const variationName = selected?.selections[0]?.variationName; let price: number | null = null; @@ -55,31 +58,31 @@ export default function ProductBuyPanel({ oldPrice = demo.oldPrice; } - const percent = price !== null && oldPrice !== null && oldPrice > price - ? Math.round(((oldPrice - price) / oldPrice) * 100) - : 0; + const percent = + price !== null && oldPrice !== null && oldPrice > price + ? Math.round(((oldPrice - price) / oldPrice) * 100) + : 0; - // Purchasing needs a real store-item variant (cart items are added by - // storeItemVariantId, not productId) — until this product has one, there - // is nothing a bearer token could add to a cart, logged in or not. - const purchasable = items.length > 0 && Boolean(selected); - const inStock = selected ? selected.stockQuantity > 0 : product.store.inStock; - const maxQuantity = selected ? Math.max(1, selected.stockQuantity) : 99; + // A shopper with more than one in-stock variant must choose before adding + // — the variant picker below doubles as that requirement, so "ready to + // add" just means a selection exists. + const purchasable = inStockVariants.length > 0 && Boolean(selected); + const maxQuantity = selected?.stockQuantity == null ? 99 : Math.max(1, selected.stockQuantity); - async function handleAddToCart() { - if (!selected || !user) return; - setAdding(true); - setError(null); - try { - await addItem(selected.storeItemId, quantity); - setAdded(true); - showToast(`${quantity > 1 ? `${toFaDigits(quantity)} عدد ` : ""}به سبد خرید اضافه شد`); - setTimeout(() => setAdded(false), 1800); - } catch { - setError("افزودن به سبد خرید ناموفق بود. لطفاً دوباره تلاش کنید."); - } finally { - setAdding(false); - } + function handleAddToCart() { + if (!selected) return; + addItem({ + id: selected.id, + name: title, + slug: product.slug, + price: selected.discountedPrice ?? selected.price ?? 0, + originalPrice: selected.discountedPrice != null ? (selected.price ?? undefined) : undefined, + image, + quantity, + }); + setAdded(true); + showToast(`${quantity > 1 ? `${toFaDigits(quantity)} عدد ` : ""}به سبد خرید اضافه شد`); + setTimeout(() => setAdded(false), 1800); } return ( @@ -95,28 +98,27 @@ export default function ProductBuyPanel({ )} - {items.length > 0 && ( + {inStockVariants.length > 0 && (

{variationName || "انتخاب گزینه"}

- {items.map((item) => { - const rawValue = item.selections[0]?.value; + {inStockVariants.map((variant) => { + const rawValue = variant.selections[0]?.value; const colorHex = rawValue?.startsWith("#") ? rawValue : null; return ( ); @@ -149,10 +151,10 @@ export default function ProductBuyPanel({
- {inStock ? "آماده ارسال" : "ناموجود"} + {purchasable ? "آماده ارسال" : "ناموجود"}
@@ -186,13 +188,6 @@ export default function ProductBuyPanel({

این محصول هنوز برای خرید آنلاین فعال نشده است.

- ) : !user ? ( - - برای خرید وارد شوید - ) : (
@@ -220,8 +215,7 @@ export default function ProductBuyPanel({
)} - {error &&

{error}

}
diff --git a/src/components/providers/CartProvider.tsx b/src/components/providers/CartProvider.tsx index c5109ae..6913726 100644 --- a/src/components/providers/CartProvider.tsx +++ b/src/components/providers/CartProvider.tsx @@ -1,112 +1,57 @@ "use client"; +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; 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 - ); -} + addGuestCartItem, + readGuestCart, + removeGuestCartItem, + updateGuestCartItem, + type GuestCartItem, +} from "@/lib/guestCart"; interface CartContextValue { - cart: Cart | null; + items: GuestCartItem[]; count: number; - loading: boolean; - refresh: () => Promise; - addItem: (storeItemVariantId: string, quantity?: number) => Promise; - updateItem: (itemId: string, quantity: number) => Promise; - removeItem: (itemId: string) => Promise; + total: number; + addItem: (item: GuestCartItem) => void; + updateItem: (id: string, quantity: number) => void; + removeItem: (id: string) => void; } const CartContext = createContext(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(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]); +// The mini-cart is local-only per AI_PROMPT.md "Shopping cart" — no server +// call, no auth required. State lives in localStorage (mirrored to a cookie +// for the customer dashboard to read); this provider is just a thin React +// wrapper around src/lib/guestCart.ts so every consumer re-renders on change. +export function CartProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState([]); + // 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(() => { - // 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]); + queueMicrotask(() => setItems(readGuestCart())); + }, []); - 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 addItem = useCallback((item: GuestCartItem) => { + setItems(addGuestCartItem(item)); + }, []); - 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 updateItem = useCallback((id: string, quantity: number) => { + setItems(updateGuestCartItem(id, quantity)); + }, []); - const removeItem = useCallback( - async (itemId: string) => { - const token = readToken(); - if (!token) return; - const { cart } = await removeCartItem(businessId, token, itemId); - setCart(cart); - }, - [businessId] - ); + const removeItem = useCallback((id: string) => { + setItems(removeGuestCartItem(id)); + }, []); - 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 ( - + {children} ); diff --git a/src/lib/api.ts b/src/lib/api.ts index aa5e0c6..422f650 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -6,7 +6,6 @@ import type { AuthUser, BrandAssets, BusinessInfo, - Cart, ContactFormInput, EntityComment, PagedResult, @@ -14,6 +13,7 @@ import type { SliderItem, StaticImageSlot, StoreItem, + StoreItemVariants, StoreSpecial, Tenant, TechnicalInfoForm, @@ -68,30 +68,6 @@ async function apiGetAuthed(path: string, token: string): Promise { return res.json() as Promise; } -// 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( - method: "GET" | "POST" | "PATCH" | "DELETE", - path: string, - token: string, - body?: unknown -): Promise { - 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; -} - // Swallows errors so one empty/broken section degrades instead of taking // the whole page down — several modules on this tenant are still unfilled. export async function safe(promise: Promise, fallback: T): Promise { @@ -192,12 +168,15 @@ export function getProductTechnicalInfo( return apiGet(t(`/products/by-id/${productId}/technical-info`)); } -// Not /store-items/by-product/{id} — that one returns a single { storeItem } -// (or null), not the full variant list a buy panel needs. The paginated -// /store-items list, filtered by productId, is the one that returns every -// purchasable variant. -export function getStoreItemsByProduct(productId: string): Promise<{ items: StoreItem[] }> { - return apiGet(t("/store-items"), { productId, pageSize: 100 }); +// The documented source for a product's purchasable variants (AI_PROMPT.md +// "Shopping cart", step 1) — { storeItem: null } when the product has no +// store-item at all. storeItem.variants is what the buy panel and the +// add-to-cart flow both read; never call /store-items?productId= for this, +// that's a different, unrelated listing (see getStoreItems below). +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 @@ -253,45 +232,10 @@ export function submitContactForm( 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); -} +// No cart functions here on purpose — AI_PROMPT.md "Hard rules" #3 and +// "Shopping cart" are explicit that `/businesses/{id}/cart*` is +// customer-dashboard-only. The mini-cart is local-only; see +// src/lib/guestCart.ts. export function findSlot(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined { const slot = slots.find((s) => s.key === key); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index c8dba89..3a291b4 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,18 +1,21 @@ import { cookies } from "next/headers"; import { getMe, safe } from "./api"; import { AUTH_TOKEN_COOKIE } from "./config"; +import { reassembleChunkedCookie } from "./authUser"; import type { AuthUser } from "./types"; export { displayName, isAdmin } from "./authUser"; -// Server-side "am I logged in" check for the header/account menu. See the -// AUTH_TOKEN_COOKIE comment in config.ts for the one external dependency -// this relies on. Resolves to null whenever the cookie is absent or /auth/me -// fails — never throws, so a missing session degrades to the anonymous view. +// Server-side "am I logged in" check for the header/account menu. Reads the +// cookie the customer dashboard writes on login (see AUTH_TOKEN_COOKIE in +// config.ts and AI_PROMPT.md "Shared login") — this storefront never issues +// 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 { const store = await cookies(); - const token = store.get(AUTH_TOKEN_COOKIE)?.value; - if (!token) return null; + const raw = reassembleChunkedCookie((name) => store.get(name)?.value, AUTH_TOKEN_COOKIE); + if (!raw) return null; + const token = decodeURIComponent(raw); const result = await safe(getMe(token), null); return result?.user ?? null; diff --git a/src/lib/authUser.ts b/src/lib/authUser.ts index 73f0008..530fa80 100644 --- a/src/lib/authUser.ts +++ b/src/lib/authUser.ts @@ -1,5 +1,6 @@ // Pure AuthUser helpers with no server-only dependency (unlike auth.ts, // which reads next/headers) — safe to import from client components. +import { AUTH_TOKEN_COOKIE } from "./config"; import type { AuthUser } from "./types"; // "علی عزیز" — first name (or the fullest name field available) + عزیز. @@ -14,3 +15,44 @@ export function displayName(user: AuthUser): string { export function isAdmin(user: AuthUser): boolean { 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)); +} diff --git a/src/lib/cards.ts b/src/lib/cards.ts index 66080c7..bc745f0 100644 --- a/src/lib/cards.ts +++ b/src/lib/cards.ts @@ -1,4 +1,4 @@ -import { discountPercent } from "./format"; +import { discountPercent, isInStock } from "./format"; import { productHref } from "./slug"; import { DEMO_PRICING, demoPrice } from "./demoPricing"; import type { ApiProduct, ProductCardData, StoreItem, StoreSpecial } from "./types"; @@ -65,7 +65,7 @@ export function specialToCards(special: StoreSpecial): ProductCardData[] { price, oldPrice, percent: price !== null && oldPrice !== null ? discountPercent(price, oldPrice) : 0, - inStock: (variant?.stockQuantity ?? 0) > 0, + inStock: isInStock(variant?.stockQuantity), }; }); } diff --git a/src/lib/config.ts b/src/lib/config.ts index 66cda90..8e2887b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -7,14 +7,19 @@ export const CUSTOMER_PORTAL_URL = `https://customer.${SITE_DOMAIN}`; // "مدیریت وبسایت" account-menu item shown to admins. export const BUSINESS_PORTAL_URL = `https://business.${SITE_DOMAIN}`; -// Login happens on the customer portal (a separate app), not on this site. -// For the account menu to know a visitor is signed in, that portal needs to -// set this cookie on the shared parent domain (".{SITE_DOMAIN}", not -// HttpOnly) after login, so requests to this site carry it too. Until it -// does, getSession() in src/lib/auth.ts always resolves to null and the -// header simply shows the anonymous "ورود | ثبت‌نام" link — nothing breaks, -// the logged-in state just never activates. -export const AUTH_TOKEN_COOKIE = "meshkee_token"; +// Written by the customer dashboard on login, on the shared parent domain +// (".{SITE_DOMAIN}", not HttpOnly) — this storefront only ever reads it, per +// the official AI_PROMPT.md "Shared login" contract. May be chunked into +// `${AUTH_TOKEN_COOKIE}_0`, `_1`, … with `${AUTH_TOKEN_COOKIE}_n` holding the +// chunk count, when the JWT is too long for one cookie — see readAuthToken() +// in src/lib/auth.ts / src/lib/authUser.ts, never read this raw. +export const AUTH_TOKEN_COOKIE = "meshkee_customer_access_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). export const BRAND = { diff --git a/src/lib/format.ts b/src/lib/format.ts index 8343c22..dd0b800 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -15,6 +15,13 @@ export function discountPercent(price: number, oldPrice: number): number { 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 // aspect-ratio property needs a slash ("16/9"), not a colon. export function toCssAspectRatio(value: string | number | null | undefined): string | undefined { diff --git a/src/lib/guestCart.ts b/src/lib/guestCart.ts new file mode 100644 index 0000000..cfd8155 --- /dev/null +++ b/src/lib/guestCart.ts @@ -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; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 7b105e5..3fec24c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -175,11 +175,21 @@ export interface StoreSpecialVariant { selections: StoreItemSelection[]; price: number | null; discountedPrice: number | null; - stockQuantity: number; + stockQuantity: number | null; isFestival: boolean; 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 { id: string; productId: string; @@ -311,30 +321,7 @@ export interface AuthUser { 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; -} +// The mini-cart is local-only (see AGENTS.md "Shopping cart" and +// src/lib/guestCart.ts for GuestCartItem) — there is no server Cart/CartItem +// type here on purpose, calling /businesses/{id}/cart* from this site is +// against the documented contract.