Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions src/mock/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,3 +722,96 @@ export const mockSimpleCardProduct: ShopifyProduct = {
}
]
}

export const mockFetchProduct1: ShopifyProduct = {
id: "gid://shopify/Product/1",
title: "Product 1",
vendor: "Vendor 1",
description: "Description 1",
encodedVariantExistence: "",
onlineStoreUrl: "/products/product-1",
availableForSale: true,
adjacentVariants: [],
images: [
{
altText: "Image 1",
height: 400,
width: 400,
thumbhash: null,
url: "https://example.com/image1.jpg"
}
],
featuredImage: {
altText: "Image 1",
height: 400,
width: 400,
thumbhash: null,
url: "https://example.com/image1.jpg"
},
options: [],
price: { currencyCode: "USD", amount: "10.00" },
compareAtPrice: null,
variants: []
}

export const mockFetchProduct2: ShopifyProduct = {
id: "gid://shopify/Product/2",
title: "Product 2",
vendor: "Vendor 2",
description: "Description 2",
encodedVariantExistence: "",
onlineStoreUrl: "/products/product-2",
availableForSale: true,
adjacentVariants: [],
images: [
{
altText: "Image 2",
height: 400,
width: 400,
thumbhash: null,
url: "https://example.com/image2.jpg"
}
],
featuredImage: {
altText: "Image 2",
height: 400,
width: 400,
thumbhash: null,
url: "https://example.com/image2.jpg"
},
options: [],
price: { currencyCode: "USD", amount: "20.00" },
compareAtPrice: null,
variants: []
}

export const mockFetchProduct3: ShopifyProduct = {
id: "gid://shopify/Product/3",
title: "Product 3",
vendor: "Vendor 3",
description: "Description 3",
encodedVariantExistence: "",
onlineStoreUrl: "/products/product-3",
availableForSale: true,
adjacentVariants: [],
images: [
{
altText: "Image 3",
height: 400,
width: 400,
thumbhash: null,
url: "https://example.com/image3.jpg"
}
],
featuredImage: {
altText: "Image 3",
height: 400,
width: 400,
thumbhash: null,
url: "https://example.com/image3.jpg"
},
options: [],
price: { currencyCode: "USD", amount: "30.00" },
compareAtPrice: null,
variants: []
}
118 changes: 116 additions & 2 deletions src/shopify/graphql/fetchProduct.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { flattenResponse } from "./utils"
import getProductsByHandles from "@/shopify/graphql/getProductsByHandles.graphql?raw"
import getProductByHandle from "@/shopify/graphql/getProductByHandle.graphql?raw"
import { getApiUrl } from "./constants"
import { cached } from "@/utils/cached"
import { ShopifyProduct } from "./types"

export const [fetchProduct, clearProductCache] = cached(async (handle: string) => {
type PendingRequest = {
handle: string
resolve: (product: ShopifyProduct) => void
reject: (error: Error) => void
}

const state = {
pendingRequests: new Map<string, PendingRequest[]>(),
scheduledFlush: null as number | null
}

async function fetchSingle(handle: string): Promise<ShopifyProduct> {
const response = await fetch(getApiUrl().href, {
headers: {
"Content-Type": "application/json"
Expand All @@ -18,9 +31,110 @@ export const [fetchProduct, clearProductCache] = cached(async (handle: string) =
}
})
})

if (!response.ok) {
throw new Error(`Failed to fetch product data: ${response.status} ${response.statusText}`)
}

const responseData = await response.json()

if (!responseData.data?.product) {
throw new Error(`Product not found: ${handle}`)
}

return flattenResponse(responseData)
})
}

