Add guest mini-cart with header icon, popup, and customer dashboard handoff
Wires a localStorage/cookie-backed guest cart (per AI_PROMPT.md contract) into the header cart icon and popup, with add-to-cart in the product card and buy panel resolved via the dedicated by-product store-items endpoint. Continue always hands off to the Meshkee customer dashboard for auth/checkout — no login, register, or payment UI is built on the storefront. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d7b490e442
commit
12d39bc020
@@ -4,7 +4,7 @@ import {
|
||||
getComments,
|
||||
getProductById,
|
||||
getProductTechnicalInfo,
|
||||
getStoreItemsByProduct,
|
||||
getStoreItemByProduct,
|
||||
safe,
|
||||
} from "@/lib/api";
|
||||
import { categoryHref } from "@/lib/slug";
|
||||
@@ -30,11 +30,12 @@ export default async function ProductDetailPage({
|
||||
|
||||
const { product, relatedProducts } = data;
|
||||
|
||||
const [{ items: storeItems }, technicalInfo, { items: comments }] = await Promise.all([
|
||||
safe(getStoreItemsByProduct(product.id), { items: [] }),
|
||||
const [{ storeItem }, technicalInfo, { items: comments }] = await Promise.all([
|
||||
safe(getStoreItemByProduct(product.id), { storeItem: null }),
|
||||
safe(getProductTechnicalInfo(id), { form: null, values: [] }),
|
||||
safe(getComments("product", product.id), { items: [] }),
|
||||
]);
|
||||
const storeItems = storeItem?.variants ?? [];
|
||||
|
||||
const images = [
|
||||
...(product.thumbnailUrl ? [product.thumbnailUrl] : []),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { GUEST_CART_STORAGE_KEY, writeGuestCartCookie } from "@/lib/cart";
|
||||
import type { CartLine } from "@/lib/types";
|
||||
|
||||
// Guest cart as a module-level external store (one cart per browser,
|
||||
// backed by localStorage) rather than React Context — sidesteps the
|
||||
// SSR/hydration mismatch that a `useState` + hydration-effect pattern would
|
||||
// hit, per React's useSyncExternalStore docs.
|
||||
type Listener = () => void;
|
||||
|
||||
let cart: CartLine[] = [];
|
||||
let initialized = false;
|
||||
const listeners = new Set<Listener>();
|
||||
// Stable reference required by useSyncExternalStore — a fresh [] literal
|
||||
// on every call would fail its identity check and loop forever.
|
||||
const EMPTY_CART: CartLine[] = [];
|
||||
|
||||
function loadFromStorage(): CartLine[] {
|
||||
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 ensureInitialized() {
|
||||
if (initialized || typeof window === "undefined") return;
|
||||
cart = loadFromStorage();
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
function commit(next: CartLine[]) {
|
||||
cart = next;
|
||||
window.localStorage.setItem(GUEST_CART_STORAGE_KEY, JSON.stringify(cart));
|
||||
writeGuestCartCookie(cart);
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function subscribe(listener: Listener) {
|
||||
ensureInitialized();
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): CartLine[] {
|
||||
ensureInitialized();
|
||||
return cart;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): CartLine[] {
|
||||
return EMPTY_CART;
|
||||
}
|
||||
|
||||
function addItem(item: CartLine) {
|
||||
ensureInitialized();
|
||||
const existing = cart.find((line) => line.id === item.id);
|
||||
if (existing) {
|
||||
commit(
|
||||
cart.map((line) =>
|
||||
line.id === item.id ? { ...line, quantity: line.quantity + item.quantity } : line
|
||||
)
|
||||
);
|
||||
} else {
|
||||
commit([...cart, item]);
|
||||
}
|
||||
}
|
||||
|
||||
function updateQuantity(id: string, quantity: number) {
|
||||
ensureInitialized();
|
||||
if (quantity <= 0) {
|
||||
commit(cart.filter((line) => line.id !== id));
|
||||
return;
|
||||
}
|
||||
commit(cart.map((line) => (line.id === id ? { ...line, quantity } : line)));
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
ensureInitialized();
|
||||
commit(cart.filter((line) => line.id !== id));
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
const items = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
const count = items.reduce((sum, line) => sum + line.quantity, 0);
|
||||
const total = items.reduce((sum, line) => sum + line.price * line.quantity, 0);
|
||||
|
||||
return { items, count, total, addItem, updateQuantity, removeItem };
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Logo from "./Logo";
|
||||
import SearchBox from "./SearchBox";
|
||||
import { BagIcon, ChevronDownIcon, CloseIcon, MenuIcon } from "./icons";
|
||||
import MiniCart from "./MiniCart";
|
||||
import { ChevronDownIcon, CloseIcon, MenuIcon } from "./icons";
|
||||
import { socialLinks } from "./socialLinks";
|
||||
import { headerNavLinks } from "@/data/site";
|
||||
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
|
||||
@@ -38,16 +39,7 @@ export default function Header({ categoryColumns }: { categoryColumns: CategoryN
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 md:gap-3 ms-auto md:ms-0">
|
||||
<a
|
||||
href="#"
|
||||
className="relative flex items-center justify-center size-10 md:size-11 rounded-lg border border-line text-ink hover:border-brand hover:text-brand transition-colors"
|
||||
aria-label="سبد خرید"
|
||||
>
|
||||
<BagIcon className="size-5" />
|
||||
<span className="absolute -top-1.5 -end-1.5 flex items-center justify-center size-4.5 rounded-full bg-gold text-[10px] font-bold text-ink">
|
||||
0
|
||||
</span>
|
||||
</a>
|
||||
<MiniCart />
|
||||
|
||||
<a
|
||||
href={CUSTOMER_PORTAL_URL}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCart } from "./CartProvider";
|
||||
import { getContinueUrl } from "@/lib/cart";
|
||||
import { formatToman } from "@/lib/format";
|
||||
import { BagIcon, CloseIcon, MinusIcon, PlusIcon, TrashIcon } from "./icons";
|
||||
|
||||
export default function MiniCart() {
|
||||
const { items, count, total, updateQuantity, removeItem } = useCart();
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onClickAway(e: MouseEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onClickAway);
|
||||
return () => document.removeEventListener("mousedown", onClickAway);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="relative flex items-center justify-center size-10 md:size-11 rounded-lg border border-line text-ink hover:border-brand hover:text-brand transition-colors"
|
||||
aria-label="سبد خرید"
|
||||
>
|
||||
<BagIcon className="size-5" />
|
||||
{count > 0 && (
|
||||
<span className="absolute -top-1.5 -end-1.5 flex items-center justify-center size-4.5 rounded-full bg-gold text-[10px] font-bold text-ink">
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute end-0 top-full mt-2 w-80 sm:w-96 bg-white border border-line rounded-xl shadow-xl z-50 flex flex-col max-h-[70vh]">
|
||||
<div className="flex items-center justify-between px-4 h-12 border-b border-line shrink-0">
|
||||
<h3 className="text-sm font-bold text-ink">سبد خرید</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="بستن"
|
||||
className="p-1 text-body hover:text-ink"
|
||||
>
|
||||
<CloseIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-body">سبد خرید شما خالی است</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-line">
|
||||
{items.map((line) => (
|
||||
<div key={line.id} className="flex gap-3 px-4 py-3">
|
||||
<img
|
||||
src={line.image}
|
||||
alt={line.name}
|
||||
className="size-16 shrink-0 rounded-lg object-cover border border-line"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<p className="text-[13px] text-ink text-right line-clamp-2">{line.name}</p>
|
||||
<div className="mt-auto flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 rounded-full border border-line px-1 h-7">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateQuantity(line.id, line.quantity + 1)}
|
||||
aria-label="افزایش تعداد"
|
||||
className="flex items-center justify-center size-5 text-body hover:text-brand"
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
</button>
|
||||
<span className="w-5 text-center text-xs font-bold text-ink">
|
||||
{line.quantity}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateQuantity(line.id, line.quantity - 1)}
|
||||
aria-label="کاهش تعداد"
|
||||
className="flex items-center justify-center size-5 text-body hover:text-brand"
|
||||
>
|
||||
<MinusIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-brand whitespace-nowrap">
|
||||
{formatToman(line.price * line.quantity)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(line.id)}
|
||||
aria-label="حذف از سبد خرید"
|
||||
className="self-start p-1 text-body hover:text-red-600"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="px-4 py-3 border-t border-line shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-body">جمع کل</span>
|
||||
<span className="text-sm font-bold text-ink">{formatToman(total)}</span>
|
||||
</div>
|
||||
<a
|
||||
href={getContinueUrl(items)}
|
||||
className="flex items-center justify-center w-full rounded-full bg-brand text-white text-sm font-bold h-11 hover:bg-brand-dark transition-colors"
|
||||
>
|
||||
ادامه فرآیند خرید
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ApiProduct, StoreItem } from "@/lib/types";
|
||||
import { productHref } from "@/lib/slug";
|
||||
import { useCart } from "./CartProvider";
|
||||
import { BagIcon, CheckIcon, PhoneIcon, RotateIcon, ShieldIcon, TruckIcon } from "./icons";
|
||||
|
||||
const trustPoints = [
|
||||
@@ -20,9 +22,12 @@ export default function ProductBuyPanel({
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState(items[0]?.id);
|
||||
const [added, setAdded] = useState(false);
|
||||
const { addItem } = useCart();
|
||||
const selected = items.find((i) => i.id === selectedId) ?? items[0];
|
||||
|
||||
const title = product.nameFa || product.title;
|
||||
const image =
|
||||
product.thumbnailUrl || product.image || product.thumbnail || "/images/products/product-1.svg";
|
||||
const variationName = selected?.selections[0]?.variationName;
|
||||
const inStock = selected ? selected.stockQuantity > 0 : product.store.inStock;
|
||||
const stockQuantity = selected
|
||||
@@ -45,6 +50,16 @@ export default function ProductBuyPanel({
|
||||
}
|
||||
|
||||
function handleAddToCart() {
|
||||
if (!selected || !inStock) return;
|
||||
addItem({
|
||||
id: selected.id,
|
||||
name: title,
|
||||
slug: productHref(product),
|
||||
price: finalPrice ?? 0,
|
||||
originalPrice: oldPrice ?? undefined,
|
||||
image,
|
||||
quantity: 1,
|
||||
});
|
||||
setAdded(true);
|
||||
setTimeout(() => setAdded(false), 1800);
|
||||
}
|
||||
|
||||
@@ -191,6 +191,22 @@ export function PlusIcon({ className }: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MinusIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} {...base}>
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrashIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} {...base}>
|
||||
<path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m-9 0 1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommentIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} {...base}>
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ApiProduct, ProductDiscount } from "@/lib/types";
|
||||
import { formatToman } from "@/lib/format";
|
||||
import { productHref } from "@/lib/slug";
|
||||
import { getStoreItemByProduct } from "@/lib/api";
|
||||
import { useCart } from "../CartProvider";
|
||||
import { CheckIcon, PlusIcon } from "../icons";
|
||||
|
||||
export default function ProductCard({
|
||||
@@ -15,17 +18,53 @@ export default function ProductCard({
|
||||
discount?: ProductDiscount;
|
||||
}) {
|
||||
const [added, setAdded] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { addItem } = useCart();
|
||||
const title = product.nameFa || product.title;
|
||||
const image =
|
||||
product.thumbnailUrl || product.image || product.thumbnail || "/images/products/product-1.svg";
|
||||
const store = product.store;
|
||||
const price = discount ? discount.price : store?.minPrice;
|
||||
|
||||
function handleQuickAdd(e: React.MouseEvent) {
|
||||
async function handleQuickAdd(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setAdded(true);
|
||||
setTimeout(() => setAdded(false), 1500);
|
||||
if (loading || added) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { storeItem } = await getStoreItemByProduct(product.id);
|
||||
const variants = storeItem?.variants ?? [];
|
||||
const inStock = variants.filter((v) => v.stockQuantity == null || v.stockQuantity > 0);
|
||||
|
||||
if (inStock.length === 0) return;
|
||||
|
||||
// More than one purchasable variant (size/color) — the buyer must
|
||||
// choose, so send them to the product page instead of guessing.
|
||||
if (inStock.length > 1) {
|
||||
router.push(productHref(product));
|
||||
return;
|
||||
}
|
||||
|
||||
const variant = inStock[0];
|
||||
const finalPrice = variant.discountedPrice ?? variant.price ?? 0;
|
||||
const hasRealDiscount =
|
||||
variant.discountedPrice != null && variant.price != null && variant.discountedPrice < variant.price;
|
||||
|
||||
addItem({
|
||||
id: variant.id,
|
||||
name: title,
|
||||
slug: productHref(product),
|
||||
price: finalPrice,
|
||||
originalPrice: hasRealDiscount ? variant.price! : undefined,
|
||||
image,
|
||||
quantity: 1,
|
||||
});
|
||||
setAdded(true);
|
||||
setTimeout(() => setAdded(false), 1500);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -76,8 +115,9 @@ export default function ProductCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickAdd}
|
||||
disabled={loading}
|
||||
aria-label="افزودن سریع به سبد خرید"
|
||||
className={`shrink-0 flex items-center justify-center size-8 rounded-full transition-colors ${
|
||||
className={`shrink-0 flex items-center justify-center size-8 rounded-full transition-colors disabled:opacity-60 ${
|
||||
added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark"
|
||||
}`}
|
||||
>
|
||||
|
||||
+7
-3
@@ -9,7 +9,7 @@ import type {
|
||||
PagedResult,
|
||||
ProductVariation,
|
||||
StaticImageSlot,
|
||||
StoreItem,
|
||||
StoreItemGroup,
|
||||
StoreSpecial,
|
||||
TechnicalInfoValue,
|
||||
} from "./types";
|
||||
@@ -91,8 +91,12 @@ export function getProductVariations(
|
||||
return apiGet(`/tenants/${SITE_DOMAIN}/products/by-id/${productId}/variations`);
|
||||
}
|
||||
|
||||
export function getStoreItemsByProduct(productId: string): Promise<{ items: StoreItem[] }> {
|
||||
return apiGet(`/tenants/${SITE_DOMAIN}/store-items`, { productId, pageSize: 100 });
|
||||
// Dedicated add-to-cart lookup: variants grouped under the product's store
|
||||
// item, per the guest-cart contract in AI_PROMPT.md.
|
||||
export function getStoreItemByProduct(
|
||||
productId: string
|
||||
): Promise<{ storeItem: StoreItemGroup | null }> {
|
||||
return apiGet(`/tenants/${SITE_DOMAIN}/store-items/by-product/${productId}`);
|
||||
}
|
||||
|
||||
export function getProductTechnicalInfo(
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { CUSTOMER_PORTAL_URL, SITE_DOMAIN } from "./config";
|
||||
import type { CartLine } from "./types";
|
||||
|
||||
export const GUEST_CART_STORAGE_KEY = "meshkee-guest-cart";
|
||||
const GUEST_CART_COOKIE_NAME = "meshkee-guest-cart";
|
||||
const ACCESS_TOKEN_COOKIE_NAME = "meshkee_customer_access_token";
|
||||
// Cookies cap out around 4KB per-cookie; stay well under that so the
|
||||
// Set-Cookie header itself (name + attributes) never gets truncated.
|
||||
const MAX_COOKIE_VALUE_LENGTH = 3500;
|
||||
|
||||
// Exact encoding mandated by the customer dashboard's guest-cart contract
|
||||
// (AI_PROMPT.md → Shopping cart) — base64url, no padding.
|
||||
export function encodeGuestCart(items: CartLine[]): string {
|
||||
const json = JSON.stringify(items);
|
||||
return btoa(unescape(encodeURIComponent(json)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
// Storefront only ever reads this cookie to detect a logged-in customer
|
||||
// session (set by the customer dashboard) — never writes or clears it.
|
||||
export function hasAccessTokenCookie(): boolean {
|
||||
return !!readCookie(ACCESS_TOKEN_COOKIE_NAME);
|
||||
}
|
||||
|
||||
// Shared parent-domain cookie so the customer dashboard can pick the guest
|
||||
// cart up without a URL param, when it fits comfortably under browsers'
|
||||
// per-cookie size limit. Silently skipped when the cart is too large — the
|
||||
// URL-param handoff on Continue is the guaranteed path either way.
|
||||
export function writeGuestCartCookie(items: CartLine[]) {
|
||||
if (typeof document === "undefined") return;
|
||||
const value = encodeGuestCart(items);
|
||||
const domainAttr = SITE_DOMAIN.includes("localhost") ? "" : `; Domain=.${SITE_DOMAIN}`;
|
||||
if (value.length > MAX_COOKIE_VALUE_LENGTH) {
|
||||
document.cookie = `${GUEST_CART_COOKIE_NAME}=; Max-Age=0; Path=/${domainAttr}`;
|
||||
return;
|
||||
}
|
||||
const maxAge = 60 * 60 * 24 * 30;
|
||||
document.cookie = `${GUEST_CART_COOKIE_NAME}=${encodeURIComponent(
|
||||
value
|
||||
)}; Max-Age=${maxAge}; Path=/${domainAttr}; SameSite=Lax`;
|
||||
}
|
||||
|
||||
export function clearGuestCartCookie() {
|
||||
if (typeof document === "undefined") return;
|
||||
const domainAttr = SITE_DOMAIN.includes("localhost") ? "" : `; Domain=.${SITE_DOMAIN}`;
|
||||
document.cookie = `${GUEST_CART_COOKIE_NAME}=; Max-Age=0; Path=/${domainAttr}`;
|
||||
}
|
||||
|
||||
// "Continue" always leaves this site for the customer dashboard, which owns
|
||||
// cart/checkout/auth. Logged-in customers go straight to checkout; guests go
|
||||
// through login first and are bounced back with the cart preserved.
|
||||
export function getContinueUrl(items: CartLine[]): string {
|
||||
const encoded = encodeGuestCart(items);
|
||||
const checkoutPath = `/checkout/cart?guestCart=${encoded}`;
|
||||
if (hasAccessTokenCookie()) {
|
||||
return `${CUSTOMER_PORTAL_URL}${checkoutPath}`;
|
||||
}
|
||||
return `${CUSTOMER_PORTAL_URL}/login?redirect=${encodeURIComponent(checkoutPath)}`;
|
||||
}
|
||||
@@ -195,6 +195,31 @@ export interface StoreItem {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Wrapper returned by the dedicated by-product store-items endpoint, used
|
||||
// specifically for the add-to-cart flow (variants grouped under one item).
|
||||
export interface StoreItemGroup {
|
||||
id: string;
|
||||
productId: string;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
variants: StoreItem[];
|
||||
}
|
||||
|
||||
// Guest mini-cart line — shape mandated by the Meshkee customer dashboard
|
||||
// contract (see AI_PROMPT.md → Shopping cart). `id` is always the
|
||||
// store-item *variant* id, never a product id.
|
||||
export interface CartLine {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
price: number;
|
||||
originalPrice?: number;
|
||||
image: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface StoreSpecialVariant {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
Reference in New Issue
Block a user