Move Storefront queries onto @shopify/hydrogen preview client

Replaces the hand-rolled shopifyFetch wrapper and raw query strings with
the hydrogen preview package (0.0.0-preview-116d5d7-20260730141607), the
same build pinned in hydrogen-preview/.

- services/shopify/client: createStorefrontClient({type: "public"}) with a
  static request context. Two module-scoped clients — uncached for carts,
  products and customers; revalidate-3600 for shop policies — using the
  client's custom fetch option to carry Next's caching hints.
- graphql/*: every document wrapped in gql(), fragments composed via the
  second argument instead of string interpolation, and $country/$language
  declared with @inContext so hydrogen injects them.
- shopifyFetch keeps its {query, variables} call shape so call sites are
  unchanged, but is now generic over the document, so data is inferred. It
  re-raises GraphQL errors, which hydrogen returns rather than throws.

Env var names, the 2025-07 API version pin, and the client-side fetching
architecture are unchanged.

Turning on type coverage surfaced real defects, not just annotations:

- hydrogen gql check caught $discountCodes: [String!] used where the field
  requires [String!]!.
- Cart and customer mutation payloads are nullable and were dereferenced
  unconditionally, so a failed mutation threw a TypeError. Adds a shared
  unwrapCartPayload helper (collapsing five copies of the same userErrors
  check) and explicit null handling in the customer service, so a null
  payload reads as an error rather than success with no errors.
- Search results are a Product | Page | Article union; adds __typename to
  the queries and narrows on it.
- Widens nullable fields (altText, image, customer.email, totalTaxAmount)
  in the domain interfaces and in the structural duplicates some
  components declare locally.

Adds a typecheck script (tsc --noEmit && hydrogen gql check); it passes
with 0 errors. Two deprecation warnings are left alone as acting on them
would change behaviour: ProductOption.values and CartCost.totalTaxAmount.

Verified against mock.shop: build prerenders the policy pages through the
cached client, and in the browser the product grid, search with facets,
collection filter round-trip, add-to-cart and quantity update all work.
Customer account flows are untested — they need real credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaxgqPbFxLsSuLPZom2kdC
This commit is contained in:
Rami Bitar
2026-08-08 10:24:38 -04:00
co-authored by Claude Opus 5
parent 95b191eeb8
commit f8c3ef9e0e
21 changed files with 468 additions and 215 deletions
+36 -33
View File
@@ -45,12 +45,12 @@ interface CartLine {
currencyCode: string;
};
image?: {
id: string;
id?: string | null;
url: string;
altText?: string;
width: number;
height: number;
};
altText?: string | null;
width?: number | null;
height?: number | null;
} | null;
product: {
id: string;
title: string;
@@ -82,7 +82,7 @@ export interface Cart {
totalTaxAmount?: {
amount: string;
currencyCode: string;
};
} | null;
};
lines: {
edges: Array<{
@@ -93,17 +93,38 @@ export interface Cart {
// ─── Shopify API functions ───────────────────────────────────────────
/**
* Every cart mutation returns the same `{ cart, userErrors }` payload, and both
* the payload and the cart inside it are nullable — Shopify returns no cart when
* the mutation could not be applied. Callers want a cart or an exception.
*/
function unwrapCartPayload<T>(
payload:
| {
cart?: T | null;
userErrors: ReadonlyArray<{ message: string }>;
}
| null
| undefined
): T {
if (payload?.userErrors.length) {
throw new Error(payload.userErrors[0].message);
}
if (!payload?.cart) {
throw new Error('Cart update failed. Please try again.');
}
return payload.cart;
}
async function createCartApi(lines: CartLineInput[] = []): Promise<Cart> {
const response = await shopifyFetch({
query: CREATE_CART_MUTATION,
variables: { lines: lines.length > 0 ? lines : null },
});
if (response.data.cartCreate.userErrors.length > 0) {
throw new Error(response.data.cartCreate.userErrors[0].message);
}
return response.data.cartCreate.cart;
return unwrapCartPayload(response.data.cartCreate);
}
async function addCartLinesApi(
@@ -115,11 +136,7 @@ async function addCartLinesApi(
variables: { cartId, lines },
});
if (response.data.cartLinesAdd.userErrors.length > 0) {
throw new Error(response.data.cartLinesAdd.userErrors[0].message);
}
return response.data.cartLinesAdd.cart;
return unwrapCartPayload(response.data.cartLinesAdd);
}
async function updateCartLinesApi(
@@ -131,11 +148,7 @@ async function updateCartLinesApi(
variables: { cartId, lines },
});
if (response.data.cartLinesUpdate.userErrors.length > 0) {
throw new Error(response.data.cartLinesUpdate.userErrors[0].message);
}
return response.data.cartLinesUpdate.cart;
return unwrapCartPayload(response.data.cartLinesUpdate);
}
async function removeCartLinesApi(
@@ -147,11 +160,7 @@ async function removeCartLinesApi(
variables: { cartId, lineIds },
});
if (response.data.cartLinesRemove.userErrors.length > 0) {
throw new Error(response.data.cartLinesRemove.userErrors[0].message);
}
return response.data.cartLinesRemove.cart;
return unwrapCartPayload(response.data.cartLinesRemove);
}
async function updateCartDiscountCodesApi(
@@ -163,13 +172,7 @@ async function updateCartDiscountCodesApi(
variables: { cartId, discountCodes },
});
if (response.data.cartDiscountCodesUpdate.userErrors.length > 0) {
throw new Error(
response.data.cartDiscountCodesUpdate.userErrors[0].message
);
}
return response.data.cartDiscountCodesUpdate.cart;
return unwrapCartPayload(response.data.cartDiscountCodesUpdate);
}
async function getCartApi(cartId: string): Promise<Cart | null> {