From ad785c9f9a7ad0289b366e5c0b93f285e79264b6 Mon Sep 17 00:00:00 2001 From: karl Date: Wed, 26 Mar 2025 16:02:22 -0400 Subject: [PATCH] d --- lib/constants.ts | 9 +- lib/shopify/index.ts | 376 +++++++++++++++++++++---------------------- 2 files changed, 195 insertions(+), 190 deletions(-) diff --git a/lib/constants.ts b/lib/constants.ts index d62a1116a..e2faab95b 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -48,4 +48,11 @@ export const TAGS = { export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden' export const DEFAULT_OPTION = 'Default Title' -export const SHOPIFY_GRAPHQL_API_ENDPOINT = `https://${process.env.SHOPIFY_STORE_DOMAIN}/api/2023-01/graphql.json` + +export function getShopifyGraphqlEndpoint() { + const storeDomain = process.env.SHOPIFY_STORE_DOMAIN + if (!storeDomain) { + throw new Error('SHOPIFY_STORE_DOMAIN environment variable is not set') + } + return `https://${storeDomain}/api/2023-01/graphql.json` +} diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts index b90893172..ccce06a1f 100644 --- a/lib/shopify/index.ts +++ b/lib/shopify/index.ts @@ -1,36 +1,36 @@ import { HIDDEN_PRODUCT_TAG, - SHOPIFY_GRAPHQL_API_ENDPOINT, - TAGS -} from 'lib/constants'; -import { isShopifyError } from 'lib/type-guards'; -import { ensureStartsWith } from 'lib/utils'; + getShopifyGraphqlEndpoint, + TAGS, +} from 'lib/constants' +import { isShopifyError } from 'lib/type-guards' +import { ensureStartsWith } from 'lib/utils' import { revalidateTag, unstable_cacheTag as cacheTag, - unstable_cacheLife as cacheLife -} from 'next/cache'; -import { cookies, headers } from 'next/headers'; -import { NextRequest, NextResponse } from 'next/server'; + unstable_cacheLife as cacheLife, +} from 'next/cache' +import { cookies, headers } from 'next/headers' +import { NextRequest, NextResponse } from 'next/server' import { addToCartMutation, createCartMutation, editCartItemsMutation, - removeFromCartMutation -} from './mutations/cart'; -import { getCartQuery } from './queries/cart'; + removeFromCartMutation, +} from './mutations/cart' +import { getCartQuery } from './queries/cart' import { getCollectionProductsQuery, getCollectionQuery, - getCollectionsQuery -} from './queries/collection'; -import { getMenuQuery } from './queries/menu'; -import { getPageQuery, getPagesQuery } from './queries/page'; + getCollectionsQuery, +} from './queries/collection' +import { getMenuQuery } from './queries/menu' +import { getPageQuery, getPagesQuery } from './queries/page' import { getProductQuery, getProductRecommendationsQuery, - getProductsQuery -} from './queries/product'; + getProductsQuery, +} from './queries/product' import { Cart, Collection, @@ -55,292 +55,290 @@ import { ShopifyProductRecommendationsOperation, ShopifyProductsOperation, ShopifyRemoveFromCartOperation, - ShopifyUpdateCartOperation -} from './types'; + ShopifyUpdateCartOperation, +} from './types' const domain = process.env.SHOPIFY_STORE_DOMAIN ? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://') - : ''; -const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`; -const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!; + : '' +const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN! type ExtractVariables = T extends { variables: object } ? T['variables'] - : never; + : never export async function shopifyFetch({ headers, query, - variables + variables, }: { - headers?: HeadersInit; - query: string; - variables?: ExtractVariables; + headers?: HeadersInit + query: string + variables?: ExtractVariables }): Promise<{ status: number; body: T } | never> { try { + const endpoint = getShopifyGraphqlEndpoint() const result = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Shopify-Storefront-Access-Token': key, - ...headers + ...headers, }, body: JSON.stringify({ ...(query && { query }), - ...(variables && { variables }) - }) - }); + ...(variables && { variables }), + }), + }) - const body = await result.json(); + const body = await result.json() if (body.errors) { - throw body.errors[0]; + throw body.errors[0] } return { status: result.status, - body - }; + body, + } } catch (e) { if (isShopifyError(e)) { throw { cause: e.cause?.toString() || 'unknown', status: e.status || 500, message: e.message, - query - }; + query, + } } throw { error: e, - query - }; + query, + } } } const removeEdgesAndNodes = (array: Connection): T[] => { - return array.edges.map((edge) => edge?.node); -}; + return array.edges.map((edge) => edge?.node) +} const reshapeCart = (cart: ShopifyCart): Cart => { if (!cart.cost?.totalTaxAmount) { cart.cost.totalTaxAmount = { amount: '0.0', - currencyCode: cart.cost.totalAmount.currencyCode - }; + currencyCode: cart.cost.totalAmount.currencyCode, + } } return { ...cart, - lines: removeEdgesAndNodes(cart.lines) - }; -}; + lines: removeEdgesAndNodes(cart.lines), + } +} const reshapeCollection = ( - collection: ShopifyCollection + collection: ShopifyCollection, ): Collection | undefined => { if (!collection) { - return undefined; + return undefined } return { ...collection, - path: `/search/${collection.handle}` - }; -}; + path: `/search/${collection.handle}`, + } +} const reshapeCollections = (collections: ShopifyCollection[]) => { - const reshapedCollections = []; + const reshapedCollections = [] for (const collection of collections) { if (collection) { - const reshapedCollection = reshapeCollection(collection); + const reshapedCollection = reshapeCollection(collection) if (reshapedCollection) { - reshapedCollections.push(reshapedCollection); + reshapedCollections.push(reshapedCollection) } } } - return reshapedCollections; -}; + return reshapedCollections +} const reshapeImages = (images: Connection, productTitle: string) => { - const flattened = removeEdgesAndNodes(images); + const flattened = removeEdgesAndNodes(images) return flattened.map((image) => { - const filename = image.url.match(/.*\/(.*)\..*/)?.[1]; + const filename = image.url.match(/.*\/(.*)\..*/)?.[1] return { ...image, - altText: image.altText || `${productTitle} - ${filename}` - }; - }); -}; + altText: image.altText || `${productTitle} - ${filename}`, + } + }) +} const reshapeProduct = ( product: ShopifyProduct, - filterHiddenProducts: boolean = true + filterHiddenProducts: boolean = true, ) => { if ( !product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG)) ) { - return undefined; + return undefined } - const { images, variants, ...rest } = product; + const { images, variants, ...rest } = product return { ...rest, images: reshapeImages(images, product.title), - variants: removeEdgesAndNodes(variants) - }; -}; + variants: removeEdgesAndNodes(variants), + } +} const reshapeProducts = (products: ShopifyProduct[]) => { - const reshapedProducts = []; + const reshapedProducts = [] for (const product of products) { if (product) { - const reshapedProduct = reshapeProduct(product); + const reshapedProduct = reshapeProduct(product) if (reshapedProduct) { - reshapedProducts.push(reshapedProduct); + reshapedProducts.push(reshapedProduct) } } } - return reshapedProducts; -}; + return reshapedProducts +} export async function createCart(): Promise { const res = await shopifyFetch({ - query: createCartMutation - }); + query: createCartMutation, + }) - return reshapeCart(res.body.data.cartCreate.cart); + return reshapeCart(res.body.data.cartCreate.cart) } export async function addToCart( - lines: { merchandiseId: string; quantity: number }[] + lines: { merchandiseId: string; quantity: number }[], ): Promise { - const cartId = (await cookies()).get('cartId')?.value!; + const cartId = (await cookies()).get('cartId')?.value! const res = await shopifyFetch({ query: addToCartMutation, variables: { cartId, - lines - } - }); - return reshapeCart(res.body.data.cartLinesAdd.cart); + lines, + }, + }) + return reshapeCart(res.body.data.cartLinesAdd.cart) } export async function removeFromCart(lineIds: string[]): Promise { - const cartId = (await cookies()).get('cartId')?.value!; + const cartId = (await cookies()).get('cartId')?.value! const res = await shopifyFetch({ query: removeFromCartMutation, variables: { cartId, - lineIds - } - }); + lineIds, + }, + }) - return reshapeCart(res.body.data.cartLinesRemove.cart); + return reshapeCart(res.body.data.cartLinesRemove.cart) } export async function updateCart( - lines: { id: string; merchandiseId: string; quantity: number }[] + lines: { id: string; merchandiseId: string; quantity: number }[], ): Promise { - const cartId = (await cookies()).get('cartId')?.value!; + const cartId = (await cookies()).get('cartId')?.value! const res = await shopifyFetch({ query: editCartItemsMutation, variables: { cartId, - lines - } - }); + lines, + }, + }) - return reshapeCart(res.body.data.cartLinesUpdate.cart); + return reshapeCart(res.body.data.cartLinesUpdate.cart) } export async function getCart(): Promise { - const cartId = (await cookies()).get('cartId')?.value; + const cartId = (await cookies()).get('cartId')?.value if (!cartId) { - return undefined; + return undefined } const res = await shopifyFetch({ query: getCartQuery, - variables: { cartId } - }); + variables: { cartId }, + }) // Old carts becomes `null` when you checkout. if (!res.body.data.cart) { - return undefined; + return undefined } - return reshapeCart(res.body.data.cart); + return reshapeCart(res.body.data.cart) } export async function getCollection( - handle: string + handle: string, ): Promise { - 'use cache'; - cacheTag(TAGS.collections); - cacheLife('days'); + 'use cache' + cacheTag(TAGS.collections) + cacheLife('days') const res = await shopifyFetch({ query: getCollectionQuery, variables: { - handle - } - }); + handle, + }, + }) - return reshapeCollection(res.body.data.collection); + return reshapeCollection(res.body.data.collection) } export async function getCollectionProducts({ collection, reverse, - sortKey + sortKey, }: { - collection: string; - reverse?: boolean; - sortKey?: string; + collection: string + reverse?: boolean + sortKey?: string }): Promise { - 'use cache'; - cacheTag(TAGS.collections, TAGS.products); - cacheLife('days'); + 'use cache' + cacheTag(TAGS.collections, TAGS.products) + cacheLife('days') const res = await shopifyFetch({ query: getCollectionProductsQuery, variables: { handle: collection, reverse, - sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey - } - }); + sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey, + }, + }) if (!res.body.data.collection) { - console.log(`No collection found for \`${collection}\``); - return []; + console.log(`No collection found for \`${collection}\``) + return [] } - return reshapeProducts( - removeEdgesAndNodes(res.body.data.collection.products) - ); + return reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products)) } export async function getCollections(): Promise { - 'use cache'; - cacheTag(TAGS.collections); - cacheLife('days'); + 'use cache' + cacheTag(TAGS.collections) + cacheLife('days') const res = await shopifyFetch({ - query: getCollectionsQuery - }); - const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections); + query: getCollectionsQuery, + }) + const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections) const collections = [ { handle: '', @@ -348,32 +346,32 @@ export async function getCollections(): Promise { description: 'All products', seo: { title: 'All', - description: 'All products' + description: 'All products', }, path: '/search', - updatedAt: new Date().toISOString() + updatedAt: new Date().toISOString(), }, // Filter out the `hidden` collections. // Collections that start with `hidden-*` need to be hidden on the search page. ...reshapeCollections(shopifyCollections).filter( - (collection) => !collection.handle.startsWith('hidden') - ) - ]; + (collection) => !collection.handle.startsWith('hidden'), + ), + ] - return collections; + return collections } export async function getMenu(handle: string): Promise { - 'use cache'; - cacheTag(TAGS.collections); - cacheLife('days'); + 'use cache' + cacheTag(TAGS.collections) + cacheLife('days') const res = await shopifyFetch({ query: getMenuQuery, variables: { - handle - } - }); + handle, + }, + }) return ( res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({ @@ -381,83 +379,83 @@ export async function getMenu(handle: string): Promise { path: item.url .replace(domain, '') .replace('/collections', '/search') - .replace('/pages', '') + .replace('/pages', ''), })) || [] - ); + ) } export async function getPage(handle: string): Promise { const res = await shopifyFetch({ query: getPageQuery, - variables: { handle } - }); + variables: { handle }, + }) - return res.body.data.pageByHandle; + return res.body.data.pageByHandle } export async function getPages(): Promise { const res = await shopifyFetch({ - query: getPagesQuery - }); + query: getPagesQuery, + }) - return removeEdgesAndNodes(res.body.data.pages); + return removeEdgesAndNodes(res.body.data.pages) } export async function getProduct(handle: string): Promise { - 'use cache'; - cacheTag(TAGS.products); - cacheLife('days'); + 'use cache' + cacheTag(TAGS.products) + cacheLife('days') const res = await shopifyFetch({ query: getProductQuery, variables: { - handle - } - }); + handle, + }, + }) - return reshapeProduct(res.body.data.product, false); + return reshapeProduct(res.body.data.product, false) } export async function getProductRecommendations( - productId: string + productId: string, ): Promise { - 'use cache'; - cacheTag(TAGS.products); - cacheLife('days'); + 'use cache' + cacheTag(TAGS.products) + cacheLife('days') const res = await shopifyFetch({ query: getProductRecommendationsQuery, variables: { - productId - } - }); + productId, + }, + }) - return reshapeProducts(res.body.data.productRecommendations); + return reshapeProducts(res.body.data.productRecommendations) } export async function getProducts({ query, reverse, - sortKey + sortKey, }: { - query?: string; - reverse?: boolean; - sortKey?: string; + query?: string + reverse?: boolean + sortKey?: string }): Promise { - 'use cache'; - cacheTag(TAGS.products); - cacheLife('days'); + 'use cache' + cacheTag(TAGS.products) + cacheLife('days') const res = await shopifyFetch({ query: getProductsQuery, variables: { query, reverse, - sortKey - } - }); + sortKey, + }, + }) - return reshapeProducts(removeEdgesAndNodes(res.body.data.products)); + return reshapeProducts(removeEdgesAndNodes(res.body.data.products)) } // This is called from `app/api/revalidate.ts` so providers can control revalidation logic. @@ -467,35 +465,35 @@ export async function revalidate(req: NextRequest): Promise { const collectionWebhooks = [ 'collections/create', 'collections/delete', - 'collections/update' - ]; + 'collections/update', + ] const productWebhooks = [ 'products/create', 'products/delete', - 'products/update' - ]; - const topic = (await headers()).get('x-shopify-topic') || 'unknown'; - const secret = req.nextUrl.searchParams.get('secret'); - const isCollectionUpdate = collectionWebhooks.includes(topic); - const isProductUpdate = productWebhooks.includes(topic); + 'products/update', + ] + const topic = (await headers()).get('x-shopify-topic') || 'unknown' + const secret = req.nextUrl.searchParams.get('secret') + const isCollectionUpdate = collectionWebhooks.includes(topic) + const isProductUpdate = productWebhooks.includes(topic) if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) { - console.error('Invalid revalidation secret.'); - return NextResponse.json({ status: 401 }); + console.error('Invalid revalidation secret.') + return NextResponse.json({ status: 401 }) } if (!isCollectionUpdate && !isProductUpdate) { // We don't need to revalidate anything for any other topics. - return NextResponse.json({ status: 200 }); + return NextResponse.json({ status: 200 }) } if (isCollectionUpdate) { - revalidateTag(TAGS.collections); + revalidateTag(TAGS.collections) } if (isProductUpdate) { - revalidateTag(TAGS.products); + revalidateTag(TAGS.products) } - return NextResponse.json({ status: 200, revalidated: true, now: Date.now() }); + return NextResponse.json({ status: 200, revalidated: true, now: Date.now() }) }