From f8c3ef9e0e6499426bf26ca3ff57b64e233e9111 Mon Sep 17 00:00:00 2001 From: Rami Bitar Date: Sat, 8 Aug 2026 10:24:38 -0400 Subject: [PATCH] Move Storefront queries onto @shopify/hydrogen preview client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JaxgqPbFxLsSuLPZom2kdC --- .gitignore | 3 + components/shopify/collection-card.tsx | 4 +- components/shopify/product-card.tsx | 2 +- components/shopify/product-detail/index.tsx | 4 +- .../product-detail/product-detail-gallery.tsx | 2 +- components/shopify/products.tsx | 2 +- graphql/{cart.js => cart.ts} | 72 +++++++---- graphql/{collections.js => collections.ts} | 20 +-- graphql/{customer.js => customer.ts} | 45 ++++--- graphql/{policies.js => policies.ts} | 6 +- graphql/{products.js => products.ts} | 39 +++--- graphql/{search.js => search.ts} | 24 ++-- hooks/use-shopify-cart.ts | 69 ++++++----- hooks/use-shopify-policies.ts | 43 +++---- package.json | 7 +- services/shopify/catalog.ts | 46 ++++--- services/shopify/client.js | 48 -------- services/shopify/client.ts | 108 ++++++++++++++++ services/shopify/customer.ts | 21 +++- tsconfig.json | 3 + yarn.lock | 115 ++++++++++++++++++ 21 files changed, 468 insertions(+), 215 deletions(-) rename graphql/{cart.js => cart.ts} (63%) rename graphql/{collections.js => collections.ts} (76%) rename graphql/{customer.js => customer.ts} (85%) rename graphql/{policies.js => policies.ts} (88%) rename graphql/{products.js => products.ts} (74%) rename graphql/{search.js => search.ts} (71%) delete mode 100644 services/shopify/client.js create mode 100644 services/shopify/client.ts diff --git a/.gitignore b/.gitignore index dc732bd..8dc6cbf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ next-env.d.ts .yarn/install-state.gz .env*.local tsconfig.tsbuildinfo + +# Temp tsconfig written by `hydrogen gql check`; normally cleaned up on exit +.hydrogen-gql-*.json diff --git a/components/shopify/collection-card.tsx b/components/shopify/collection-card.tsx index 8c07c65..2c8e286 100644 --- a/components/shopify/collection-card.tsx +++ b/components/shopify/collection-card.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; interface CollectionImage { url: string; - altText?: string; + altText?: string | null; } interface Collection { @@ -11,7 +11,7 @@ interface Collection { title: string; handle: string; description?: string; - image?: CollectionImage; + image?: CollectionImage | null; } interface CollectionCardProps { diff --git a/components/shopify/product-card.tsx b/components/shopify/product-card.tsx index fc713ff..8818961 100644 --- a/components/shopify/product-card.tsx +++ b/components/shopify/product-card.tsx @@ -4,7 +4,7 @@ import { truncate } from '@/lib/utils'; interface ProductImage { url: string; - altText?: string; + altText?: string | null; } interface ProductPrice { diff --git a/components/shopify/product-detail/index.tsx b/components/shopify/product-detail/index.tsx index 43b53a1..8212950 100644 --- a/components/shopify/product-detail/index.tsx +++ b/components/shopify/product-detail/index.tsx @@ -29,8 +29,8 @@ interface ProductVariant { }>; image?: { url: string; - altText?: string; - }; + altText?: string | null; + } | null; } export type { Product }; diff --git a/components/shopify/product-detail/product-detail-gallery.tsx b/components/shopify/product-detail/product-detail-gallery.tsx index b0212ce..eb50adb 100644 --- a/components/shopify/product-detail/product-detail-gallery.tsx +++ b/components/shopify/product-detail/product-detail-gallery.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'; interface ProductImage { url: string; - altText?: string; + altText?: string | null; } interface ProductDetailGalleryProps { diff --git a/components/shopify/products.tsx b/components/shopify/products.tsx index 7c643b3..4b6647b 100644 --- a/components/shopify/products.tsx +++ b/components/shopify/products.tsx @@ -8,7 +8,7 @@ import { Loader } from '@/components/ui/loader'; interface ProductImage { url: string; - altText?: string; + altText?: string | null; } interface ProductPrice { diff --git a/graphql/cart.js b/graphql/cart.ts similarity index 63% rename from graphql/cart.js rename to graphql/cart.ts index c70b616..d95959d 100644 --- a/graphql/cart.js +++ b/graphql/cart.ts @@ -1,5 +1,7 @@ +import { gql } from '@shopify/hydrogen'; + // Cart Fragment for consistent cart data -const CartFragment = ` +const CartFragment = gql(` fragment CartFragment on Cart { id checkoutUrl @@ -64,12 +66,13 @@ const CartFragment = ` } } } -`; +`); // Create a new cart -export const CREATE_CART_MUTATION = ` - ${CartFragment} - mutation CreateCart($lines: [CartLineInput!]) { +export const CREATE_CART_MUTATION = gql( + ` + mutation CreateCart($lines: [CartLineInput!], $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { cartCreate(input: { lines: $lines }) { cart { ...CartFragment @@ -80,12 +83,15 @@ export const CREATE_CART_MUTATION = ` } } } -`; +`, + [CartFragment] +); // Add lines to cart -export const ADD_CART_LINES_MUTATION = ` - ${CartFragment} - mutation AddCartLines($cartId: ID!, $lines: [CartLineInput!]!) { +export const ADD_CART_LINES_MUTATION = gql( + ` + mutation AddCartLines($cartId: ID!, $lines: [CartLineInput!]!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { cartLinesAdd(cartId: $cartId, lines: $lines) { cart { ...CartFragment @@ -96,12 +102,15 @@ export const ADD_CART_LINES_MUTATION = ` } } } -`; +`, + [CartFragment] +); // Update cart lines -export const UPDATE_CART_LINES_MUTATION = ` - ${CartFragment} - mutation UpdateCartLines($cartId: ID!, $lines: [CartLineUpdateInput!]!) { +export const UPDATE_CART_LINES_MUTATION = gql( + ` + mutation UpdateCartLines($cartId: ID!, $lines: [CartLineUpdateInput!]!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { cartLinesUpdate(cartId: $cartId, lines: $lines) { cart { ...CartFragment @@ -112,12 +121,15 @@ export const UPDATE_CART_LINES_MUTATION = ` } } } -`; +`, + [CartFragment] +); // Remove lines from cart -export const REMOVE_CART_LINES_MUTATION = ` - ${CartFragment} - mutation RemoveCartLines($cartId: ID!, $lineIds: [ID!]!) { +export const REMOVE_CART_LINES_MUTATION = gql( + ` + mutation RemoveCartLines($cartId: ID!, $lineIds: [ID!]!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { cartLinesRemove(cartId: $cartId, lineIds: $lineIds) { cart { ...CartFragment @@ -128,12 +140,15 @@ export const REMOVE_CART_LINES_MUTATION = ` } } } -`; +`, + [CartFragment] +); // Apply (or clear) discount codes on the cart -export const UPDATE_CART_DISCOUNT_CODES_MUTATION = ` - ${CartFragment} - mutation UpdateCartDiscountCodes($cartId: ID!, $discountCodes: [String!]) { +export const UPDATE_CART_DISCOUNT_CODES_MUTATION = gql( + ` + mutation UpdateCartDiscountCodes($cartId: ID!, $discountCodes: [String!]!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) { cart { ...CartFragment @@ -144,14 +159,19 @@ export const UPDATE_CART_DISCOUNT_CODES_MUTATION = ` } } } -`; +`, + [CartFragment] +); // Get cart by ID -export const GET_CART_QUERY = ` - ${CartFragment} - query GetCart($cartId: ID!) { +export const GET_CART_QUERY = gql( + ` + query GetCart($cartId: ID!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { cart(id: $cartId) { ...CartFragment } } -`; +`, + [CartFragment] +); diff --git a/graphql/collections.js b/graphql/collections.ts similarity index 76% rename from graphql/collections.js rename to graphql/collections.ts index 0eca05a..6523afc 100644 --- a/graphql/collections.js +++ b/graphql/collections.ts @@ -1,8 +1,10 @@ +import { gql } from '@shopify/hydrogen'; import { ProductFragment } from '@/graphql/products'; // Get all collections -export const GET_COLLECTIONS_QUERY = ` - query GetCollections($first: Int!) { +export const GET_COLLECTIONS_QUERY = gql(` + query GetCollections($first: Int!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { collections(first: $first) { edges { node { @@ -26,11 +28,11 @@ export const GET_COLLECTIONS_QUERY = ` } } } -`; +`); // Get products in a collection -export const GET_COLLECTION_PRODUCTS_QUERY = ` - ${ProductFragment} +export const GET_COLLECTION_PRODUCTS_QUERY = gql( + ` query GetCollectionProducts( $handle: String! $first: Int! @@ -38,7 +40,9 @@ export const GET_COLLECTION_PRODUCTS_QUERY = ` $sortKey: ProductCollectionSortKeys $reverse: Boolean $filters: [ProductFilter!] - ) { + $country: CountryCode + $language: LanguageCode + ) @inContext(country: $country, language: $language) { collection(handle: $handle) { id title @@ -82,4 +86,6 @@ export const GET_COLLECTION_PRODUCTS_QUERY = ` } } } -`; +`, + [ProductFragment] +); diff --git a/graphql/customer.js b/graphql/customer.ts similarity index 85% rename from graphql/customer.js rename to graphql/customer.ts index b22fb50..f592b25 100644 --- a/graphql/customer.js +++ b/graphql/customer.ts @@ -1,7 +1,8 @@ // Customer account operations (classic Storefront customer accounts). // https://shopify.dev/docs/api/storefront/latest/objects/Customer +import { gql } from '@shopify/hydrogen'; -const CustomerFragment = ` +const CustomerFragment = gql(` fragment CustomerFragment on Customer { id email @@ -24,10 +25,10 @@ const CustomerFragment = ` phone } } -`; +`); -export const CUSTOMER_QUERY = ` - ${CustomerFragment} +export const CUSTOMER_QUERY = gql( + ` query GetCustomer($customerAccessToken: String!, $orderCount: Int!) { customer(customerAccessToken: $customerAccessToken) { ...CustomerFragment @@ -63,9 +64,11 @@ export const CUSTOMER_QUERY = ` } } } -`; +`, + [CustomerFragment] +); -export const CUSTOMER_CREATE_MUTATION = ` +export const CUSTOMER_CREATE_MUTATION = gql(` mutation CustomerCreate($input: CustomerCreateInput!) { customerCreate(input: $input) { customer { @@ -79,9 +82,9 @@ export const CUSTOMER_CREATE_MUTATION = ` } } } -`; +`); -export const CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION = ` +export const CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION = gql(` mutation CustomerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) { customerAccessTokenCreate(input: $input) { customerAccessToken { @@ -95,9 +98,9 @@ export const CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION = ` } } } -`; +`); -export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = ` +export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = gql(` mutation CustomerAccessTokenDelete($customerAccessToken: String!) { customerAccessTokenDelete(customerAccessToken: $customerAccessToken) { deletedAccessToken @@ -107,10 +110,10 @@ export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = ` } } } -`; +`); // Sends the "reset your password" email. -export const CUSTOMER_RECOVER_MUTATION = ` +export const CUSTOMER_RECOVER_MUTATION = gql(` mutation CustomerRecover($email: String!) { customerRecover(email: $email) { customerUserErrors { @@ -120,10 +123,10 @@ export const CUSTOMER_RECOVER_MUTATION = ` } } } -`; +`); // Completes the reset using the id + token from the emailed link. -export const CUSTOMER_RESET_MUTATION = ` +export const CUSTOMER_RESET_MUTATION = gql(` mutation CustomerReset($id: ID!, $input: CustomerResetInput!) { customerReset(id: $id, input: $input) { customerAccessToken { @@ -137,10 +140,10 @@ export const CUSTOMER_RESET_MUTATION = ` } } } -`; +`); // Activation link sent to customers created by the merchant. -export const CUSTOMER_ACTIVATE_MUTATION = ` +export const CUSTOMER_ACTIVATE_MUTATION = gql(` mutation CustomerActivate($id: ID!, $input: CustomerActivateInput!) { customerActivate(id: $id, input: $input) { customerAccessToken { @@ -154,10 +157,10 @@ export const CUSTOMER_ACTIVATE_MUTATION = ` } } } -`; +`); -export const CUSTOMER_UPDATE_MUTATION = ` - ${CustomerFragment} +export const CUSTOMER_UPDATE_MUTATION = gql( + ` mutation CustomerUpdate($customerAccessToken: String!, $customer: CustomerUpdateInput!) { customerUpdate(customerAccessToken: $customerAccessToken, customer: $customer) { customer { @@ -170,4 +173,6 @@ export const CUSTOMER_UPDATE_MUTATION = ` } } } -`; +`, + [CustomerFragment] +); diff --git a/graphql/policies.js b/graphql/policies.ts similarity index 88% rename from graphql/policies.js rename to graphql/policies.ts index e36b119..c2b710b 100644 --- a/graphql/policies.js +++ b/graphql/policies.ts @@ -1,6 +1,8 @@ +import { gql } from '@shopify/hydrogen'; + // Shop policies are exposed on the `shop` object of the Storefront API. // There is no lookup-by-handle field, so we fetch all of them and match. -export const GET_SHOP_POLICIES_QUERY = ` +export const GET_SHOP_POLICIES_QUERY = gql(` query GetShopPolicies { shop { privacyPolicy { @@ -40,4 +42,4 @@ export const GET_SHOP_POLICIES_QUERY = ` } } } -`; +`); diff --git a/graphql/products.js b/graphql/products.ts similarity index 74% rename from graphql/products.js rename to graphql/products.ts index 725b2fd..6f12e19 100644 --- a/graphql/products.js +++ b/graphql/products.ts @@ -1,5 +1,7 @@ +import { gql } from '@shopify/hydrogen'; + // Product Fragment for consistent product data -export const ProductFragment = ` +export const ProductFragment = gql(` fragment ProductFragment on Product { id title @@ -98,12 +100,13 @@ export const ProductFragment = ` } } } -`; +`); // Get multiple products -export const GET_PRODUCTS_QUERY = ` - ${ProductFragment} - query GetProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) { +export const GET_PRODUCTS_QUERY = gql( + ` + query GetProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) { edges { node { @@ -116,24 +119,32 @@ export const GET_PRODUCTS_QUERY = ` } } } -`; +`, + [ProductFragment] +); // Get a single product by handle -export const GET_PRODUCT_QUERY = ` - ${ProductFragment} - query GetProduct($handle: String!) { +export const GET_PRODUCT_QUERY = gql( + ` + query GetProduct($handle: String!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { product(handle: $handle) { ...ProductFragment } } -`; +`, + [ProductFragment] +); // Get product recommendations -export const QUERY_PRODUCT_RECOMMENDATIONS = ` - ${ProductFragment} - query GetProductRecommendations($productId: ID!) { +export const QUERY_PRODUCT_RECOMMENDATIONS = gql( + ` + query GetProductRecommendations($productId: ID!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { productRecommendations(productId: $productId) { ...ProductFragment } } -`; +`, + [ProductFragment] +); diff --git a/graphql/search.js b/graphql/search.ts similarity index 71% rename from graphql/search.js rename to graphql/search.ts index 7ec0dab..f247b2d 100644 --- a/graphql/search.js +++ b/graphql/search.ts @@ -1,10 +1,11 @@ +import { gql } from '@shopify/hydrogen'; import { ProductFragment } from '@/graphql/products'; // Storefront search over products. `productFilters` accepts the raw `input` // values returned in `productFilters[].values[].input`, so facets round-trip // without the client needing to know each filter's shape. -export const SEARCH_PRODUCTS_QUERY = ` - ${ProductFragment} +export const SEARCH_PRODUCTS_QUERY = gql( + ` query SearchProducts( $query: String! $first: Int! @@ -12,7 +13,9 @@ export const SEARCH_PRODUCTS_QUERY = ` $sortKey: SearchSortKeys $reverse: Boolean $productFilters: [ProductFilter!] - ) { + $country: CountryCode + $language: LanguageCode + ) @inContext(country: $country, language: $language) { search( query: $query first: $first @@ -40,6 +43,9 @@ export const SEARCH_PRODUCTS_QUERY = ` } edges { node { + # types: PRODUCT already narrows the results, but the schema still + # types nodes as a union — __typename lets callers narrow too. + __typename ... on Product { ...ProductFragment } @@ -47,16 +53,20 @@ export const SEARCH_PRODUCTS_QUERY = ` } } } -`; +`, + [ProductFragment] +); // Lightweight variant for the autocomplete dropdown — just enough to render a // row, so the dialog stays responsive while typing. -export const SEARCH_SUGGESTIONS_QUERY = ` - query SearchSuggestions($query: String!, $first: Int!) { +export const SEARCH_SUGGESTIONS_QUERY = gql(` + query SearchSuggestions($query: String!, $first: Int!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { search(query: $query, first: $first, types: PRODUCT) { totalCount edges { node { + __typename ... on Product { id title @@ -76,4 +86,4 @@ export const SEARCH_SUGGESTIONS_QUERY = ` } } } -`; +`); diff --git a/hooks/use-shopify-cart.ts b/hooks/use-shopify-cart.ts index e41c250..3498ddf 100644 --- a/hooks/use-shopify-cart.ts +++ b/hooks/use-shopify-cart.ts @@ -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( + 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 { 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 { diff --git a/hooks/use-shopify-policies.ts b/hooks/use-shopify-policies.ts index 4e51b82..c3c202f 100644 --- a/hooks/use-shopify-policies.ts +++ b/hooks/use-shopify-policies.ts @@ -1,4 +1,4 @@ -import { SHOPIFY_STOREFRONT_API_URL } from '@/services/shopify/client'; +import { shopifyFetch } from '@/services/shopify/client'; import { GET_SHOP_POLICIES_QUERY } from '@/graphql/policies'; export interface ShopPolicy { @@ -9,12 +9,6 @@ export interface ShopPolicy { url: string; } -interface ShopPoliciesResponse { - data?: { - shop?: Record; - }; -} - // Handles Shopify uses for each policy — also the routes under /policies/[handle]. export const POLICY_HANDLES = [ 'terms-of-service', @@ -24,32 +18,23 @@ export const POLICY_HANDLES = [ 'subscription-policy', ] as const; -// Policies change rarely, so this uses its own fetch (revalidated hourly) -// rather than the no-store `shopifyFetch` used for carts and products. +// Policies change rarely, so this reads through the cached client (revalidated +// hourly) rather than the no-store one used for carts and products. export async function getShopPolicies(): Promise { - const token = process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN; + try { + const { data } = await shopifyFetch({ + query: GET_SHOP_POLICIES_QUERY, + cache: true, + }); - const response = await fetch(SHOPIFY_STOREFRONT_API_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(token ? { 'X-Shopify-Storefront-Access-Token': token } : {}), - }, - body: JSON.stringify({ query: GET_SHOP_POLICIES_QUERY }), - next: { revalidate: 3600 }, - }); - - if (!response.ok) { - console.error('Failed to load shop policies:', response.status); + return Object.values(data?.shop ?? {}).filter( + (policy): policy is ShopPolicy => Boolean(policy?.handle) + ); + } catch (err) { + // A storefront without policies configured shouldn't break the footer. + console.error('Failed to load shop policies:', err); return []; } - - const json: ShopPoliciesResponse = await response.json(); - const shop = json.data?.shop ?? {}; - - return Object.values(shop).filter((policy): policy is ShopPolicy => - Boolean(policy?.handle) - ); } export async function getShopPolicy( diff --git a/package.json b/package.json index dda5677..37cbb99 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "next dev --webpack", "build": "next build --webpack", - "start": "next start" + "start": "next start", + "typecheck": "tsc --noEmit && hydrogen gql check" }, "version": "0.0.0", "dependencies": { @@ -15,6 +16,7 @@ "@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-use-controllable-state": "^1.2.6", "@remixicon/react": "^4.9.0", + "@shopify/hydrogen": "0.0.0-preview-116d5d7-20260730141607", "@shopify/storefront-api-client": "^1.0.0", "ai": "^7", "class-variance-authority": "0.7.1", @@ -52,5 +54,6 @@ "@types/react": "^19", "@types/react-dom": "^19", "typescript": "^5.7.3" - } + }, + "packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e" } diff --git a/services/shopify/catalog.ts b/services/shopify/catalog.ts index 9c8461e..edd3921 100644 --- a/services/shopify/catalog.ts +++ b/services/shopify/catalog.ts @@ -3,6 +3,7 @@ // These are plain async functions with no React imports, so they can be called // from Route Handlers (see app/api/chat/route.ts) as well as from the client // hooks in hooks/use-shopify-*.ts, which re-export them. +import type { StorefrontApi } from '@shopify/hydrogen'; import { shopifyFetch } from '@/services/shopify/client'; import { GET_PRODUCTS_QUERY, @@ -18,9 +19,11 @@ import { SEARCH_SUGGESTIONS_QUERY, } from '@/graphql/search'; +// Optional fields are `| null` rather than just optional: the Storefront API +// returns explicit nulls, and the typed `gql()` documents now surface that. interface ProductImage { url: string; - altText?: string; + altText?: string | null; } interface ProductPrice { @@ -37,7 +40,7 @@ interface ProductVariant { name: string; value: string; }>; - image?: ProductImage; + image?: ProductImage | null; } export interface ProductOptionValue { @@ -183,7 +186,7 @@ export async function getProductRecommendations(productId: string): Promise { - const filters = filterInputs.flatMap((input) => { - try { - return [JSON.parse(input)]; - } catch { - console.warn('Ignoring malformed product filter input:', input); - return []; - } - }); + const filters = parseFilterInputs(filterInputs); const response = await shopifyFetch({ query: GET_COLLECTION_PRODUCTS_QUERY, @@ -342,7 +338,7 @@ export interface SearchSuggestion { handle: string; featuredImage?: { url: string; - altText?: string; + altText?: string | null; } | null; priceRange: { minVariantPrice: { @@ -362,10 +358,18 @@ interface SearchProductsOptions { filterInputs?: string[]; } -function parseFilterInputs(inputs: string[]): unknown[] { +// The `ProductFilter` input shape, taken from the query that consumes it so it +// tracks the schema rather than being restated here. +type ProductFilterInput = NonNullable< + StorefrontApi.VariablesOf['filters'] +>[number]; + +// Facet `input` values are opaque JSON strings produced by Shopify and handed +// straight back as filter inputs, so they are parsed, not constructed. +function parseFilterInputs(inputs: string[]): ProductFilterInput[] { return inputs.flatMap((input) => { try { - return [JSON.parse(input)]; + return [JSON.parse(input) as ProductFilterInput]; } catch { console.warn('Ignoring malformed product filter input:', input); return []; @@ -398,7 +402,11 @@ export async function searchProducts({ const search = response.data.search; return { - products: search.edges.map((edge: { node: Product }) => edge.node), + products: search.edges + .map((edge) => edge.node) + .filter((node): node is Extract => + node.__typename === 'Product' + ), totalCount: search.totalCount ?? 0, filters: search.productFilters ?? [], hasNextPage: Boolean(search.pageInfo?.hasNextPage), @@ -418,7 +426,11 @@ export async function searchSuggestions( const search = response.data.search; return { - products: search.edges.map((edge: { node: SearchSuggestion }) => edge.node), + products: search.edges + .map((edge) => edge.node) + .filter((node): node is Extract => + node.__typename === 'Product' + ), totalCount: search.totalCount ?? 0, }; } diff --git a/services/shopify/client.js b/services/shopify/client.js deleted file mode 100644 index 318248e..0000000 --- a/services/shopify/client.js +++ /dev/null @@ -1,48 +0,0 @@ -// Shopify Storefront API Service -const SHOPIFY_STORE_DOMAIN = process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN; -const SHOPIFY_STOREFRONT_ACCESS_TOKEN = process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN; -const SHOPIFY_API_VERSION = process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION || '2025-07'; -const SHOPIFY_STOREFRONT_API_URL = `https://${SHOPIFY_STORE_DOMAIN}/api/${SHOPIFY_API_VERSION}/graphql.json`; - -// Shopify API request with optional access token -async function shopifyFetch({ query, variables = {} }) { - try { - const headers = { - 'Content-Type': 'application/json', - }; - - // Add access token if available - if (SHOPIFY_STOREFRONT_ACCESS_TOKEN) { - headers['X-Shopify-Storefront-Access-Token'] = SHOPIFY_STOREFRONT_ACCESS_TOKEN; - } - - const response = await fetch(SHOPIFY_STOREFRONT_API_URL, { - method: 'POST', - headers, - body: JSON.stringify({ - query, - variables, - }), - cache: 'no-store', // Ensure fresh data for cart operations - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`Shopify API HTTP error! Status: ${response.status}, Body: ${errorBody}`); - } - - const json = await response.json(); - - if (json.errors) { - console.error('Shopify API errors:', json.errors); - throw new Error(`Shopify GraphQL errors: ${JSON.stringify(json.errors)}`); - } - - return json; - } catch (error) { - console.error('Shopify fetch error:', error); - throw error; - } -} - -export { shopifyFetch, SHOPIFY_STORE_DOMAIN, SHOPIFY_STOREFRONT_API_URL }; diff --git a/services/shopify/client.ts b/services/shopify/client.ts new file mode 100644 index 0000000..900515a --- /dev/null +++ b/services/shopify/client.ts @@ -0,0 +1,108 @@ +// Shopify Storefront API access, backed by `@shopify/hydrogen` (preview build). +// +// The package supplies the transport (`createStorefrontClient`) and the typed +// query documents (`gql`, see graphql/*.ts). `shopifyFetch` keeps the call +// shape the rest of the app already uses — `{ query, variables }` in, `{ data }` +// out, throwing on GraphQL errors — but now infers `data` from the document. +import { + createShopifyRequestContext, + createStorefrontClient, + StorefrontApiError, + type AnyStorefrontQueryString, + type StorefrontApi, +} from '@shopify/hydrogen'; + +const SHOPIFY_STORE_DOMAIN = process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN; +const SHOPIFY_STOREFRONT_ACCESS_TOKEN = + process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN; +const SHOPIFY_API_VERSION = + process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION || '2025-07'; +const SHOPIFY_STOREFRONT_API_URL = `https://${SHOPIFY_STORE_DOMAIN}/api/${SHOPIFY_API_VERSION}/graphql.json`; + +// No incoming request and no buyer context: these clients are module-scoped and +// serve both server rendering and the browser-side hooks, so they must not close +// over per-request state. `$country`/`$language` are injected from this i18n. +const requestContext = createShopifyRequestContext({ + request: { headers: new Headers() }, + i18n: { country: 'US', language: 'EN' }, +}); + +// Hydrogen calls `fetch(url, init, cacheOptions)`; Next's caching hints ride +// along on `init`, which is how the two ways of caching get to coexist. +const fetchWith = (overrides: RequestInit): typeof globalThis.fetch => + ((url, init) => + globalThis.fetch(url, { ...init, ...overrides })) as typeof globalThis.fetch; + +const config = { + storeDomain: SHOPIFY_STORE_DOMAIN!, + apiVersion: SHOPIFY_API_VERSION, + // `mock.shop` and other tokenless storefronts leave this unset. + publicStorefrontToken: SHOPIFY_STOREFRONT_ACCESS_TOKEN, +}; + +/** + * Default client. Uncached, because carts and customer reads must never serve a + * stale response. + */ +export const storefront = createStorefrontClient({ + type: 'public', + requestContext, + config: { ...config, fetch: fetchWith({ cache: 'no-store' }) }, +}); + +/** + * Client for catalogue data that changes rarely (shop policies, and anything + * else safe to serve from Next's data cache for an hour). + */ +export const cachedStorefront = createStorefrontClient({ + type: 'public', + requestContext, + config: { ...config, fetch: fetchWith({ next: { revalidate: 3600 } }) }, +}); + +interface ShopifyFetchArgs { + query: TDoc; + variables?: StorefrontApi.VariablesOf; + /** Serve from Next's data cache instead of hitting Shopify every time. */ + cache?: boolean; +} + +/** + * Runs a `gql()` document and returns its data. + * + * Hydrogen returns GraphQL errors rather than throwing them (a 200 with partial + * data is still a valid response), so this re-raises them to preserve the + * fail-loudly behaviour the callers were written against. Transport failures + * already throw as `StorefrontApiError`. + */ +export async function shopifyFetch({ + query, + variables, + cache = false, +}: ShopifyFetchArgs): Promise<{ + data: StorefrontApi.ResultOf; +}> { + const client = cache ? cachedStorefront : storefront; + + try { + const { data, errors } = await client.graphql(query, { + variables, + } as never); + + if (errors?.length) { + console.error('Shopify API errors:', errors); + throw new Error(`Shopify GraphQL errors: ${JSON.stringify(errors)}`); + } + + return { data: data as StorefrontApi.ResultOf }; + } catch (error) { + if (error instanceof StorefrontApiError) { + console.error('Shopify fetch error:', error.toJSON()); + } else { + console.error('Shopify fetch error:', error); + } + throw error; + } +} + +export { SHOPIFY_STORE_DOMAIN, SHOPIFY_STOREFRONT_API_URL }; diff --git a/services/shopify/customer.ts b/services/shopify/customer.ts index 02d57d4..d12b7f4 100644 --- a/services/shopify/customer.ts +++ b/services/shopify/customer.ts @@ -18,7 +18,8 @@ import { export const CUSTOMER_TOKEN_COOKIE = 'customerAccessToken'; export interface CustomerUserError { - code?: string; + /** One of `CustomerErrorCode`; kept as a string so new codes don't break. */ + code?: string | null; field?: string[] | null; message: string; } @@ -57,7 +58,7 @@ export interface CustomerOrder { export interface Customer { id: string; - email: string; + email: string | null; firstName?: string | null; lastName?: string | null; phone?: string | null; @@ -84,6 +85,13 @@ const firstMessage = (errors: CustomerUserError[]) => export { firstMessage as customerErrorMessage }; +// Shopify returns a null mutation payload when the mutation could not run at +// all. That is not a success, so it surfaces as a generic error rather than an +// empty error list, which callers read as "it worked". +const MUTATION_FAILED: CustomerUserError[] = [ + { message: 'Something went wrong. Please try again.' }, +]; + export async function createCustomer(input: { email: string; password: string; @@ -96,7 +104,10 @@ export async function createCustomer(input: { variables: { input }, }); - return { errors: response.data.customerCreate.customerUserErrors ?? [] }; + const result = response.data.customerCreate; + if (!result) return { errors: MUTATION_FAILED }; + + return { errors: result.customerUserErrors ?? [] }; } export async function login( @@ -109,6 +120,7 @@ export async function login( }); const result = response.data.customerAccessTokenCreate; + if (!result) return { token: null, errors: MUTATION_FAILED }; return { token: result.customerAccessToken ?? null, @@ -152,6 +164,7 @@ export async function resetPassword( }); const result = response.data.customerReset; + if (!result) return { token: null, errors: MUTATION_FAILED }; return { token: result.customerAccessToken ?? null, @@ -170,6 +183,7 @@ export async function activateAccount( }); const result = response.data.customerActivate; + if (!result) return { token: null, errors: MUTATION_FAILED }; return { token: result.customerAccessToken ?? null, @@ -210,6 +224,7 @@ export async function updateCustomer( }); const result = response.data.customerUpdate; + if (!result) return { customer: null, errors: MUTATION_FAILED }; return { customer: result.customer ?? null, diff --git a/tsconfig.json b/tsconfig.json index e7ff3a2..baa5a72 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,9 @@ "plugins": [ { "name": "next" + }, + { + "name": "@shopify/hydrogen/ts-plugin" } ], "paths": { diff --git a/yarn.lock b/yarn.lock index 47fffcc..dc6e346 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,31 @@ __metadata: version: 10 cacheKey: 10c0 +"@0no-co/graphql.web@npm:^1.0.5, @0no-co/graphql.web@npm:^1.3.1": + version: 1.3.3 + resolution: "@0no-co/graphql.web@npm:1.3.3" + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + graphql: + optional: true + checksum: 10c0/7237b805118049e9023e5fa4fd71c9334e1f85df6f98933038cc1b919f388e814fd1beae9e41b6a7509643f6a208ece0422dbc5d8474d24bec957020c899fb93 + languageName: node + linkType: hard + +"@0no-co/graphqlsp@npm:^1.12.13": + version: 1.17.3 + resolution: "@0no-co/graphqlsp@npm:1.17.3" + dependencies: + "@gql.tada/internal": "npm:^1.2.0" + graphql: "npm:^15.5.0 || ^16.0.0 || ^17.0.0" + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.0.0 || ^6.0.0 + checksum: 10c0/fa0f2d74275827e1c8aee36e07aaa0d86138b9f450e3e06e51be05cd507a95c1f7e7f603eaf611f6defdb2634f19acd0c2be16c222eff4802c67916ca77d4d7d + languageName: node + linkType: hard + "@ai-sdk/gateway@npm:4.0.33": version: 4.0.33 resolution: "@ai-sdk/gateway@npm:4.0.33" @@ -144,6 +169,52 @@ __metadata: languageName: node linkType: hard +"@gql.tada/cli-utils@npm:1.7.3": + version: 1.7.3 + resolution: "@gql.tada/cli-utils@npm:1.7.3" + dependencies: + "@0no-co/graphqlsp": "npm:^1.12.13" + "@gql.tada/internal": "npm:1.0.9" + graphql: "npm:^15.5.0 || ^16.0.0 || ^17.0.0" + peerDependencies: + "@0no-co/graphqlsp": ^1.12.13 + "@gql.tada/svelte-support": 1.0.2 + "@gql.tada/vue-support": 1.0.2 + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + "@gql.tada/svelte-support": + optional: true + "@gql.tada/vue-support": + optional: true + checksum: 10c0/55405687375cbda9a9e963fc627939a55c64e81816c8d6433add3564f2fc2c0600ec60c98e01de77fa21511713daf9e84b644362d9ce571f0ea5c43851c68b6f + languageName: node + linkType: hard + +"@gql.tada/internal@npm:1.0.9": + version: 1.0.9 + resolution: "@gql.tada/internal@npm:1.0.9" + dependencies: + "@0no-co/graphql.web": "npm:^1.0.5" + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.0.0 || ^6.0.0 + checksum: 10c0/5599ab58bc5859bf9d38f13e4ca0aae564cf7e94fbfa9afdb9932d4719fb945cb19da917fe23ed0cf115550ce07d31bcac89c229385fe7077754b54c2bed16e9 + languageName: node + linkType: hard + +"@gql.tada/internal@npm:^1.2.0": + version: 1.2.2 + resolution: "@gql.tada/internal@npm:1.2.2" + dependencies: + "@0no-co/graphql.web": "npm:^1.3.1" + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + checksum: 10c0/2861061a64fa88dbd8bac84cff87bc33a30e9ed971cbee9f8d9a52ec27a17ffc64c389fb6a75f2231458524556829b81f4744b55ccb433e46d28e1066a5d4655 + languageName: node + linkType: hard + "@img/colour@npm:^1.0.0": version: 1.1.0 resolution: "@img/colour@npm:1.1.0" @@ -1817,6 +1888,25 @@ __metadata: languageName: node linkType: hard +"@shopify/hydrogen@npm:0.0.0-preview-116d5d7-20260730141607": + version: 0.0.0-preview-116d5d7-20260730141607 + resolution: "@shopify/hydrogen@npm:0.0.0-preview-116d5d7-20260730141607" + dependencies: + gql.tada: "npm:1.9.2" + peerDependencies: + react: ^18.0.0 || ^19.0.0 + vue: ^3.5.0 + peerDependenciesMeta: + react: + optional: true + vue: + optional: true + bin: + hydrogen: bin/hydrogen.mjs + checksum: 10c0/b12e230d174a910d8abb7ab18ecb99357dce82d2125054b30af6dcbecf280c6fea40c8ca5fd67d8931e1079fd73e587e3ea1484f50c294f4a79ad714c9664ecd + languageName: node + linkType: hard + "@shopify/storefront-api-client@npm:^1.0.0": version: 1.0.10 resolution: "@shopify/storefront-api-client@npm:1.0.10" @@ -2427,6 +2517,23 @@ __metadata: languageName: node linkType: hard +"gql.tada@npm:1.9.2": + version: 1.9.2 + resolution: "gql.tada@npm:1.9.2" + dependencies: + "@0no-co/graphql.web": "npm:^1.0.5" + "@0no-co/graphqlsp": "npm:^1.12.13" + "@gql.tada/cli-utils": "npm:1.7.3" + "@gql.tada/internal": "npm:1.0.9" + peerDependencies: + typescript: ^5.0.0 || ^6.0.0 + bin: + gql-tada: bin/cli.js + gql.tada: bin/cli.js + checksum: 10c0/e6995d5ddd41a0049f7e2494758d605567027c57b270e59c48355d7fbd0ca95db77db2385c14d2add1198ef59b91c1b5a7f9b70d9eac8668c2be534e55a56517 + languageName: node + linkType: hard + "graceful-fs@npm:^4.2.4": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -2434,6 +2541,13 @@ __metadata: languageName: node linkType: hard +"graphql@npm:^15.5.0 || ^16.0.0 || ^17.0.0": + version: 17.0.2 + resolution: "graphql@npm:17.0.2" + checksum: 10c0/766546216b891439ed09fd8584963df2013e26f2f79f096036eaaa2788c38964e049c8a142d68e3afdabdd8bbe70042639e83f0f4b82edbc7c6850421f072bb6 + languageName: node + linkType: hard + "hast-util-sanitize@npm:^5.0.0": version: 5.0.2 resolution: "hast-util-sanitize@npm:5.0.2" @@ -3371,6 +3485,7 @@ __metadata: "@radix-ui/react-slot": "npm:^1.3.3" "@radix-ui/react-use-controllable-state": "npm:^1.2.6" "@remixicon/react": "npm:^4.9.0" + "@shopify/hydrogen": "npm:0.0.0-preview-116d5d7-20260730141607" "@shopify/storefront-api-client": "npm:^1.0.0" "@tailwindcss/postcss": "npm:^4" "@types/hast": "npm:^3.0.5"