Phase 0: pin Storefront API 2026-10, carry Variant GIDs, serve last-good data
Foundations for the phases that write. The Storefront served a retired API version (2025-01 was answered as 2025-10) and no product carried a merchandise ID, so nothing could be carted. - Pin the Storefront API to 2026-10 and log the version Shopify actually served, so retired-version drift is visible instead of silent. Drop the stale predictiveSearch comment. - ShopProduct carries the Variant GID and per-variant price, replacing the product-level price range with the Variant as the one source of truth. Products without a Variant are dropped rather than shown uncartable. - Send Shopify-Storefront-Buyer-IP from the buyer's request, and omit it when no request is in scope (builds, scripts). - Standardise on the minted OAuth token; the static storefront token is a warned legacy fallback. - Persist the last known-good payloads to disk beside the token cache, so a Shopify outage serves stale real commerce data rather than the bundled Editorial catalog. Verified against an unreachable store: a product absent from the Editorial catalog still rendered from last-good data. - Fix a latent 500: a failed OAuth mint threw out of gql (it ran outside the try). Transport failures now report status 0 and gql fails closed. - Point the seasonal Rakhi card at the gift collection; its /products/rakhi-bark-box-200gm handle is dead and no Rakhi product remains.
This commit is contained in:
1 parent
1120a35fd1
commit
bb0c78fec5
2 files changed
+227
-77
No files matched your search
+4
-1
@@ -533,7 +533,10 @@ export const seasonal = [
|
|||||||
name: "Rakhi",
|
name: "Rakhi",
|
||||||
blurb: "Threads & chocolate",
|
blurb: "Threads & chocolate",
|
||||||
image: "/products/whatsappimage2026-08-17at5-27-27pm.jpg",
|
image: "/products/whatsappimage2026-08-17at5-27-27pm.jpg",
|
||||||
url: "/products/rakhi-bark-box-200gm",
|
// No Rakhi product exists in the Shopify catalog any more (the old
|
||||||
|
// rakhi-bark-box-200gm handle is dead), so the card points at the gift
|
||||||
|
// collection like the other occasion tiles.
|
||||||
|
url: "/collections/gift-collection",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Eid",
|
name: "Eid",
|
||||||
|
|||||||
+223
-76
@@ -1,61 +1,93 @@
|
|||||||
/**
|
/**
|
||||||
* Shopify Storefront GraphQL client — commerce truth (products, prices,
|
* Shopify Storefront GraphQL client — commerce truth (products, prices,
|
||||||
* collections). Read-only; cart/checkout arrive in Phase 2.
|
* collections, and the Variant GIDs the Cart takes as merchandise IDs).
|
||||||
|
* Read-only until Phase 1 adds cart mutations.
|
||||||
*
|
*
|
||||||
* Credentials, either of:
|
* Credentials — the standard path is the OAuth client-credentials app:
|
||||||
* SHOPIFY_STOREFRONT_ACCESS_TOKEN static 32-hex storefront token, or
|
|
||||||
* SHOPIFY_CLIENT_ID + SHOPIFY_CLIENT_SECRET
|
* SHOPIFY_CLIENT_ID + SHOPIFY_CLIENT_SECRET
|
||||||
* OAuth client-credentials app — the
|
* the client mints an admin token (24 h)
|
||||||
* client mints an admin token (24 h) and
|
* and uses it to mint a storefront token,
|
||||||
* uses it to mint a long-lived storefront
|
* cached on disk and re-minted if it ever
|
||||||
* token, cached on disk and re-minted if
|
* stops working.
|
||||||
* it ever stops working.
|
* SHOPIFY_STOREFRONT_ACCESS_TOKEN legacy static 32-hex storefront token,
|
||||||
|
* used only when the OAuth pair is absent.
|
||||||
* plus SHOPIFY_STORE_DOMAIN, and optionally SHOPIFY_TOKEN_CACHE (path for the
|
* plus SHOPIFY_STORE_DOMAIN, and optionally SHOPIFY_TOKEN_CACHE (path for the
|
||||||
* minted-token cache; defaults to <cwd>/.cache/shopify-token.json).
|
* minted-token cache; defaults to <cwd>/.cache/shopify-token.json).
|
||||||
*
|
*
|
||||||
* Every response is cached in memory (TTL + stale-while-revalidate) so page
|
* `API_VERSION` is pinned deliberately. Shopify serves a retired version as
|
||||||
* renders almost never hit the API. Without credentials the site falls back
|
* something else without complaint, so the version it actually served
|
||||||
* to bundled catalog data — callers treat `null` as "use the defaults".
|
* (`X-Shopify-API-Version`) is logged whenever it drifts — a silent downgrade
|
||||||
|
* should show up in logs, not in wrong field behaviour.
|
||||||
|
*
|
||||||
|
* Calls carry `Shopify-Storefront-Buyer-IP` whenever a buyer request is in
|
||||||
|
* scope, and omit it when there is none (builds, scripts).
|
||||||
|
*
|
||||||
|
* Every response is cached in memory (TTL + stale-while-revalidate) and the
|
||||||
|
* last good payload is also written to disk, so an outage serves real but
|
||||||
|
* stale commerce data rather than the bundled Editorial catalog. Callers
|
||||||
|
* treat `null` as "no real data at all" and use the defaults.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { getRequestIP } from "@tanstack/react-start/server";
|
||||||
|
|
||||||
const DOMAIN = process.env.SHOPIFY_STORE_DOMAIN ?? "";
|
const DOMAIN = process.env.SHOPIFY_STORE_DOMAIN ?? "";
|
||||||
const STATIC_TOKEN = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? "";
|
const STATIC_TOKEN = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? "";
|
||||||
const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID ?? "";
|
const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID ?? "";
|
||||||
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET ?? "";
|
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET ?? "";
|
||||||
|
const hasOAuth = Boolean(CLIENT_ID && CLIENT_SECRET);
|
||||||
const TOKEN_CACHE_FILE =
|
const TOKEN_CACHE_FILE =
|
||||||
process.env.SHOPIFY_TOKEN_CACHE ??
|
process.env.SHOPIFY_TOKEN_CACHE ??
|
||||||
path.join(process.cwd(), ".cache", "shopify-token.json");
|
path.join(process.cwd(), ".cache", "shopify-token.json");
|
||||||
const ADMIN_API_VERSION = "2026-07";
|
const ADMIN_API_VERSION = "2026-07";
|
||||||
const API_VERSION = "2025-01";
|
/** Storefront API version — pinned; drift is logged in `gql`. */
|
||||||
|
const API_VERSION = "2026-10";
|
||||||
const ENDPOINT = `https://${DOMAIN}/api/${API_VERSION}/graphql.json`;
|
const ENDPOINT = `https://${DOMAIN}/api/${API_VERSION}/graphql.json`;
|
||||||
|
|
||||||
export const shopifyEnabled = Boolean(
|
export const shopifyEnabled = Boolean(DOMAIN && (hasOAuth || STATIC_TOKEN));
|
||||||
DOMAIN && (STATIC_TOKEN || (CLIENT_ID && CLIENT_SECRET)),
|
|
||||||
);
|
/**
|
||||||
|
* The buyer's IP, as `Shopify-Storefront-Buyer-IP` wants it. Reads the request
|
||||||
|
* in scope; outside one (sitemap builds, scripts, warmups) there is no buyer
|
||||||
|
* and therefore no header — Shopify tolerates its absence.
|
||||||
|
*/
|
||||||
|
function buyerIp(): string | undefined {
|
||||||
|
try {
|
||||||
|
return getRequestIP({ xForwardedFor: true }) || undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined; // no request in scope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── token provider ───────────────────────────────────────── */
|
/* ── token provider ───────────────────────────────────────── */
|
||||||
|
|
||||||
let memToken: string | null = null;
|
let memToken: string | null = null;
|
||||||
let minting: Promise<string | null> | null = null;
|
let minting: Promise<string | null> | null = null;
|
||||||
|
let legacyTokenWarned = false;
|
||||||
|
|
||||||
|
/** POST JSON, reporting a transport failure as `status: 0` rather than
|
||||||
|
* throwing — credential checks below must fail closed, not explode. */
|
||||||
const postJson = async (
|
const postJson = async (
|
||||||
url: string,
|
url: string,
|
||||||
body: unknown,
|
body: unknown,
|
||||||
headers: Record<string, string> = {},
|
headers: Record<string, string> = {},
|
||||||
) => {
|
) => {
|
||||||
const res = await fetch(url, {
|
try {
|
||||||
method: "POST",
|
const res = await fetch(url, {
|
||||||
headers: { "Content-Type": "application/json", ...headers },
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
headers: { "Content-Type": "application/json", ...headers },
|
||||||
signal: AbortSignal.timeout(10_000),
|
body: JSON.stringify(body),
|
||||||
});
|
signal: AbortSignal.timeout(10_000),
|
||||||
return {
|
});
|
||||||
status: res.status,
|
return {
|
||||||
json: (await res.json().catch(() => null)) as never,
|
status: res.status,
|
||||||
};
|
json: (await res.json().catch(() => null)) as never,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[shopify] transport failed", url, error);
|
||||||
|
return { status: 0, json: null as never };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Cheap authenticated check that a storefront token actually works. */
|
/** Cheap authenticated check that a storefront token actually works. */
|
||||||
@@ -137,30 +169,47 @@ function clearCachedToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureToken(): Promise<string | null> {
|
async function ensureToken(): Promise<string | null> {
|
||||||
if (STATIC_TOKEN) return STATIC_TOKEN;
|
|
||||||
if (memToken) return memToken;
|
if (memToken) return memToken;
|
||||||
if (!DOMAIN || !CLIENT_ID || !CLIENT_SECRET) return null;
|
|
||||||
if (minting) return minting;
|
|
||||||
|
|
||||||
minting = (async () => {
|
// Standard path: mint from the OAuth app's client credentials.
|
||||||
const cached = readCachedToken();
|
if (DOMAIN && hasOAuth) {
|
||||||
if (cached && (await tokenWorks(cached))) {
|
if (!minting) {
|
||||||
memToken = cached;
|
minting = (async () => {
|
||||||
return cached;
|
const cached = readCachedToken();
|
||||||
|
if (cached && (await tokenWorks(cached))) return cached;
|
||||||
|
const minted = await mintStorefrontToken();
|
||||||
|
if (minted) {
|
||||||
|
writeCachedToken(minted);
|
||||||
|
console.log("[shopify] storefront token minted");
|
||||||
|
return minted;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()
|
||||||
|
.then((token) => {
|
||||||
|
memToken = token;
|
||||||
|
return token;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
minting = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const minted = await mintStorefrontToken();
|
const token = await minting;
|
||||||
if (minted) {
|
if (token) return token;
|
||||||
writeCachedToken(minted);
|
}
|
||||||
memToken = minted;
|
|
||||||
console.log("[shopify] storefront token minted");
|
|
||||||
return minted;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})().finally(() => {
|
|
||||||
minting = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return minting;
|
// Legacy fallback: a static storefront token, only when the OAuth app is
|
||||||
|
// absent or the mint failed. No rotation, so it is not the standard path.
|
||||||
|
if (STATIC_TOKEN) {
|
||||||
|
if (!legacyTokenWarned) {
|
||||||
|
legacyTokenWarned = true;
|
||||||
|
console.warn(
|
||||||
|
"[shopify] using legacy SHOPIFY_STOREFRONT_ACCESS_TOKEN — the standard credential path is SHOPIFY_CLIENT_ID + SHOPIFY_CLIENT_SECRET",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return STATIC_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NutritionRow = { label: string; value: string; unit?: string };
|
export type NutritionRow = { label: string; value: string; unit?: string };
|
||||||
@@ -187,6 +236,12 @@ function formatNutrient(raw: string): string {
|
|||||||
export type ShopProduct = {
|
export type ShopProduct = {
|
||||||
handle: string;
|
handle: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
/**
|
||||||
|
* The product's single Variant GID — the merchandise ID the Cart API takes.
|
||||||
|
* Every product in this catalog has exactly one Variant (ADR-0002); products
|
||||||
|
* without one are dropped by `shapeProduct` rather than shipped uncartable.
|
||||||
|
*/
|
||||||
|
variantId: string;
|
||||||
/** tag shown on the card (weight, or first useful tag) */
|
/** tag shown on the card (weight, or first useful tag) */
|
||||||
weight?: string;
|
weight?: string;
|
||||||
/** Shopify product_type (collection-page filter chips) */
|
/** Shopify product_type (collection-page filter chips) */
|
||||||
@@ -202,6 +257,7 @@ export type ShopProduct = {
|
|||||||
descriptionHtml: string;
|
descriptionHtml: string;
|
||||||
/** short plain-text description */
|
/** short plain-text description */
|
||||||
blurb: string;
|
blurb: string;
|
||||||
|
/** the Variant's price — commerce truth, not the Editorial catalog's */
|
||||||
price: number;
|
price: number;
|
||||||
compareAtPrice?: number;
|
compareAtPrice?: number;
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
@@ -218,43 +274,122 @@ export type ShopCollection = {
|
|||||||
products: ShopProduct[];
|
products: ShopProduct[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── cache (TTL + stale fallback) ──────────────────────────── */
|
/* ── cache: fresh in memory, last-good on disk ─────────────── */
|
||||||
|
|
||||||
type Entry = { at: number; data: unknown };
|
type Entry = { at: number; data: unknown };
|
||||||
const CACHE = new Map<string, Entry>();
|
const CACHE = new Map<string, Entry>();
|
||||||
const TTL_MS = Number(process.env.SHOPIFY_CACHE_TTL_MS ?? 60_000);
|
const TTL_MS = Number(process.env.SHOPIFY_CACHE_TTL_MS ?? 60_000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last known-good payload per key, kept on disk beside the token cache so it
|
||||||
|
* survives restarts and deploys. A Shopify outage then serves real (if stale)
|
||||||
|
* commerce data instead of the bundled Editorial catalog. Search results are
|
||||||
|
* excluded — their key space is unbounded.
|
||||||
|
*/
|
||||||
|
const DATA_CACHE_FILE =
|
||||||
|
process.env.SHOPIFY_DATA_CACHE ??
|
||||||
|
path.join(path.dirname(TOKEN_CACHE_FILE), "shopify-data.json");
|
||||||
|
const NEVER_PERSIST = /^search:/;
|
||||||
|
const FLUSH_MS = 1_000;
|
||||||
|
|
||||||
|
let lastGood: Map<string, Entry> | null = null;
|
||||||
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function lastGoodStore(): Map<string, Entry> {
|
||||||
|
if (lastGood) return lastGood;
|
||||||
|
lastGood = new Map();
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(DATA_CACHE_FILE, "utf8")) as Record<
|
||||||
|
string,
|
||||||
|
Entry
|
||||||
|
>;
|
||||||
|
for (const [key, entry] of Object.entries(raw)) {
|
||||||
|
if (entry && typeof entry.at === "number") lastGood.set(key, entry);
|
||||||
|
}
|
||||||
|
console.log(`[shopify] last-good cache loaded (${lastGood.size} keys)`);
|
||||||
|
} catch {
|
||||||
|
/* no cache yet, or unreadable — start empty */
|
||||||
|
}
|
||||||
|
return lastGood;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Debounced whole-file rewrite: one page load sets dozens of keys at once. */
|
||||||
|
function persistLastGood() {
|
||||||
|
if (flushTimer) return;
|
||||||
|
flushTimer = setTimeout(() => {
|
||||||
|
flushTimer = null;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(DATA_CACHE_FILE), { recursive: true });
|
||||||
|
const tmp = `${DATA_CACHE_FILE}.tmp`;
|
||||||
|
fs.writeFileSync(
|
||||||
|
tmp,
|
||||||
|
JSON.stringify(Object.fromEntries(lastGoodStore())),
|
||||||
|
);
|
||||||
|
fs.renameSync(tmp, DATA_CACHE_FILE);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[shopify] last-good cache write skipped", error);
|
||||||
|
}
|
||||||
|
}, FLUSH_MS);
|
||||||
|
(flushTimer as { unref?: () => void }).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
function getCached<T>(key: string): T | undefined {
|
function getCached<T>(key: string): T | undefined {
|
||||||
const hit = CACHE.get(key);
|
const hit = CACHE.get(key);
|
||||||
if (!hit) return undefined;
|
if (!hit) return undefined;
|
||||||
if (Date.now() - hit.at > TTL_MS) return undefined; // expired
|
if (Date.now() - hit.at > TTL_MS) return undefined; // expired
|
||||||
return hit.data as T;
|
return hit.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Any-age real data: in-process first, then the on-disk last good. */
|
||||||
function getStale<T>(key: string): T | undefined {
|
function getStale<T>(key: string): T | undefined {
|
||||||
const hit = CACHE.get(key);
|
const hit = CACHE.get(key) ?? lastGoodStore().get(key);
|
||||||
return hit ? (hit.data as T) : undefined; // any age — error fallback
|
return hit ? (hit.data as T) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCached(key: string, data: unknown) {
|
function setCached(key: string, data: unknown) {
|
||||||
CACHE.set(key, { at: Date.now(), data });
|
const entry: Entry = { at: Date.now(), data };
|
||||||
|
CACHE.set(key, entry);
|
||||||
|
if (!NEVER_PERSIST.test(key)) {
|
||||||
|
lastGoodStore().set(key, entry);
|
||||||
|
persistLastGood();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let loggedServedVersion: string | null = null;
|
||||||
|
|
||||||
|
/** Surface the version Shopify actually served, once per distinct value. */
|
||||||
|
function noteServedVersion(served: string | null) {
|
||||||
|
if (!served || served === loggedServedVersion) return;
|
||||||
|
loggedServedVersion = served;
|
||||||
|
if (served === API_VERSION) {
|
||||||
|
console.log(`[shopify] Storefront API ${served}`);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`[shopify] pinned to Storefront API ${API_VERSION} but Shopify served ${served}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function gql<T>(
|
async function gql<T>(
|
||||||
query: string,
|
query: string,
|
||||||
variables?: Record<string, unknown>,
|
variables?: Record<string, unknown>,
|
||||||
): Promise<T | null> {
|
): Promise<T | null> {
|
||||||
let token = await ensureToken();
|
|
||||||
if (!token) return null;
|
|
||||||
for (let attempt = 0; attempt < 2; attempt++) {
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
try {
|
try {
|
||||||
|
let token = await ensureToken();
|
||||||
|
if (!token) return null;
|
||||||
|
const ip = buyerIp();
|
||||||
const res = await fetch(ENDPOINT, {
|
const res = await fetch(ENDPOINT, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-Shopify-Storefront-Access-Token": token,
|
"X-Shopify-Storefront-Access-Token": token,
|
||||||
|
...(ip ? { "Shopify-Storefront-Buyer-IP": ip } : {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
signal: AbortSignal.timeout(8_000),
|
signal: AbortSignal.timeout(8_000),
|
||||||
});
|
});
|
||||||
|
noteServedVersion(res.headers.get("x-shopify-api-version"));
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (res.status === 401 || res.status === 403) {
|
||||||
// token went stale/revoked — drop it and re-mint once
|
// token went stale/revoked — drop it and re-mint once
|
||||||
memToken = null;
|
memToken = null;
|
||||||
@@ -293,17 +428,6 @@ const PRODUCT_FIELDS = /* GraphQL */ `
|
|||||||
tags
|
tags
|
||||||
productType
|
productType
|
||||||
availableForSale
|
availableForSale
|
||||||
priceRange {
|
|
||||||
minVariantPrice {
|
|
||||||
amount
|
|
||||||
currencyCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
compareAtPriceRange {
|
|
||||||
minVariantPrice {
|
|
||||||
amount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
images(first: 6) {
|
images(first: 6) {
|
||||||
nodes {
|
nodes {
|
||||||
url
|
url
|
||||||
@@ -312,9 +436,17 @@ const PRODUCT_FIELDS = /* GraphQL */ `
|
|||||||
}
|
}
|
||||||
variants(first: 1) {
|
variants(first: 1) {
|
||||||
nodes {
|
nodes {
|
||||||
|
id
|
||||||
availableForSale
|
availableForSale
|
||||||
weight
|
weight
|
||||||
weightUnit
|
weightUnit
|
||||||
|
price {
|
||||||
|
amount
|
||||||
|
currencyCode
|
||||||
|
}
|
||||||
|
compareAtPrice {
|
||||||
|
amount
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
metafields(
|
metafields(
|
||||||
@@ -347,14 +479,15 @@ type GqlProduct = {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
productType: string | null;
|
productType: string | null;
|
||||||
availableForSale: boolean;
|
availableForSale: boolean;
|
||||||
priceRange: { minVariantPrice: { amount: string; currencyCode: string } };
|
|
||||||
compareAtPriceRange?: { minVariantPrice: { amount: string } };
|
|
||||||
images: { nodes: { url: string; altText: string | null }[] };
|
images: { nodes: { url: string; altText: string | null }[] };
|
||||||
variants: {
|
variants: {
|
||||||
nodes: {
|
nodes: {
|
||||||
|
id: string;
|
||||||
availableForSale: boolean;
|
availableForSale: boolean;
|
||||||
weight: number | null;
|
weight: number | null;
|
||||||
weightUnit: string | null;
|
weightUnit: string | null;
|
||||||
|
price: { amount: string; currencyCode: string };
|
||||||
|
compareAtPrice: { amount: string } | null;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
metafields: { namespace: string; key: string; value: string }[] | null;
|
metafields: { namespace: string; key: string; value: string }[] | null;
|
||||||
@@ -369,9 +502,17 @@ const stripTags = (html: string) =>
|
|||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
function shapeProduct(p: GqlProduct): ShopProduct {
|
/**
|
||||||
|
* `null` when the product has no Variant: it has no merchandise ID, so it
|
||||||
|
* cannot be carted and must not be shown as buyable.
|
||||||
|
*/
|
||||||
|
function shapeProduct(p: GqlProduct): ShopProduct | null {
|
||||||
const variant = p.variants.nodes[0];
|
const variant = p.variants.nodes[0];
|
||||||
const weight = variant?.weight;
|
if (!variant?.id) {
|
||||||
|
console.warn(`[shopify] ${p.handle}: no variant, dropped`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const weight = variant.weight;
|
||||||
|
|
||||||
// metafields arrive as null list entries when a key is absent
|
// metafields arrive as null list entries when a key is absent
|
||||||
const mf = new Map<string, string>();
|
const mf = new Map<string, string>();
|
||||||
@@ -423,28 +564,29 @@ function shapeProduct(p: GqlProduct): ShopProduct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unit =
|
const unit =
|
||||||
variant?.weightUnit === "KILOGRAMS"
|
variant.weightUnit === "KILOGRAMS"
|
||||||
? "kg"
|
? "kg"
|
||||||
: variant?.weightUnit === "GRAMS"
|
: variant.weightUnit === "GRAMS"
|
||||||
? "g"
|
? "g"
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
handle: p.handle,
|
handle: p.handle,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
|
variantId: variant.id,
|
||||||
descriptionHtml: p.descriptionHtml,
|
descriptionHtml: p.descriptionHtml,
|
||||||
blurb: stripTags(p.descriptionHtml).slice(0, 180),
|
blurb: stripTags(p.descriptionHtml).slice(0, 180),
|
||||||
price: Number(p.priceRange.minVariantPrice.amount),
|
price: Number(variant.price.amount),
|
||||||
compareAtPrice: p.compareAtPriceRange
|
compareAtPrice: variant.compareAtPrice
|
||||||
? Number(p.compareAtPriceRange.minVariantPrice.amount) || undefined
|
? Number(variant.compareAtPrice.amount) || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
currencyCode: p.priceRange.minVariantPrice.currencyCode,
|
currencyCode: variant.price.currencyCode,
|
||||||
productType: p.productType || undefined,
|
productType: p.productType || undefined,
|
||||||
tagline: pick("custom.tagline"),
|
tagline: pick("custom.tagline"),
|
||||||
nutrition: nutrition.length ? nutrition : undefined,
|
nutrition: nutrition.length ? nutrition : undefined,
|
||||||
ingredients: pick("custom.ingredients", "facts.ingredients"),
|
ingredients: pick("custom.ingredients", "facts.ingredients"),
|
||||||
storage: pick("facts.storage"),
|
storage: pick("facts.storage"),
|
||||||
images: p.images.nodes.map((i) => i.url),
|
images: p.images.nodes.map((i) => i.url),
|
||||||
available: p.availableForSale && (variant?.availableForSale ?? true),
|
available: p.availableForSale && variant.availableForSale,
|
||||||
weight:
|
weight:
|
||||||
weight && unit
|
weight && unit
|
||||||
? `${weight} ${unit}`
|
? `${weight} ${unit}`
|
||||||
@@ -453,6 +595,10 @@ function shapeProduct(p: GqlProduct): ShopProduct {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shape a product list, dropping any entry with no merchandise ID. */
|
||||||
|
const shapeProducts = (nodes: GqlProduct[]): ShopProduct[] =>
|
||||||
|
nodes.map(shapeProduct).filter((p): p is ShopProduct => p !== null);
|
||||||
|
|
||||||
/* ── public API ────────────────────────────────────────────── */
|
/* ── public API ────────────────────────────────────────────── */
|
||||||
|
|
||||||
const COLLECTION_QUERY = /* GraphQL */ `
|
const COLLECTION_QUERY = /* GraphQL */ `
|
||||||
@@ -501,7 +647,7 @@ export async function getShopifyCollection(
|
|||||||
title: data.collection.title,
|
title: data.collection.title,
|
||||||
description: data.collection.description ?? "",
|
description: data.collection.description ?? "",
|
||||||
image: data.collection.image?.url,
|
image: data.collection.image?.url,
|
||||||
products: data.collection.products.nodes.map(shapeProduct),
|
products: shapeProducts(data.collection.products.nodes),
|
||||||
};
|
};
|
||||||
setCached(key, shaped);
|
setCached(key, shaped);
|
||||||
return shaped;
|
return shaped;
|
||||||
@@ -532,6 +678,8 @@ export async function getShopifyProduct(
|
|||||||
if (!data?.product) return getStale<ShopProduct>(key) ?? null;
|
if (!data?.product) return getStale<ShopProduct>(key) ?? null;
|
||||||
|
|
||||||
const shaped = shapeProduct(data.product);
|
const shaped = shapeProduct(data.product);
|
||||||
|
if (!shaped) return getStale<ShopProduct>(key) ?? null;
|
||||||
|
|
||||||
setCached(key, shaped);
|
setCached(key, shaped);
|
||||||
return shaped;
|
return shaped;
|
||||||
}
|
}
|
||||||
@@ -603,7 +751,6 @@ export async function getPredictiveSearch(
|
|||||||
const key = `search:${query.toLowerCase()}`;
|
const key = `search:${query.toLowerCase()}`;
|
||||||
const cached = getCached<PredictiveSearchData>(key);
|
const cached = getCached<PredictiveSearchData>(key);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
// 2025-01: predictiveSearch sections are plain lists, not connections
|
|
||||||
const data = await gql<{
|
const data = await gql<{
|
||||||
predictiveSearch: {
|
predictiveSearch: {
|
||||||
products: GqlProduct[];
|
products: GqlProduct[];
|
||||||
@@ -638,7 +785,7 @@ export async function getPredictiveSearch(
|
|||||||
if (!data?.predictiveSearch)
|
if (!data?.predictiveSearch)
|
||||||
return getStale<PredictiveSearchData>(key) ?? null;
|
return getStale<PredictiveSearchData>(key) ?? null;
|
||||||
const shaped: PredictiveSearchData = {
|
const shaped: PredictiveSearchData = {
|
||||||
products: (data.predictiveSearch.products ?? []).map(shapeProduct),
|
products: shapeProducts(data.predictiveSearch.products ?? []),
|
||||||
collections: (data.predictiveSearch.collections ?? []).map((c) => ({
|
collections: (data.predictiveSearch.collections ?? []).map((c) => ({
|
||||||
handle: c.handle,
|
handle: c.handle,
|
||||||
title: c.title,
|
title: c.title,
|
||||||
|
|||||||
Reference in new issue
Block a user