diff --git a/app/[page]/opengraph-image.tsx b/app/[page]/opengraph-image.tsx index 5f11d802b3..3959388504 100644 --- a/app/[page]/opengraph-image.tsx +++ b/app/[page]/opengraph-image.tsx @@ -3,7 +3,7 @@ import { getPage } from "lib/shopify"; export default async function Image({ params }: { params: { page: string } }) { const page = await getPage(params.page); - const title = page.seo?.title || page.title; + const title = page?.seo?.title || page?.title; return await OpengraphImage({ title }); } diff --git a/app/sitemap.ts b/app/sitemap.ts index b1c199ead6..9e52bb111e 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,5 +1,5 @@ import { getCollections, getPages, getProducts } from "lib/shopify"; -import { baseUrl, validateEnvironmentVariables } from "lib/utils"; +import { baseUrl } from "lib/utils"; import { MetadataRoute } from "next"; type Route = { @@ -10,8 +10,6 @@ type Route = { export const dynamic = "force-dynamic"; export default async function sitemap(): Promise { - validateEnvironmentVariables(); - const routesMap = [""].map((route) => ({ url: `${baseUrl}${route}`, lastModified: new Date().toISOString(), @@ -38,15 +36,9 @@ export default async function sitemap(): Promise { })), ); - let fetchedRoutes: Route[] = []; - - try { - fetchedRoutes = ( - await Promise.all([collectionsPromise, productsPromise, pagesPromise]) - ).flat(); - } catch (error) { - throw JSON.stringify(error, null, 2); - } + const fetchedRoutes: Route[] = ( + await Promise.all([collectionsPromise, productsPromise, pagesPromise]) + ).flat(); return [...routesMap, ...fetchedRoutes]; } diff --git a/components/cart/actions.ts b/components/cart/actions.ts index c257ac9ecc..5a03af8829 100644 --- a/components/cart/actions.ts +++ b/components/cart/actions.ts @@ -14,7 +14,7 @@ import { redirect } from "next/navigation"; export async function addItem( prevState: any, - selectedVariantId: string | undefined + selectedVariantId: string | undefined, ) { if (!selectedVariantId) { return "Error adding item to cart"; @@ -37,7 +37,7 @@ export async function removeItem(prevState: any, merchandiseId: string) { } const lineItem = cart.lines.find( - (line) => line.merchandise.id === merchandiseId + (line) => line.merchandise.id === merchandiseId, ); if (lineItem && lineItem.id) { @@ -56,7 +56,7 @@ export async function updateItemQuantity( payload: { merchandiseId: string; quantity: number; - } + }, ) { const { merchandiseId, quantity } = payload; @@ -68,7 +68,7 @@ export async function updateItemQuantity( } const lineItem = cart.lines.find( - (line) => line.merchandise.id === merchandiseId + (line) => line.merchandise.id === merchandiseId, ); if (lineItem && lineItem.id) { @@ -96,11 +96,25 @@ export async function updateItemQuantity( } export async function redirectToCheckout() { - let cart = await getCart(); - redirect(cart!.checkoutUrl); + const cart = await getCart(); + + if (!cart?.checkoutUrl) { + return; + } + + redirect(cart.checkoutUrl); } export async function createCartAndSetCookie() { - let cart = await createCart(); - (await cookies()).set("cartId", cart.id!); + try { + const cart = await createCart(); + + if (!cart.id) { + return; + } + + (await cookies()).set("cartId", cart.id); + } catch (error) { + console.error(error); + } } diff --git a/components/layout/footer.tsx b/components/layout/footer.tsx index 9f0025df64..9f8bdedb7c 100644 --- a/components/layout/footer.tsx +++ b/components/layout/footer.tsx @@ -1,9 +1,9 @@ import Link from "next/link"; +import { Suspense } from "react"; import FooterMenu from "components/layout/footer-menu"; import LogoSquare from "components/logo-square"; import { getMenu } from "lib/shopify"; -import { Suspense } from "react"; const { COMPANY_NAME, SITE_NAME } = process.env; @@ -47,7 +47,7 @@ export default async function Footer() { aria-label="Deploy on Vercel" href="https://vercel.com/templates/next.js/nextjs-commerce" > - +
Deploy @@ -68,7 +68,7 @@ export default async function Footer() {

- Created by ▲ Vercel + Created by ▲ Vercel

diff --git a/components/welcome-toast.tsx b/components/welcome-toast.tsx index 414055db9c..c2c4f620ad 100644 --- a/components/welcome-toast.tsx +++ b/components/welcome-toast.tsx @@ -5,10 +5,11 @@ import { toast } from "sonner"; export function WelcomeToast() { useEffect(() => { - // ignore if screen height is too small + // Ignore if screen height is too small. if (window.innerHeight < 650) return; + if (!document.cookie.includes("welcome-toast=2")) { - toast("🛍️ Welcome to Next.js Commerce!", { + toast("\u{1F6CD}\uFE0F Welcome to Next.js Commerce!", { id: "welcome-toast", duration: Infinity, onDismiss: () => { @@ -22,6 +23,7 @@ export function WelcomeToast() { href="https://vercel.com/templates/next.js/nextjs-commerce" className="text-blue-600 hover:underline" target="_blank" + rel="noreferrer" > Deploy your own diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts index ccd48e32ff..d5e586bb65 100644 --- a/lib/shopify/index.ts +++ b/lib/shopify/index.ts @@ -62,12 +62,50 @@ const domain = process.env.SHOPIFY_STORE_DOMAIN ? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, "https://") : ""; const endpoint = domain ? `${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; +function isShopifyConfigured() { + return Boolean(endpoint && key); +} + +function getShopifyErrorMessage(error: unknown) { + if (typeof error === "string") { + return error; + } + + if (error && typeof error === "object" && "message" in error) { + return String(error.message); + } + + return "Unknown Shopify error"; +} + +function logShopifyReadFailure(operation: string, error: unknown) { + console.warn( + `[shopify] ${operation} failed: ${getShopifyErrorMessage(error)}`, + ); +} + +function getDefaultCollections(): Collection[] { + return [ + { + handle: "", + title: "All", + description: "All products", + seo: { + title: "All", + description: "All products", + }, + path: "/search", + updatedAt: new Date().toISOString(), + }, + ]; +} + export async function shopifyFetch({ headers, query, @@ -141,7 +179,7 @@ const reshapeCart = (cart: ShopifyCart): Cart => { }; const reshapeCollection = ( - collection: ShopifyCollection + collection: ShopifyCollection, ): Collection | undefined => { if (!collection) { return undefined; @@ -183,7 +221,7 @@ const reshapeImages = (images: Connection, productTitle: string) => { const reshapeProduct = ( product: ShopifyProduct, - filterHiddenProducts: boolean = true + filterHiddenProducts: boolean = true, ) => { if ( !product || @@ -226,7 +264,7 @@ export async function createCart(): Promise { } export async function addToCart( - lines: { merchandiseId: string; quantity: number }[] + lines: { merchandiseId: string; quantity: number }[], ): Promise { const cartId = (await cookies()).get("cartId")?.value!; const res = await shopifyFetch({ @@ -253,7 +291,7 @@ export async function removeFromCart(lineIds: string[]): Promise { } 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 res = await shopifyFetch({ @@ -278,34 +316,48 @@ export async function getCart(): Promise { return undefined; } - const res = await shopifyFetch({ - query: getCartQuery, - variables: { cartId }, - }); + try { + const res = await shopifyFetch({ + query: getCartQuery, + variables: { cartId }, + }); + + // Old carts becomes `null` when you checkout. + if (!res.body.data.cart) { + return undefined; + } - // Old carts becomes `null` when you checkout. - if (!res.body.data.cart) { + return reshapeCart(res.body.data.cart); + } catch (error) { + logShopifyReadFailure("getCart", error); return undefined; } - - return reshapeCart(res.body.data.cart); } export async function getCollection( - handle: string + handle: string, ): Promise { "use cache"; cacheTag(TAGS.collections); cacheLife("days"); - const res = await shopifyFetch({ - query: getCollectionQuery, - variables: { - handle, - }, - }); + if (!isShopifyConfigured()) { + return undefined; + } - return reshapeCollection(res.body.data.collection); + try { + const res = await shopifyFetch({ + query: getCollectionQuery, + variables: { + handle, + }, + }); + + return reshapeCollection(res.body.data.collection); + } catch (error) { + logShopifyReadFailure(`getCollection(${handle})`, error); + return undefined; + } } export async function getCollectionProducts({ @@ -321,30 +373,31 @@ export async function getCollectionProducts({ cacheTag(TAGS.collections, TAGS.products); cacheLife("days"); - if (!endpoint) { - console.log( - `Skipping getCollectionProducts for '${collection}' - Shopify not configured` - ); + if (!isShopifyConfigured()) { return []; } - const res = await shopifyFetch({ - query: getCollectionProductsQuery, - variables: { - handle: collection, - reverse, - sortKey: sortKey === "CREATED_AT" ? "CREATED" : sortKey, - }, - }); + try { + const res = await shopifyFetch({ + query: getCollectionProductsQuery, + variables: { + handle: collection, + reverse, + sortKey: sortKey === "CREATED_AT" ? "CREATED" : sortKey, + }, + }); + + if (!res.body.data.collection) { + return []; + } - if (!res.body.data.collection) { - console.log(`No collection found for \`${collection}\``); + return reshapeProducts( + removeEdgesAndNodes(res.body.data.collection.products), + ); + } catch (error) { + logShopifyReadFailure(`getCollectionProducts(${collection})`, error); return []; } - - return reshapeProducts( - removeEdgesAndNodes(res.body.data.collection.products) - ); } export async function getCollections(): Promise { @@ -352,47 +405,29 @@ export async function getCollections(): Promise { cacheTag(TAGS.collections); cacheLife("days"); - if (!endpoint) { - console.log("Skipping getCollections - Shopify not configured"); - return [ - { - handle: "", - title: "All", - description: "All products", - seo: { - title: "All", - description: "All products", - }, - path: "/search", - updatedAt: new Date().toISOString(), - }, - ]; + if (!isShopifyConfigured()) { + return getDefaultCollections(); } - const res = await shopifyFetch({ - query: getCollectionsQuery, - }); - const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections); - const collections = [ - { - handle: "", - title: "All", - description: "All products", - seo: { - title: "All", - description: "All products", - }, - path: "/search", - 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") - ), - ]; + try { + const res = await shopifyFetch({ + query: getCollectionsQuery, + }); + const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections); + const collections = [ + ...getDefaultCollections(), + // 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"), + ), + ]; - return collections; + return collections; + } catch (error) { + logShopifyReadFailure("getCollections", error); + return getDefaultCollections(); + } } export async function getMenu(handle: string): Promise { @@ -400,44 +435,68 @@ export async function getMenu(handle: string): Promise { cacheTag(TAGS.collections); cacheLife("days"); - if (!endpoint) { - console.log(`Skipping getMenu for '${handle}' - Shopify not configured`); + if (!isShopifyConfigured()) { return []; } - const res = await shopifyFetch({ - query: getMenuQuery, - variables: { - handle, - }, - }); + try { + const res = await shopifyFetch({ + query: getMenuQuery, + variables: { + handle, + }, + }); - return ( - res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({ - title: item.title, - path: item.url - .replace(domain, "") - .replace("/collections", "/search") - .replace("/pages", ""), - })) || [] - ); + return ( + res.body?.data?.menu?.items.map( + (item: { title: string; url: string }) => ({ + title: item.title, + path: item.url + .replace(domain, "") + .replace("/collections", "/search") + .replace("/pages", ""), + }), + ) || [] + ); + } catch (error) { + logShopifyReadFailure(`getMenu(${handle})`, error); + return []; + } } -export async function getPage(handle: string): Promise { - const res = await shopifyFetch({ - query: getPageQuery, - variables: { handle }, - }); +export async function getPage(handle: string): Promise { + if (!isShopifyConfigured()) { + return undefined; + } - return res.body.data.pageByHandle; + try { + const res = await shopifyFetch({ + query: getPageQuery, + variables: { handle }, + }); + + return res.body.data.pageByHandle; + } catch (error) { + logShopifyReadFailure(`getPage(${handle})`, error); + return undefined; + } } export async function getPages(): Promise { - const res = await shopifyFetch({ - query: getPagesQuery, - }); + if (!isShopifyConfigured()) { + return []; + } - return removeEdgesAndNodes(res.body.data.pages); + try { + const res = await shopifyFetch({ + query: getPagesQuery, + }); + + return removeEdgesAndNodes(res.body.data.pages); + } catch (error) { + logShopifyReadFailure("getPages", error); + return []; + } } export async function getProduct(handle: string): Promise { @@ -445,36 +504,49 @@ export async function getProduct(handle: string): Promise { cacheTag(TAGS.products); cacheLife("days"); - if (!endpoint) { - console.log(`Skipping getProduct for '${handle}' - Shopify not configured`); + if (!isShopifyConfigured()) { return undefined; } - const res = await shopifyFetch({ - query: getProductQuery, - variables: { - handle, - }, - }); + try { + const res = await shopifyFetch({ + query: getProductQuery, + variables: { + handle, + }, + }); - return reshapeProduct(res.body.data.product, false); + return reshapeProduct(res.body.data.product, false); + } catch (error) { + logShopifyReadFailure(`getProduct(${handle})`, error); + return undefined; + } } export async function getProductRecommendations( - productId: string + productId: string, ): Promise { "use cache"; cacheTag(TAGS.products); cacheLife("days"); - const res = await shopifyFetch({ - query: getProductRecommendationsQuery, - variables: { - productId, - }, - }); + if (!isShopifyConfigured()) { + return []; + } - return reshapeProducts(res.body.data.productRecommendations); + try { + const res = await shopifyFetch({ + query: getProductRecommendationsQuery, + variables: { + productId, + }, + }); + + return reshapeProducts(res.body.data.productRecommendations); + } catch (error) { + logShopifyReadFailure(`getProductRecommendations(${productId})`, error); + return []; + } } export async function getProducts({ @@ -490,16 +562,25 @@ export async function getProducts({ cacheTag(TAGS.products); cacheLife("days"); - const res = await shopifyFetch({ - query: getProductsQuery, - variables: { - query, - reverse, - sortKey, - }, - }); + if (!isShopifyConfigured()) { + return []; + } + + try { + const res = await shopifyFetch({ + query: getProductsQuery, + variables: { + query, + reverse, + sortKey, + }, + }); - return reshapeProducts(removeEdgesAndNodes(res.body.data.products)); + return reshapeProducts(removeEdgesAndNodes(res.body.data.products)); + } catch (error) { + logShopifyReadFailure("getProducts", error); + return []; + } } // This is called from `app/api/revalidate.ts` so providers can control revalidation logic.