This commit is contained in:
Henrik Larsson
2023-08-08 16:20:45 +02:00
parent 3e513eaf99
commit 22aab2f657
39 changed files with 1259 additions and 594 deletions

View File

@@ -1,5 +1,8 @@
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT } from 'lib/constants';
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
import { isShopifyError } from 'lib/type-guards';
import { revalidateTag } from 'next/cache';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import {
addToCartMutation,
createCartMutation,
@@ -23,6 +26,7 @@ import {
Cart,
Collection,
Connection,
Image,
Menu,
Page,
Product,
@@ -52,15 +56,17 @@ const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
export async function shopifyFetch<T>({
query,
variables,
cache = 'force-cache',
headers,
cache = 'force-cache'
query,
tags,
variables
}: {
query: string;
variables?: ExtractVariables<T>;
headers?: HeadersInit;
cache?: RequestCache;
headers?: HeadersInit;
query: string;
tags?: string[];
variables?: ExtractVariables<T>;
}): Promise<{ status: number; body: T } | never> {
try {
const result = await fetch(endpoint, {
@@ -75,7 +81,7 @@ export async function shopifyFetch<T>({
...(variables && { variables })
}),
cache,
next: { revalidate: 900 } // 15 minutes
...(tags && { next: { tags } })
});
const body = await result.json();
@@ -149,6 +155,18 @@ const reshapeCollections = (collections: ShopifyCollection[]) => {
return reshapedCollections;
};
const reshapeImages = (images: Connection<Image>, productTitle: string) => {
const flattened = removeEdgesAndNodes(images);
return flattened.map((image) => {
const filename = image.url.match(/.*\/(.*)\..*/)[1];
return {
...image,
altText: image.altText || `${productTitle} - ${filename}`
};
});
};
const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean = true) => {
if (!product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))) {
return undefined;
@@ -158,7 +176,7 @@ const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean =
return {
...rest,
images: removeEdgesAndNodes(images),
images: reshapeImages(images, product.title),
variants: removeEdgesAndNodes(variants)
};
};
@@ -232,15 +250,16 @@ export async function updateCart(
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
export async function getCart(cartId: string): Promise<Cart | null> {
export async function getCart(cartId: string): Promise<Cart | undefined> {
const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery,
variables: { cartId },
cache: 'no-store'
});
// Old carts becomes `null` when you checkout.
if (!res.body.data.cart) {
return null;
return undefined;
}
return reshapeCart(res.body.data.cart);
@@ -249,6 +268,7 @@ export async function getCart(cartId: string): Promise<Cart | null> {
export async function getCollection(handle: string): Promise<Collection | undefined> {
const res = await shopifyFetch<ShopifyCollectionOperation>({
query: getCollectionQuery,
tags: [TAGS.collections],
variables: {
handle
}
@@ -257,16 +277,27 @@ export async function getCollection(handle: string): Promise<Collection | undefi
return reshapeCollection(res.body.data.collection);
}
export async function getCollectionProducts(handle: string): Promise<Product[]> {
export async function getCollectionProducts({
collection,
reverse,
sortKey
}: {
collection: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
query: getCollectionProductsQuery,
tags: [TAGS.collections, TAGS.products],
variables: {
handle
handle: collection,
reverse,
sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey
}
});
if (!res.body.data.collection) {
console.log('No collection found for handle', handle);
console.log(`No collection found for \`${collection}\``);
return [];
}
@@ -274,7 +305,10 @@ export async function getCollectionProducts(handle: string): Promise<Product[]>
}
export async function getCollections(): Promise<Collection[]> {
const res = await shopifyFetch<ShopifyCollectionsOperation>({ query: getCollectionsQuery });
const res = await shopifyFetch<ShopifyCollectionsOperation>({
query: getCollectionsQuery,
tags: [TAGS.collections]
});
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
const collections = [
{
@@ -301,6 +335,7 @@ export async function getCollections(): Promise<Collection[]> {
export async function getMenu(handle: string): Promise<Menu[]> {
const res = await shopifyFetch<ShopifyMenuOperation>({
query: getMenuQuery,
tags: [TAGS.collections],
variables: {
handle
}
@@ -334,6 +369,7 @@ export async function getPages(): Promise<Page[]> {
export async function getProduct(handle: string): Promise<Product | undefined> {
const res = await shopifyFetch<ShopifyProductOperation>({
query: getProductQuery,
tags: [TAGS.products],
variables: {
handle
}
@@ -345,6 +381,7 @@ export async function getProduct(handle: string): Promise<Product | undefined> {
export async function getProductRecommendations(productId: string): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
query: getProductRecommendationsQuery,
tags: [TAGS.products],
variables: {
productId
}
@@ -364,6 +401,7 @@ export async function getProducts({
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductsOperation>({
query: getProductsQuery,
tags: [TAGS.products],
variables: {
query,
reverse,
@@ -373,3 +411,35 @@ export async function getProducts({
return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
}
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
export async function revalidate(req: NextRequest): Promise<NextResponse> {
// We always need to respond with a 200 status code to Shopify,
// otherwise it will continue to retry the request.
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
const topic = 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: 200 });
}
if (!isCollectionUpdate && !isProductUpdate) {
// We don't need to revalidate anything for any other topics.
return NextResponse.json({ status: 200 });
}
if (isCollectionUpdate) {
revalidateTag(TAGS.collections);
}
if (isProductUpdate) {
revalidateTag(TAGS.products);
}
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
}

View File

@@ -37,9 +37,13 @@ export const getCollectionsQuery = /* GraphQL */ `
`;
export const getCollectionProductsQuery = /* GraphQL */ `
query getCollectionProducts($handle: String!) {
query getCollectionProducts(
$handle: String!
$sortKey: ProductCollectionSortKeys
$reverse: Boolean
) {
collection(handle: $handle) {
products(first: 100) {
products(sortKey: $sortKey, reverse: $reverse, first: 100) {
edges {
node {
...product

View File

@@ -201,6 +201,8 @@ export type ShopifyCollectionProductsOperation = {
};
variables: {
handle: string;
reverse?: boolean;
sortKey?: string;
};
};