diff --git a/.gitignore b/.gitignore index 2f82fbc..7c118a3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,4 @@ dist src/routeTree.gen.ts *.tsbuildinfo -# Temp tsconfig written by `hydrogen gql check`; normally cleaned up on exit -.hydrogen-gql-*.json .tanstack diff --git a/README.md b/README.md index f9d1f2d..2383a88 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # Vite Shopify Storefront A server-rendered Shopify storefront built with Vite, TanStack Start (TanStack -Router + TanStack Query), Hono and Tailwind CSS v4. `@shopify/hydrogen` is used -only as the Storefront API client and for typed `gql()` documents. It's a port -of `nextjs-templates/shopify-storefront` without the AI store assistant. +Router + TanStack Query), Hono and Tailwind CSS v4, talking to Shopify's +[Storefront API](https://shopify.dev/docs/api/storefront) directly over +GraphQL — no Shopify SDK. It's a port of `nextjs-templates/shopify-storefront` +without the AI store assistant. Runs in the nodesandbox runtime: `npm install` → `npm run dev` → port 3000. @@ -20,7 +21,7 @@ yarn dev # http://localhost:3000 | ---------------- | --------------------------------------------------------- | | `yarn dev` | Vite dev server with SSR and the Hono `/api` routes | | `yarn build` | Production build to `dist/` | -| `yarn typecheck` | `tsc` plus `hydrogen gql check` for the GraphQL documents | +| `yarn typecheck` | `tsc` | ## nodesandbox compatibility @@ -50,8 +51,8 @@ src/ account.ts login, logout, register, recover, reset, activate, me account.functions.ts server functions for account reads in loaders hooks/ TanStack Query options + hooks over the Storefront API - services/shopify Storefront client, catalogue and customer functions - graphql/ Typed `gql()` documents + services/shopify Storefront API client (plain fetch), catalogue and customer functions + graphql/ Storefront API queries and mutations (`gql()` joins fragments) components/ Storefront UI (header, cart drawer, product detail, ...) ``` diff --git a/package.json b/package.json index bf8a511..365aeb8 100644 --- a/package.json +++ b/package.json @@ -6,14 +6,13 @@ "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", "build": "vite build", - "typecheck": "tsc --noEmit && hydrogen gql check" + "typecheck": "tsc --noEmit" }, "packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e", "dependencies": { "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", "@remixicon/react": "^4.9.0", - "@shopify/hydrogen": "0.0.0-preview-116d5d7-20260730141607", "@tanstack/query-core": "^5.104.1", "@tanstack/react-query": "^5.104.1", "@tanstack/react-router": "^1.170.41", @@ -23,7 +22,6 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "framer-motion": "^12.42.2", - "graphql": "^17.0.2", "hono": "^4.13.12", "lucide-react": "^0.562.0", "radix-ui": "^1.6.7", diff --git a/src/components/shopify/products.tsx b/src/components/shopify/products.tsx index d26259c..e90fe8c 100644 --- a/src/components/shopify/products.tsx +++ b/src/components/shopify/products.tsx @@ -13,8 +13,8 @@ interface ProductsProps { } const Products: React.FC = ({ - title = 'Shopify Hydrogen Storefront', - subtitle = 'A Shopify storefront built with Vite, TanStack and Hydrogen.', + title = 'Shopify Storefront', + subtitle = 'A Shopify storefront built with Vite, TanStack and the Storefront API.', limit = 12, showLoadMore = true, }) => { diff --git a/src/config/site.ts b/src/config/site.ts index d915c59..e349d42 100644 --- a/src/config/site.ts +++ b/src/config/site.ts @@ -1,7 +1,8 @@ /** Chrome shared by every route — rendered once in the root layout. */ export const site = { storeName: 'Shop', - description: 'A Shopify storefront built with Vite, TanStack and Hydrogen.', + description: + 'A Shopify storefront built with Vite, TanStack and the Storefront API.', // Absolute origin for canonical and Open Graph URLs (set in vite.config.ts). url: import.meta.env.VITE_SITE_URL ?? 'http://localhost:3000', logoUrl: '', diff --git a/src/graphql/cart.ts b/src/graphql/cart.ts index d95959d..76372d5 100644 --- a/src/graphql/cart.ts +++ b/src/graphql/cart.ts @@ -1,4 +1,4 @@ -import { gql } from '@shopify/hydrogen'; +import { gql } from '@/graphql/gql'; // Cart Fragment for consistent cart data const CartFragment = gql(` diff --git a/src/graphql/collections.ts b/src/graphql/collections.ts index 6523afc..5bee7ef 100644 --- a/src/graphql/collections.ts +++ b/src/graphql/collections.ts @@ -1,4 +1,4 @@ -import { gql } from '@shopify/hydrogen'; +import { gql } from '@/graphql/gql'; import { ProductFragment } from '@/graphql/products'; // Get all collections diff --git a/src/graphql/customer.ts b/src/graphql/customer.ts index f592b25..4a04382 100644 --- a/src/graphql/customer.ts +++ b/src/graphql/customer.ts @@ -1,6 +1,6 @@ // Customer account operations (classic Storefront customer accounts). // https://shopify.dev/docs/api/storefront/latest/objects/Customer -import { gql } from '@shopify/hydrogen'; +import { gql } from '@/graphql/gql'; const CustomerFragment = gql(` fragment CustomerFragment on Customer { diff --git a/src/graphql/gql.ts b/src/graphql/gql.ts new file mode 100644 index 0000000..6702e61 --- /dev/null +++ b/src/graphql/gql.ts @@ -0,0 +1,27 @@ +/** + * Builds a GraphQL document string: the operation or fragment source, followed + * by any fragments it spreads. Fragments that themselves include fragments are + * flattened and de-duplicated, so each definition appears once — the + * Storefront API rejects a document that defines the same fragment twice. + */ +export function gql(source: string, fragments: readonly string[] = []): string { + const definitions: string[] = []; + const seen = new Set(); + + const add = (text: string) => { + // A composed fragment is "its own source + its fragments", joined by blank + // lines; split it back into definitions so shared ones de-duplicate. + for (const part of text.split(/\n(?=\s*fragment\s)/)) { + const definition = part.trim(); + if (definition && !seen.has(definition)) { + seen.add(definition); + definitions.push(definition); + } + } + }; + + add(source); + for (const fragment of fragments) add(fragment); + + return definitions.join('\n\n'); +} diff --git a/src/graphql/policies.ts b/src/graphql/policies.ts index c2b710b..b509eb8 100644 --- a/src/graphql/policies.ts +++ b/src/graphql/policies.ts @@ -1,4 +1,4 @@ -import { gql } from '@shopify/hydrogen'; +import { gql } from '@/graphql/gql'; // 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. diff --git a/src/graphql/products.ts b/src/graphql/products.ts index 7423c05..b6d4611 100644 --- a/src/graphql/products.ts +++ b/src/graphql/products.ts @@ -1,4 +1,4 @@ -import { gql } from '@shopify/hydrogen'; +import { gql } from '@/graphql/gql'; // Product Fragment for consistent product data export const ProductFragment = gql(` diff --git a/src/graphql/search.ts b/src/graphql/search.ts index f247b2d..313ff0c 100644 --- a/src/graphql/search.ts +++ b/src/graphql/search.ts @@ -1,4 +1,4 @@ -import { gql } from '@shopify/hydrogen'; +import { gql } from '@/graphql/gql'; import { ProductFragment } from '@/graphql/products'; // Storefront search over products. `productFilters` accepts the raw `input` diff --git a/src/hooks/use-shopify-cart.ts b/src/hooks/use-shopify-cart.ts index c5e2091..00ce72a 100644 --- a/src/hooks/use-shopify-cart.ts +++ b/src/hooks/use-shopify-cart.ts @@ -89,6 +89,12 @@ export interface Cart { }; } +// Every cart mutation selects this payload; both it and its cart are nullable. +type CartPayload = { + cart: Cart | null; + userErrors: Array<{ field?: string[] | null; message: string }>; +} | null; + // ─── Shopify API functions ─────────────────────────────────────────── /** @@ -118,9 +124,12 @@ function unwrapCartPayload( async function createCartApi(lines: CartLineInput[] = []): Promise { const data = unwrapStorefrontResult( - await storefront.graphql(CREATE_CART_MUTATION, { - variables: { lines: lines.length > 0 ? lines : null }, - }), + await storefront.graphql<{ cartCreate: CartPayload }>( + CREATE_CART_MUTATION, + { + variables: { lines: lines.length > 0 ? lines : null }, + } + ), 'CreateCart' ); @@ -132,9 +141,12 @@ async function addCartLinesApi( lines: CartLineInput[] ): Promise { const data = unwrapStorefrontResult( - await storefront.graphql(ADD_CART_LINES_MUTATION, { - variables: { cartId, lines }, - }), + await storefront.graphql<{ cartLinesAdd: CartPayload }>( + ADD_CART_LINES_MUTATION, + { + variables: { cartId, lines }, + } + ), 'AddCartLines' ); @@ -146,9 +158,12 @@ async function updateCartLinesApi( lines: CartLineUpdateInput[] ): Promise { const data = unwrapStorefrontResult( - await storefront.graphql(UPDATE_CART_LINES_MUTATION, { - variables: { cartId, lines }, - }), + await storefront.graphql<{ cartLinesUpdate: CartPayload }>( + UPDATE_CART_LINES_MUTATION, + { + variables: { cartId, lines }, + } + ), 'UpdateCartLines' ); @@ -160,9 +175,12 @@ async function removeCartLinesApi( lineIds: string[] ): Promise { const data = unwrapStorefrontResult( - await storefront.graphql(REMOVE_CART_LINES_MUTATION, { - variables: { cartId, lineIds }, - }), + await storefront.graphql<{ cartLinesRemove: CartPayload }>( + REMOVE_CART_LINES_MUTATION, + { + variables: { cartId, lineIds }, + } + ), 'RemoveCartLines' ); @@ -174,9 +192,12 @@ async function updateCartDiscountCodesApi( discountCodes: string[] ): Promise { const data = unwrapStorefrontResult( - await storefront.graphql(UPDATE_CART_DISCOUNT_CODES_MUTATION, { - variables: { cartId, discountCodes }, - }), + await storefront.graphql<{ cartDiscountCodesUpdate: CartPayload }>( + UPDATE_CART_DISCOUNT_CODES_MUTATION, + { + variables: { cartId, discountCodes }, + } + ), 'UpdateCartDiscountCodes' ); @@ -185,7 +206,9 @@ async function updateCartDiscountCodesApi( async function getCartApi(cartId: string): Promise { const data = unwrapStorefrontResult( - await storefront.graphql(GET_CART_QUERY, { variables: { cartId } }), + await storefront.graphql<{ cart: Cart | null }>(GET_CART_QUERY, { + variables: { cartId }, + }), 'GetCart' ); diff --git a/src/hooks/use-shopify-policies.ts b/src/hooks/use-shopify-policies.ts index 20b3cb5..b4704f8 100644 --- a/src/hooks/use-shopify-policies.ts +++ b/src/hooks/use-shopify-policies.ts @@ -22,7 +22,9 @@ export const POLICY_HANDLES = [ export async function getShopPolicies(): Promise { try { const data = unwrapStorefrontResult( - await storefront.graphql(GET_SHOP_POLICIES_QUERY), + await storefront.graphql<{ + shop: Record | null; + }>(GET_SHOP_POLICIES_QUERY), 'GetShopPolicies' ); diff --git a/src/routes/index.tsx b/src/routes/index.tsx index bd7055c..e437a21 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -15,8 +15,8 @@ export const Route = createFileRoute('/')({ head: () => seo({ title: site.storeName, absolute: true, path: '/' }), component: () => ( ), }); diff --git a/src/services/shopify/catalog.ts b/src/services/shopify/catalog.ts index f777830..99da6c6 100644 --- a/src/services/shopify/catalog.ts +++ b/src/services/shopify/catalog.ts @@ -3,7 +3,6 @@ // These are plain async functions with no React imports, so they can be called // from the Hono server as well as from the TanStack Query options in // hooks/use-shopify-*.ts, which re-export them. -import type { StorefrontApi } from '@shopify/hydrogen'; import { storefront, unwrapStorefrontResult } from '@/services/shopify/client'; import { GET_PRODUCTS_QUERY, @@ -19,6 +18,19 @@ import { SEARCH_SUGGESTIONS_QUERY, } from '@/graphql/search'; +// Storefront API connection shape (`edges { node }` + `pageInfo`). +interface Connection { + edges: Array<{ node: T }>; + pageInfo?: { hasNextPage: boolean; endCursor: string | null } | null; +} + +// `search` returns a union; `types: PRODUCT` narrows it, `__typename` proves it. +type SearchNode = (T & { __typename: 'Product' }) | { __typename: string }; + +const isProductNode = ( + node: SearchNode +): node is T & { __typename: 'Product' } => node.__typename === 'Product'; + // Optional fields are `| null` rather than just optional: the Storefront API // returns explicit nulls, and the typed `gql()` documents now surface that. interface ProductImage { @@ -150,9 +162,10 @@ export async function getProductsPage({ reverse = false, }: UseProductsOptions = {}): Promise { const data = unwrapStorefrontResult( - await storefront.graphql(GET_PRODUCTS_QUERY, { - variables: { first, after, query, sortKey, reverse }, - }), + await storefront.graphql<{ products: Connection }>( + GET_PRODUCTS_QUERY, + { variables: { first, after, query, sortKey, reverse } } + ), 'GetProducts' ); @@ -168,7 +181,9 @@ export async function getProductsPage({ // Fetch a single product by handle export async function getProduct(handle: string): Promise { const data = unwrapStorefrontResult( - await storefront.graphql(GET_PRODUCT_QUERY, { variables: { handle } }), + await storefront.graphql<{ product: Product | null }>(GET_PRODUCT_QUERY, { + variables: { handle }, + }), 'GetProduct' ); @@ -178,9 +193,10 @@ export async function getProduct(handle: string): Promise { // Fetch product recommendations export async function getProductRecommendations(productId: string): Promise { const data = unwrapStorefrontResult( - await storefront.graphql(QUERY_PRODUCT_RECOMMENDATIONS, { - variables: { productId }, - }), + await storefront.graphql<{ productRecommendations: Product[] | null }>( + QUERY_PRODUCT_RECOMMENDATIONS, + { variables: { productId } } + ), 'GetProductRecommendations' ); @@ -245,7 +261,10 @@ export interface ProductFilterFacet { // Fetch all collections export async function getCollections(first = 50): Promise { const data = unwrapStorefrontResult( - await storefront.graphql(GET_COLLECTIONS_QUERY, { variables: { first } }), + await storefront.graphql<{ collections: Connection }>( + GET_COLLECTIONS_QUERY, + { variables: { first } } + ), 'GetCollections' ); @@ -277,7 +296,13 @@ export async function getCollectionProductsPage( const filters = parseFilterInputs(filterInputs); const data = unwrapStorefrontResult( - await storefront.graphql(GET_COLLECTION_PRODUCTS_QUERY, { + await storefront.graphql<{ + collection: + | (Collection & { + products: Connection & { filters: ProductFilterFacet[] }; + }) + | null; + }>(GET_COLLECTION_PRODUCTS_QUERY, { variables: { handle, first, @@ -364,11 +389,10 @@ interface SearchProductsOptions { filterInputs?: string[]; } -// 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]; +// The Storefront API's `ProductFilter` input. Facet `input` strings decode to +// one of these, so only the shape matters here. +// https://shopify.dev/docs/api/storefront/latest/input-objects/ProductFilter +type ProductFilterInput = Record; // Facet `input` values are opaque JSON strings produced by Shopify and handed // straight back as filter inputs, so they are parsed, not constructed. @@ -392,7 +416,12 @@ export async function searchProducts({ filterInputs = [], }: SearchProductsOptions): Promise { const data = unwrapStorefrontResult( - await storefront.graphql(SEARCH_PRODUCTS_QUERY, { + await storefront.graphql<{ + search: Connection> & { + totalCount: number; + productFilters: SearchFilter[]; + }; + }>(SEARCH_PRODUCTS_QUERY, { variables: { query, first, @@ -412,9 +441,7 @@ export async function searchProducts({ return { products: search.edges .map((edge) => edge.node) - .filter((node): node is Extract => - node.__typename === 'Product' - ), + .filter(isProductNode), totalCount: search.totalCount ?? 0, filters: search.productFilters ?? [], hasNextPage: Boolean(search.pageInfo?.hasNextPage), @@ -427,7 +454,9 @@ export async function searchSuggestions( first = 3 ): Promise<{ products: SearchSuggestion[]; totalCount: number }> { const data = unwrapStorefrontResult( - await storefront.graphql(SEARCH_SUGGESTIONS_QUERY, { + await storefront.graphql<{ + search: Connection> & { totalCount: number }; + }>(SEARCH_SUGGESTIONS_QUERY, { variables: { query, first }, }), 'SearchSuggestions' @@ -438,9 +467,7 @@ export async function searchSuggestions( return { products: search.edges .map((edge) => edge.node) - .filter((node): node is Extract => - node.__typename === 'Product' - ), + .filter(isProductNode), totalCount: search.totalCount ?? 0, }; } diff --git a/src/services/shopify/client.ts b/src/services/shopify/client.ts index b9c9da1..56d825b 100644 --- a/src/services/shopify/client.ts +++ b/src/services/shopify/client.ts @@ -1,80 +1,129 @@ -// Storefront API clients, built on `@shopify/hydrogen`. +// Storefront API client: plain `fetch` against the GraphQL endpoint, no SDK. +// https://shopify.dev/docs/api/storefront // -// Call sites use the package's own API — `storefront.graphql(DOCUMENT, { -// variables })` — and pass the result through `unwrapStorefrontResult`, which -// applies this app's error policy: fail loudly. Hydrogen deliberately does not -// do that itself, because a 200 carrying partial data and GraphQL errors is a -// valid response that some callers want to render. -import { - createShopifyRequestContext, - createStorefrontClient, - type GraphQLFormattedError, -} from '@shopify/hydrogen'; +// Call sites use `storefront.graphql(QUERY, { variables })` and pass the +// result through `unwrapStorefrontResult`, which applies this app's error +// policy: fail loudly. `graphql()` itself returns `{ data, errors }` like the +// API does, because a 200 can carry partial data alongside GraphQL errors. import { SHOPIFY_API_VERSION, SHOPIFY_PUBLIC_ACCESS_TOKEN, SHOPIFY_STORE_DOMAIN, } from '@/services/shopify/config'; -// No incoming request and no buyer context: this client is module-scoped and -// serves both the Hono API and the browser-side hooks, so it 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' }, -}); +export interface StorefrontGraphQLError { + message: string; + path?: ReadonlyArray; + extensions?: Record; +} -/** - * Workaround for a bug in this preview build of `@shopify/hydrogen`. - * - * The client tags every request with `X-Hydrogen-Version`, but the Storefront - * API does not list that header in its CORS `access-control-allow-headers`. - * Browsers therefore reject the preflight and `fetch` throws, which hydrogen - * reports as the generic "SFAPI request failed". It only bites against real - * stores — `mock.shop` answers `access-control-allow-headers: *`. - * - * Stripped in the browser only: server-side requests are not subject to CORS, - * so they keep sending the header. Remove this once the API allows it (or once - * these queries move server-side, which is the better long-term fix). - */ -const CORS_BLOCKED_HEADERS = ['X-Hydrogen-Version']; +export interface StorefrontResult { + data: TData | null; + errors?: StorefrontGraphQLError[]; +} -const storefrontFetch = ((url, init) => { - const headers = new Headers(init?.headers); +/** Transport failures: non-2xx responses and bodies that aren't GraphQL JSON. */ +export class StorefrontApiError extends Error { + constructor( + message: string, + readonly status?: number + ) { + super(message); + this.name = 'StorefrontApiError'; + } +} - if (typeof document !== 'undefined') { - for (const header of CORS_BLOCKED_HEADERS) headers.delete(header); +// Buyer context for `@inContext(country: $country, language: $language)`. +// The client is module-scoped and serves both the Hono API and the browser, so +// it carries no per-request state; queries that declare these variables get +// them filled in unless the caller passes its own. +const DEFAULT_CONTEXT = { country: 'US', language: 'EN' } as const; + +const STORE_URL = /^https?:\/\//.test(SHOPIFY_STORE_DOMAIN) + ? SHOPIFY_STORE_DOMAIN.replace(/\/+$/, '') + : `https://${SHOPIFY_STORE_DOMAIN}`; + +export const STOREFRONT_API_URL = `${STORE_URL}/api/${SHOPIFY_API_VERSION}/graphql.json`; + +function withContextVariables( + query: string, + variables: Record = {} +): Record { + const merged = { ...variables }; + for (const [name, value] of Object.entries(DEFAULT_CONTEXT)) { + if (merged[name] === undefined && query.includes(`$${name}`)) { + merged[name] = value; + } + } + return merged; +} + +async function graphql( + query: string, + options: { variables?: Record } = {} +): Promise> { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + // Public token only; mock.shop needs none. Never send a private token here. + if (SHOPIFY_PUBLIC_ACCESS_TOKEN) { + headers['X-Shopify-Storefront-Access-Token'] = SHOPIFY_PUBLIC_ACCESS_TOKEN; } - return globalThis.fetch(url, { ...init, cache: 'no-store', headers }); -}) as typeof globalThis.fetch; + const response = await fetch(STOREFRONT_API_URL, { + method: 'POST', + headers, + body: JSON.stringify({ + query, + variables: withContextVariables(query, options.variables), + }), + // Carts and customer reads must never be served stale; catalogue caching + // happens in TanStack Query instead (see the `staleTime`s). + cache: 'no-store', + }); -/** - * The one Storefront client. Uncached at the HTTP layer, because carts and - * customer reads must never serve a stale response — response caching for - * catalogue data happens in TanStack Query instead (see `staleTime`s). - */ -export const storefront = createStorefrontClient({ - type: 'public', - requestContext, - config: { - storeDomain: SHOPIFY_STORE_DOMAIN, - apiVersion: SHOPIFY_API_VERSION, - publicStorefrontToken: SHOPIFY_PUBLIC_ACCESS_TOKEN, - fetch: storefrontFetch, - }, -}); + let body: unknown; + try { + body = await response.json(); + } catch { + throw new StorefrontApiError( + `Storefront API returned a non-JSON response (${response.status}).`, + response.status + ); + } + + // The API answers GraphQL-level problems with 200 + `errors`; anything else + // that lacks a GraphQL body is a transport failure. + const result = body as Partial> | null; + if (!result || (!('data' in result) && !('errors' in result))) { + throw new StorefrontApiError( + `Storefront API request failed (${response.status}).`, + response.status + ); + } + if (!response.ok && !result.errors?.length) { + throw new StorefrontApiError( + `Storefront API request failed (${response.status}).`, + response.status + ); + } + + return { data: result.data ?? null, errors: result.errors }; +} + +/** The one Storefront client. */ +export const storefront = { graphql }; /** * Returns the data from a `graphql()` result, throwing if Shopify reported any - * GraphQL errors. Transport failures — non-200, timeouts, unparseable bodies — - * have already thrown as `StorefrontApiError` by this point. + * GraphQL errors. Transport failures have already thrown `StorefrontApiError`. * * `operation` names the query in the log and the thrown message, so a failure * points at the call site rather than just at "Shopify". */ export function unwrapStorefrontResult( - result: { data: TData | null; errors?: GraphQLFormattedError[] }, + result: StorefrontResult, operation: string ): TData { if (result.errors?.length) { diff --git a/src/services/shopify/customer.ts b/src/services/shopify/customer.ts index 53589f1..a0cd90f 100644 --- a/src/services/shopify/customer.ts +++ b/src/services/shopify/customer.ts @@ -80,6 +80,15 @@ export interface AuthResult { errors: CustomerUserError[]; } +// Payload shapes the customer mutations select. Shopify returns a null payload +// when a mutation could not run at all. +interface UserErrorsPayload { + customerUserErrors: CustomerUserError[] | null; +} +type TokenPayload = UserErrorsPayload & { + customerAccessToken: AccessToken | null; +}; + const firstMessage = (errors: CustomerUserError[]) => errors[0]?.message ?? 'Something went wrong. Please try again.'; @@ -100,9 +109,10 @@ export async function createCustomer(input: { acceptsMarketing?: boolean; }): Promise<{ errors: CustomerUserError[] }> { const data = unwrapStorefrontResult( - await storefront.graphql(CUSTOMER_CREATE_MUTATION, { - variables: { input }, - }), + await storefront.graphql<{ customerCreate: UserErrorsPayload | null }>( + CUSTOMER_CREATE_MUTATION, + { variables: { input } } + ), 'CustomerCreate' ); @@ -117,9 +127,10 @@ export async function login( password: string ): Promise { const data = unwrapStorefrontResult( - await storefront.graphql(CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION, { - variables: { input: { email, password } }, - }), + await storefront.graphql<{ customerAccessTokenCreate: TokenPayload | null }>( + CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION, + { variables: { input: { email, password } } } + ), 'CustomerAccessTokenCreate' ); @@ -161,9 +172,10 @@ export async function resetPassword( password: string ): Promise { const data = unwrapStorefrontResult( - await storefront.graphql(CUSTOMER_RESET_MUTATION, { - variables: { id, input: { resetToken, password } }, - }), + await storefront.graphql<{ customerReset: TokenPayload | null }>( + CUSTOMER_RESET_MUTATION, + { variables: { id, input: { resetToken, password } } } + ), 'CustomerReset' ); @@ -182,9 +194,10 @@ export async function activateAccount( password: string ): Promise { const data = unwrapStorefrontResult( - await storefront.graphql(CUSTOMER_ACTIVATE_MUTATION, { - variables: { id, input: { activationToken, password } }, - }), + await storefront.graphql<{ customerActivate: TokenPayload | null }>( + CUSTOMER_ACTIVATE_MUTATION, + { variables: { id, input: { activationToken, password } } } + ), 'CustomerActivate' ); @@ -203,7 +216,7 @@ export async function getCustomer( ): Promise { try { const data = unwrapStorefrontResult( - await storefront.graphql(CUSTOMER_QUERY, { + await storefront.graphql<{ customer: Customer | null }>(CUSTOMER_QUERY, { variables: { customerAccessToken: accessToken, orderCount }, }), 'GetCustomer' @@ -227,7 +240,9 @@ export async function updateCustomer( } ): Promise<{ customer: Customer | null; errors: CustomerUserError[] }> { const data = unwrapStorefrontResult( - await storefront.graphql(CUSTOMER_UPDATE_MUTATION, { + await storefront.graphql<{ + customerUpdate: (UserErrorsPayload & { customer: Customer | null }) | null; + }>(CUSTOMER_UPDATE_MUTATION, { variables: { customerAccessToken: accessToken, customer }, }), 'CustomerUpdate' diff --git a/tsconfig.json b/tsconfig.json index 181d4db..7947ab2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,6 @@ "isolatedModules": true, "jsx": "react-jsx", "types": ["vite/client", "node"], - "plugins": [{ "name": "@shopify/hydrogen/ts-plugin" }], "paths": { "@/*": ["./src/*"] } diff --git a/yarn.lock b/yarn.lock index c5f1fd9..ef3de1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,31 +5,6 @@ __metadata: version: 10 cacheKey: 10c0 -"@0no-co/graphql.web@npm:^1.0.5, @0no-co/graphql.web@npm:^1.3.1": - version: 1.3.4 - resolution: "@0no-co/graphql.web@npm:1.3.4" - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - peerDependenciesMeta: - graphql: - optional: true - checksum: 10c0/58c61e16664121bd0974609890bf68135469fa1af23c70c3966cf50f0215efac7062a4ff2ded60faaf7575e420b73c0800467e46fc0440942d48490d2f6c13bd - languageName: node - linkType: hard - -"@0no-co/graphqlsp@npm:^1.12.13": - version: 1.17.5 - resolution: "@0no-co/graphqlsp@npm:1.17.5" - dependencies: - "@gql.tada/internal": "npm:^1.2.1" - 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/6b40d666ce9b237d0aebb13d17770c009e4416e8955c6636acaef49174b1bde5d612c7a6d7656a3053518cbbfbb85f9d039df78b595233c365878d2091987ed2 - languageName: node - linkType: hard - "@babel/code-frame@npm:7.29.7, @babel/code-frame@npm:^7.29.7": version: 7.29.7 resolution: "@babel/code-frame@npm:7.29.7" @@ -313,52 +288,6 @@ __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.1": - 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 - "@isaacs/fs-minipass@npm:^4.0.0": version: 4.0.1 resolution: "@isaacs/fs-minipass@npm:4.0.1" @@ -2010,25 +1939,6 @@ __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 - "@tailwindcss/node@npm:4.3.1": version: 4.3.1 resolution: "@tailwindcss/node@npm:4.3.1" @@ -2876,23 +2786,6 @@ __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, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -2900,13 +2793,6 @@ __metadata: languageName: node linkType: hard -"graphql@npm:^15.5.0 || ^16.0.0 || ^17.0.0, graphql@npm:^17.0.2": - version: 17.0.2 - resolution: "graphql@npm:17.0.2" - checksum: 10c0/766546216b891439ed09fd8584963df2013e26f2f79f096036eaaa2788c38964e049c8a142d68e3afdabdd8bbe70042639e83f0f4b82edbc7c6850421f072bb6 - languageName: node - linkType: hard - "h3-v2@npm:h3@2.0.1-rc.20": version: 2.0.1-rc.20 resolution: "h3@npm:2.0.1-rc.20" @@ -3923,7 +3809,6 @@ __metadata: "@fontsource-variable/geist": "npm:^5.3.0" "@fontsource-variable/geist-mono": "npm:^5.3.0" "@remixicon/react": "npm:^4.9.0" - "@shopify/hydrogen": "npm:0.0.0-preview-116d5d7-20260730141607" "@tailwindcss/vite": "npm:4.3.1" "@tanstack/query-core": "npm:^5.104.1" "@tanstack/react-query": "npm:^5.104.1" @@ -3938,7 +3823,6 @@ __metadata: clsx: "npm:^2.1.1" cmdk: "npm:^1.1.1" framer-motion: "npm:^12.42.2" - graphql: "npm:^17.0.2" hono: "npm:^4.13.12" lucide-react: "npm:^0.562.0" radix-ui: "npm:^1.6.7"