/** * Cart enrichment: the storefront only knows variant ids and quantities, so we look up * collections, tags, vendor, type and per-location inventory with the Admin API. * Results are cached briefly per shop+variant to keep the widget fast. */ import type { Cart, CartItem } from "../engine/types"; import type { Inventory } from "../engine/locations"; type AdminGraphql = { graphql: (q: string, o?: { variables?: Record }) => Promise }; export interface StorefrontLine { variantId: string | number; quantity: number; price?: number; // minor units, from /cart.js title?: string; } interface VariantInfo { item: Omit & { price: number }; stock: Record; // shopify location gid → available tracked: boolean; isFee: boolean; } const cache = new Map(); const TTL = 60_000; const gid = (id: string | number) => (String(id).startsWith("gid://") ? String(id) : `gid://shopify/ProductVariant/${id}`); export const FEE_TAG = "datewise-fee"; export async function enrichCart( admin: AdminGraphql, shop: string, lines: StorefrontLine[], shopifyToAppLocation: Record, ): Promise<{ cart: Cart; inventory: Inventory; feeLines: StorefrontLine[] }> { const ids = [...new Set(lines.map((l) => gid(l.variantId)))]; const missing = ids.filter((id) => { const c = cache.get(`${shop}|${id}`); return !c || Date.now() - c.at > TTL; }); for (let i = 0; i < missing.length; i += 50) { const res = await admin.graphql( `#graphql query DatewiseCart($ids: [ID!]!) { nodes(ids: $ids) { ... on ProductVariant { id title price product { id title vendor productType tags collections(first: 50) { nodes { id } } } inventoryItem { tracked inventoryLevels(first: 30) { nodes { location { id } quantities(names: ["available"]) { quantity } } } } } } }`, { variables: { ids: missing.slice(i, i + 50) } }, ); const json = (await res.json()) as { data: { nodes: (RawVariant | null)[] } }; for (const n of json.data.nodes) { if (!n) continue; const tracked = n.inventoryItem?.tracked ?? false; const stock: Record = {}; for (const lvl of n.inventoryItem?.inventoryLevels.nodes ?? []) stock[lvl.location.id] = lvl.quantities[0]?.quantity ?? 0; cache.set(`${shop}|${n.id}`, { at: Date.now(), v: { isFee: n.product.tags.includes(FEE_TAG), tracked, stock, item: { productId: n.product.id, variantId: n.id, title: n.product.title + (n.title && n.title !== "Default Title" ? ` – ${n.title}` : ""), price: Math.round(Number(n.price) * 100), collectionIds: n.product.collections.nodes.map((c) => c.id), vendor: n.product.vendor, productType: n.product.productType, tags: n.product.tags, }, }, }); } } const items: CartItem[] = []; const inventory: Inventory = {}; const feeLines: StorefrontLine[] = []; for (const l of lines) { const info = cache.get(`${shop}|${gid(l.variantId)}`)?.v; if (!info) continue; if (info.isFee) { feeLines.push(l); continue; } items.push({ ...info.item, quantity: l.quantity, price: l.price ?? info.item.price }); // Every linked location gets an explicit figure; untracked items count as unlimited. for (const [shopifyLoc, appLoc] of Object.entries(shopifyToAppLocation)) { inventory[appLoc] ??= {}; inventory[appLoc][info.item.variantId] = info.tracked ? info.stock[shopifyLoc] ?? 0 : Number.MAX_SAFE_INTEGER; } } return { cart: { items, subtotal: items.reduce((s, i) => s + i.price * i.quantity, 0) }, inventory, feeLines }; } interface RawVariant { id: string; title: string; price: string; product: { id: string; title: string; vendor: string; productType: string; tags: string[]; collections: { nodes: { id: string }[] } }; inventoryItem: { tracked: boolean; inventoryLevels: { nodes: { location: { id: string }; quantities: { quantity: number }[] }[] } } | null; }