119 lines
4.3 KiB
TypeScript
119 lines
4.3 KiB
TypeScript
/**
|
||
* 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<string, unknown> }) => Promise<Response> };
|
||
|
||
export interface StorefrontLine {
|
||
variantId: string | number;
|
||
quantity: number;
|
||
price?: number; // minor units, from /cart.js
|
||
title?: string;
|
||
}
|
||
|
||
interface VariantInfo {
|
||
item: Omit<CartItem, "quantity" | "price"> & { price: number };
|
||
stock: Record<string, number>; // shopify location gid → available
|
||
tracked: boolean;
|
||
isFee: boolean;
|
||
}
|
||
|
||
const cache = new Map<string, { at: number; v: VariantInfo }>();
|
||
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<string, string>,
|
||
): 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<string, number> = {};
|
||
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;
|
||
}
|