async function fetchBatch(handles: string[], requestsMap: Map<string, PendingRequest[]>) {
const queryString = handles.map(h => `handle:"${h}"`).join(" OR ")

const response = await fetch(getApiUrl().href, {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify({
query: getProductsByHandles,
variables: {
language: window.Shopify?.locale?.toUpperCase() || "EN",
country: window.Shopify?.country || "US",
query: queryString,
first: handles.length
}
})
})

if (!response.ok) {
const error = new Error(`Failed to fetch product data: ${response.status} ${response.statusText}`)
Array.from(requestsMap.values())
.flatMap(requests => requests)
.forEach(request => request.reject(error))
return
}

const responseData = await response.json()

const productsByHandle = new Map<string, ShopifyProduct>()
if (responseData.data?.products?.nodes) {
responseData.data.products.nodes.forEach((productNode: ShopifyProduct & { handle: string }) => {
const product = flattenResponse({ data: { product: productNode } })
productsByHandle.set(productNode.handle, product)
})
}

for (const handle of handles) {
const requests = requestsMap.get(handle)
if (!requests) continue

const product = productsByHandle.get(handle)
if (product) {
requests.forEach(request => request.resolve(product))
} else {
const error = new Error(`Product not found: ${handle}`)
requests.forEach(request => request.reject(error))
}
}
}

async function flush() {
state.scheduledFlush = null

const handles = Array.from(state.pendingRequests.keys())
const requestsMap = new Map(state.pendingRequests)
state.pendingRequests.clear()

if (handles.length === 0) {
return
}

try {
if (handles.length === 1) {
const handle = handles[0]
const requests = requestsMap.get(handle)
if (requests) {
const product = await fetchSingle(handle)
requests.forEach(request => request.resolve(product))
}
} else {
await fetchBatch(handles, requestsMap)
}
} catch (error) {
Array.from(requestsMap.values())
.flatMap(requests => requests)
.forEach(request => request.reject(error instanceof Error ? error : new Error(String(error))))
}
}

function requestProduct(handle: string): Promise<ShopifyProduct> {
return new Promise((resolve, reject) => {
const requests = state.pendingRequests.get(handle) || []
requests.push({ handle, resolve, reject })
state.pendingRequests.set(handle, requests)

if (state.scheduledFlush === null) {
state.scheduledFlush = requestAnimationFrame(() => flush())
}
})
}

export const [fetchProduct, clearProductCache] = cached(requestProduct)
19 changes: 15 additions & 4 deletions src/shopify/graphql/getProductByHandle.graphql
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
query ProductByHandle($country: CountryCode!, $language: LanguageCode!, $handle: String!)
query ProductByHandle(
$country: CountryCode!
$language: LanguageCode!
$handle: String!
)
@inContext(country: $country, language: $language) {
product(handle: $handle) {
id
handle
title
vendor
description
Expand All @@ -11,14 +16,18 @@ query ProductByHandle($country: CountryCode!, $language: LanguageCode!, $handle:
images(first: 10) {
nodes {
url
width
altText
height
width
thumbhash
}
}
featuredImage {
url
width
altText
height
width
thumbhash
}
adjacentVariants {
...VariantFragment
Expand Down Expand Up @@ -52,8 +61,10 @@ fragment VariantFragment on ProductVariant {
id
image {
url
width
altText
height
width
thumbhash
}
price {
currencyCode
Expand Down
88 changes: 88 additions & 0 deletions src/shopify/graphql/getProductsByHandles.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
query ProductsByHandles(
$country: CountryCode!
$language: LanguageCode!
$query: String!
$first: Int!
)
@inContext(country: $country, language: $language) {
products(first: $first, query: $query) {
nodes {
id
handle
title
vendor
description
encodedVariantExistence
onlineStoreUrl
availableForSale
images(first: 10) {
nodes {
url
altText
height
width
thumbhash
}
}
featuredImage {
url
altText
height
width
thumbhash
}
adjacentVariants {
...VariantFragment
}
options(first: 50) {
name
optionValues {
firstSelectableVariant {
...VariantFragment
}
name
swatch {
color
image {
alt
id
mediaContentType
previewImage {
url
}
}
}
}
}
}
}
}

fragment VariantFragment on ProductVariant {
availableForSale
title
id
image {
url
altText
height
width
thumbhash
}
price {
currencyCode
amount
}
compareAtPrice {
currencyCode
amount
}
product {
id
onlineStoreUrl
}
selectedOptions {
name
value
}
}
Loading