Feat/remove shopify (#12)

This commit is contained in:
leonmargaritis 2023-11-24 10:56:53 +01:00 committed by GitHub
parent 33c82d2d70
commit bb2c00c5c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 631 additions and 1646 deletions

View File

@ -1,14 +1,11 @@
COMPANY_NAME="Vercel Inc."
TWITTER_CREATOR="@vercel"
TWITTER_SITE="https://nextjs.org/commerce"
SITE_NAME="Next.js Commerce"
SHOPIFY_REVALIDATION_SECRET=""
SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
CTP_PROJECT_KEY=""
CTP_CLIENT_SECRET=""
CTP_CLIENT_ID=""
CTP_AUTH_URL=""
CTP_API_URL=""
CTP_SCOPES=""
COMPANY_NAME="Vercel Inc."
TWITTER_CREATOR="@vercel"
TWITTER_SITE="https://nextjs.org/commerce"
SITE_NAME="Next.js Commerce"

View File

@ -16,7 +16,7 @@ A Next.js 14 and App Router-ready ecommerce template for commercetools, featurin
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary.
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify store.
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Commercetools store.
1. Install Vercel CLI: `npm i -g vercel`
2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`

View File

@ -1,5 +1,5 @@
import OpengraphImage from "components/opengraph-image";
import { getPage } from "lib/shopify";
import { getPage } from "lib/commercetools/queries";
export const runtime = "edge";

View File

@ -1,7 +1,7 @@
import type { Metadata } from "next";
import Prose from "components/prose";
import { getPage } from "lib/shopify";
import { getPage } from "lib/commercetools/queries";
import { notFound } from "next/navigation";
export const runtime = "edge";

View File

@ -1,4 +1,4 @@
import { revalidate } from "lib/shopify";
import { revalidate } from "lib/commercetools/queries";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "edge";

View File

@ -6,7 +6,7 @@ import { Suspense } from "react";
export const runtime = "edge";
export const metadata = {
description: "High-performance ecommerce store built with Next.js, Vercel, and Shopify.",
description: "High-performance ecommerce store built with Next.js, Vercel, and Commercetools.",
openGraph: {
type: "website"
}

View File

@ -6,9 +6,9 @@ import { GridTileImage } from "components/grid/tile";
import Footer from "components/layout/footer";
import { Gallery } from "components/product/gallery";
import { ProductDescription } from "components/product/product-description";
import { HIDDEN_PRODUCT_TAG } from "lib/constants";
import { getProduct, getProductRecommendations } from "lib/commercetools";
import { getProduct, getProductRecommendations } from "lib/commercetools/queries";
import { Image } from "lib/commercetools/types";
import { HIDDEN_PRODUCT_TAG } from "lib/constants";
import Link from "next/link";
export const runtime = "edge";
@ -53,7 +53,7 @@ export async function generateMetadata({
export default async function ProductPage({ params }: { params: { handle: string } }) {
const product = await getProduct(params.handle);
console.log(product);
if (!product) return notFound();
const productJsonLd = {

View File

@ -1,5 +1,5 @@
import OpengraphImage from "components/opengraph-image";
import { getCollection } from "lib/commercetools";
import { getCollection } from "lib/commercetools/queries";
export const runtime = "edge";

View File

@ -1,4 +1,4 @@
import { getCollection, getCollectionProducts } from "lib/commercetools";
import { getCollection, getCollectionProducts } from "lib/commercetools/queries";
import { Metadata } from "next";
import { notFound } from "next/navigation";

View File

@ -1,7 +1,7 @@
import Grid from "components/grid";
import ProductGridItems from "components/layout/product-grid-items";
import { getProducts } from "lib/commercetools/queries";
import { defaultSort, sorting } from "lib/constants";
import { getProducts } from "lib/commercetools";
export const runtime = "edge";

View File

@ -1,5 +1,4 @@
import { getCollections, getProducts } from "lib/commercetools";
import { getPages } from "lib/shopify";
import { getCollections, getPages, getProducts } from "lib/commercetools/queries";
import { validateEnvironmentVariables } from "lib/utils";
import { MetadataRoute } from "next";

View File

@ -1,4 +1,4 @@
import { getCollectionProducts } from "lib/commercetools";
import { getCollectionProducts } from "lib/commercetools/queries";
import Link from "next/link";
import { GridTileImage } from "./grid/tile";

View File

@ -1,7 +1,13 @@
"use server";
import {
addToCart,
createCart,
getCart,
removeFromCart,
updateCart
} from "lib/commercetools/queries";
import { TAGS } from "lib/constants";
import { addToCart, createCart, getCart, removeFromCart, updateCart } from "lib/commercetools";
import { revalidateTag } from "next/cache";
import { cookies } from "next/headers";
@ -16,7 +22,7 @@ export async function addItem(prevState: any, selectedVariantId: string | undefi
if (!cartId || !cart) {
cart = await createCart();
cartId = cart.id;
cookies().set("cartId", cartId);
cookies().set({ name: "cartId", value: cartId, httpOnly: true });
}
if (!selectedVariantId) {

View File

@ -7,7 +7,6 @@ import LoadingDots from "components/loading-dots";
import { ProductVariant } from "lib/commercetools/types";
import { useSearchParams } from "next/navigation";
import { useFormState, useFormStatus } from "react-dom";
import { addToCart } from "lib/commercetools";
function SubmitButton({
availableForSale,

View File

@ -1,4 +1,4 @@
import { getCart } from "lib/commercetools";
import { getCart } from "lib/commercetools/queries";
import { cookies } from "next/headers";
import CartModal from "./modal";

View File

@ -1,5 +1,5 @@
import { GridTileImage } from "components/grid/tile";
import { getCollectionProducts } from "lib/commercetools";
import { getCollectionProducts } from "lib/commercetools/queries";
import type { Product } from "lib/commercetools/types";
import Link from "next/link";

View File

@ -1,7 +1,7 @@
"use client";
import clsx from "clsx";
import { Menu } from "lib/shopify/types";
import { Menu } from "lib/commercetools/types";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";

View File

@ -2,7 +2,7 @@ import Link from "next/link";
import FooterMenu from "components/layout/footer-menu";
import LogoSquare from "components/logo-square";
import { getMenu } from "lib/shopify";
import { getMenu } from "lib/commercetools/queries";
import { Suspense } from "react";
const { COMPANY_NAME, SITE_NAME } = process.env;

View File

@ -1,8 +1,8 @@
import Cart from "components/cart";
import OpenCart from "components/cart/open-cart";
import LogoSquare from "components/logo-square";
import { getMenu } from "lib/shopify";
import { Menu } from "lib/shopify/types";
import { getMenu } from "lib/commercetools/queries";
import { Menu } from "lib/commercetools/types";
import Link from "next/link";
import { Suspense } from "react";
import MobileMenu from "./mobile-menu";

View File

@ -6,7 +6,7 @@ import { usePathname, useSearchParams } from "next/navigation";
import { Fragment, useEffect, useState } from "react";
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
import { Menu } from "lib/shopify/types";
import { Menu } from "lib/commercetools/types";
import Search from "./search";
export default function MobileMenu({ menu }: { menu: Menu[] }) {

View File

@ -1,7 +1,7 @@
import clsx from "clsx";
import { Suspense } from "react";
import { getCollections } from "lib/commercetools";
import { getCollections } from "lib/commercetools/queries";
import FilterList from "./filter";
async function CollectionList() {

View File

@ -15,7 +15,10 @@ export const DUMMY_PRICERANGE = {
maxVariantPrice: { amount: "99.99", currencyCode: "EUR" }
};
// Dummy delimiter for meta keywords
export const DUMMY_DELIMITER = " ";
// Dummy limit for product projection search
export const DUMMY_LIMIT = 200;
// Dummy image for products
@ -26,6 +29,7 @@ export const DUMMY_IMAGE = {
altText: "dummy-image"
};
// Dummy CommercetoolsMoney with centAmount 0
export const DUMMY_ZERO_EUR0: CommercetoolsCentPrecisionMoney = {
centAmount: 0,
fractionDigits: 2,

View File

@ -1,517 +0,0 @@
import {
TypedMoney as CommercetoolsTypedMoney,
Image as CommercetoolsImage,
ProductProjection as CommercetoolsProductProjection,
Cart as CommercetoolsCart,
LineItem as CommercetoolsLineItem,
ProductVariant as CommercetoolsProductVariant,
Attribute as CommercetoolsAttribute,
Category as CommercetoolsCategory
} from "@commercetools/platform-sdk";
import {
Image,
Money,
Product,
ProductOption,
ProductVariant,
Cart,
CartItem,
Collection
} from "./types";
import { HIDDEN_PRODUCT_TAG } from "lib/constants";
import {
DUMMY_CURRENCY_CODE,
DUMMY_IMAGE,
DUMMY_LOCALE,
DUMMY_COUNTRY,
DUMMY_ZERO_EUR0,
DUMMY_LIMIT,
DUMMY_DELIMITER
} from "./dummy-data";
import apiRoot from "lib/client-builder";
import { isString, isNumber, hasLocalizedStringValue } from "./type-guards";
function reshapeMoney(typedMoney: CommercetoolsTypedMoney): Money {
const { fractionDigits, currencyCode, type } = typedMoney;
const typedAmount = type === "centPrecision" ? typedMoney.centAmount : typedMoney.preciseAmount;
const amount = (typedAmount / Math.pow(10, fractionDigits)).toString();
return { amount, currencyCode };
}
function reshapeImage(image: CommercetoolsImage): Image {
const filename = image.url.match(/.*\/(.*)\..*/)?.[1];
return {
url: image.url,
width: image.dimensions.w,
height: image.dimensions.h,
altText: image.label || filename || ""
};
}
function reshapeProductProjection(
productProjection: CommercetoolsProductProjection,
filterHiddenProducts: boolean = true
): Product | undefined {
const {
id,
description,
masterVariant,
variants,
slug,
name,
metaTitle,
metaDescription,
metaKeywords,
lastModifiedAt
} = productProjection;
const handle = slug[DUMMY_LOCALE];
const title = name[DUMMY_LOCALE];
const reshapedVariants = reshapeProductVariants(variants);
const reshapedMasterVariant = reshapeProductVariant(masterVariant);
if (!reshapedMasterVariant || !handle || !title) return undefined;
let priceRange = {
minVariantPrice: reshapedMasterVariant.price,
maxVariantPrice: reshapedMasterVariant.price
};
// Calculate pricerange only if the product has variants
if (reshapedVariants.length) {
for (const variant of reshapedVariants) {
const { amount } = variant.price;
if (amount < priceRange.minVariantPrice.amount) priceRange.minVariantPrice = variant.price;
if (amount > priceRange.maxVariantPrice.amount) priceRange.maxVariantPrice = variant.price;
}
}
// Reduce all images of all variants in one array.
const images = [masterVariant, ...variants]
.flatMap((variant) => variant.images)
.filter((image) => image !== undefined) as CommercetoolsImage[];
// Set featured Image to first image of master variant or placeholder image.
const firstImage = images[0];
const featuredImage = firstImage ? reshapeImage(firstImage) : DUMMY_IMAGE;
const options = reshapeAttributesToOptions(
masterVariant.attributes,
variants.map((variant) => variant.attributes)
);
const product = {
id,
handle,
availableForSale: reshapedMasterVariant?.availableForSale || false,
title,
description: description?.[DUMMY_LOCALE] || "",
descriptionHtml: description?.[DUMMY_LOCALE] || "", // needs to be replaced
options,
priceRange,
variants: [reshapedMasterVariant, ...reshapedVariants],
featuredImage,
images: images.map((image) => reshapeImage(image)),
seo: {
title: metaTitle?.[DUMMY_LOCALE],
description: metaDescription?.[DUMMY_LOCALE]
},
tags: metaKeywords?.[DUMMY_LOCALE]?.split(DUMMY_DELIMITER).map((word) => word.trim()) || [],
updatedAt: lastModifiedAt
};
if (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG)) {
return undefined;
}
return product;
}
function reshapeProductProjections(
productProjections: CommercetoolsProductProjection[]
): Product[] {
return productProjections
.map((item) => reshapeProductProjection(item))
.filter((item) => item !== undefined) as Product[];
}
function reshapeLineItem(lineItem: CommercetoolsLineItem): CartItem {
const firstVariantImage = lineItem.variant.images?.[0];
const featuredImage = firstVariantImage ? reshapeImage(firstVariantImage) : DUMMY_IMAGE; // needs to be replaced by placeholder image
const selectedOptions = reshapeAttributesToSelectedOptions(lineItem.variant.attributes || []);
return {
id: lineItem.id,
quantity: lineItem.quantity,
cost: { totalAmount: reshapeMoney(lineItem.totalPrice) },
merchandise: {
id: lineItem.variant.sku as string,
title: lineItem.name[DUMMY_LOCALE] || "",
selectedOptions,
product: {
handle: lineItem.productSlug?.[DUMMY_LOCALE] || "",
title: lineItem.name[DUMMY_LOCALE] || "",
featuredImage
}
}
};
}
function reshapeCart(cart: CommercetoolsCart): Cart {
return {
id: cart.id,
checkoutUrl: "", // needs to be replaced
cost: {
totalAmount: reshapeMoney(cart.taxedPrice?.totalGross || cart.totalPrice),
totalTaxAmount: reshapeMoney(cart.taxedPrice?.totalTax || DUMMY_ZERO_EUR0)
},
lines: cart.lineItems.map((lineItem) => reshapeLineItem(lineItem)),
totalQuantity: cart.totalLineItemQuantity || 0
};
}
function reshapeProductVariant(variant: CommercetoolsProductVariant): ProductVariant | undefined {
const sku = variant.sku;
const price = variant.price?.value;
if (!sku || !price) return undefined;
const selectedOptions = reshapeAttributesToSelectedOptions(variant.attributes || []);
return {
id: variant.sku,
availableForSale: variant.availability?.isOnStock || false,
selectedOptions,
price: reshapeMoney(price)
};
}
function reshapeProductVariants(variants: CommercetoolsProductVariant[]): ProductVariant[] {
return variants
.map((variant) => reshapeProductVariant(variant))
.filter((variant) => variant !== undefined) as ProductVariant[];
}
/**
* Reshapes attributes of all variants of a product into product options.
* All key value pairs of the existing attributes are combined and then all values with a common key are combined.
*
* @param {CommercetoolsAttribute[] | undefined} masterVariantAttributes - Master variant attributes.
* @param {CommercetoolsAttribute[] | undefined} variantAttributes - Variant attributes.
* @returns {ProductOption[]} - Array of product options.
*/
function reshapeAttributesToOptions(
masterVariantAttributes: CommercetoolsAttribute[] | undefined,
variantAttributes: (CommercetoolsAttribute[] | undefined)[]
): ProductOption[] {
const attributes = [masterVariantAttributes, ...variantAttributes].flat();
// Remove all attributes that are undefined and group the values with the same name
const options: ProductOption[] = [];
for (const attribute of attributes) {
if (!attribute) continue;
const { name, value } = attribute;
let valueAsString: string;
// Check whether the value is a string, a number or a localized string and convert it to a string, if it is not, stop the iteration.
if (isString(value)) valueAsString = value;
else if (isNumber(value)) valueAsString = value.toString();
else if (hasLocalizedStringValue(value, DUMMY_LOCALE)) valueAsString = value[DUMMY_LOCALE];
else continue;
// Add the current attribute to options if it does not yet exist. If it does exist, add the value of the attribute to the associated option if the value does not yet exist.
const index = options.findIndex((option) => option.name === name);
if (index === -1) {
options.push({ name, id: name, values: [valueAsString] });
} else {
const existingOption = options[index] as ProductOption;
if (!existingOption.values.includes(valueAsString)) existingOption.values.push(valueAsString);
}
}
console.log("OPTIONS", options);
return options;
}
function reshapeAttributesToSelectedOptions(
attributes: CommercetoolsAttribute[]
): { name: string; value: string }[] {
let selectedOptions = [];
for (const { name, value } of attributes) {
if (isString(value)) selectedOptions.push({ name, value });
else if (isNumber(value)) selectedOptions.push({ name, value: value.toString() });
else if (hasLocalizedStringValue(value, DUMMY_LOCALE))
selectedOptions.push({ name, value: value[DUMMY_LOCALE] });
}
return selectedOptions;
}
function reshapeCategory(category: CommercetoolsCategory): Collection | undefined {
const { name, slug, description, metaTitle, metaDescription, lastModifiedAt } = category;
const title = name[DUMMY_LOCALE];
const handle = slug[DUMMY_LOCALE];
if (!handle || !title) return undefined;
return {
title,
handle,
description: description?.[DUMMY_LOCALE] || "",
seo: {
title: metaTitle?.[DUMMY_LOCALE],
description: metaDescription?.[DUMMY_LOCALE]
},
updatedAt: lastModifiedAt,
path: `/search/${handle}`
};
}
function reshapeCategories(categories: CommercetoolsCategory[]): Collection[] {
return categories
.map((item) => reshapeCategory(item))
.filter((item) => item !== undefined) as Collection[];
}
export async function getCollection(handle: string): Promise<Collection | undefined> {
try {
const response = await apiRoot
.categories()
.get({
queryArgs: {
where: `slug(${DUMMY_LOCALE}="${handle}")`
}
})
.execute();
const matchedCategory = response.body.results[0];
if (!matchedCategory) return undefined;
return reshapeCategory(matchedCategory);
} catch (err) {
return undefined;
}
}
export async function getCollections(): Promise<Collection[]> {
const response = await apiRoot.categories().get().execute();
return reshapeCategories(response.body.results);
}
/**
* Requests a productprojection based on its handle (slug) from commercetools and reshapes it.
*
* @param {string} handle - The handle (slug) of the product.
* @returns {Promise<Product | undefined>} A promise resolving to the retrieved product or undefined if not found.
*/
export async function getProduct(handle: string): Promise<Product | undefined> {
try {
const response = await apiRoot
.productProjections()
.get({
queryArgs: {
where: `slug(${DUMMY_LOCALE}="${handle}")`,
priceCurrency: DUMMY_CURRENCY_CODE,
priceCountry: DUMMY_COUNTRY
}
})
.execute();
const matchedProductProjection = response.body.results[0];
if (!matchedProductProjection) return undefined;
return reshapeProductProjection(matchedProductProjection);
} catch (err) {
return undefined;
}
}
export async function getProductRecommendations(productId: string): Promise<Product[]> {
return [];
}
/**
* Requests a list of product projections based on optional query, sort key and reverse flag from commercetools and reshapes it.
*
* @param {Object} options - The options object containing query, reverse, and sortKey.
* @param {string} [options.query] - The optional query string for commercetools fulltext search.
* @param {boolean} [options.reverse] - Flag to indicate whether to reverse the products list.
* @param {string} [options.sortKey] - The key to sort the products ().
* @returns {Promise<Product[]>} A promise resolving to an array of products.
*/
export async function getProducts({
query,
reverse,
sortKey
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
try {
const response = await apiRoot
.productProjections()
.search()
.get({
queryArgs: {
limit: DUMMY_LIMIT,
[`text.${DUMMY_LOCALE}`]: query,
priceCurrency: DUMMY_CURRENCY_CODE,
priceCountry: DUMMY_COUNTRY,
sort: sortKey
}
})
.execute();
const products = reshapeProductProjections(response.body.results);
return reverse ? products.reverse() : products;
} catch (err) {
return [];
}
}
/**
* Requests a list of product projections filtered by a collection (category) based on optional sort key and reverse flag from commercetools and reshapes it.
*
* @param {Object} options - The options object containing query, reverse, and sortKey.
* @param {collection} [options.collection] - Handle (slug) of collection
* @param {boolean} [options.reverse] - Flag to indicate whether to reverse the products list.
* @param {string} [options.sortKey] - The key to sort the products ().
* @returns {Promise<Product[]>} A promise resolving to an array of products.
*/
export async function getCollectionProducts({
collection,
reverse,
sortKey
}: {
collection: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
try {
// Get category by slug
const categoriesResponse = await apiRoot
.categories()
.get({ queryArgs: { where: `slug(${DUMMY_LOCALE}="${collection}")` } })
.execute();
const categoryId = categoriesResponse.body.results[0]?.id;
if (!categoryId) return [];
// Get filtered product projections
const productProjectionsResponse = await apiRoot
.productProjections()
.search()
.get({
queryArgs: {
limit: DUMMY_LIMIT,
filter: `categories.id:"${categoryId}"`,
priceCurrency: DUMMY_CURRENCY_CODE,
priceCountry: DUMMY_COUNTRY,
sort: sortKey
}
})
.execute();
const products = reshapeProductProjections(productProjectionsResponse.body.results);
return reverse ? products.reverse() : products;
} catch (err) {
return [];
}
}
export async function getCart(cartId: string): Promise<Cart | undefined> {
try {
const response = await apiRoot.carts().withId({ ID: cartId }).get().execute();
return reshapeCart(response.body);
} catch (err) {
return undefined;
}
}
export async function createCart(): Promise<Cart> {
const response = await apiRoot
.carts()
.post({ body: { currency: DUMMY_CURRENCY_CODE, country: DUMMY_COUNTRY } })
.execute();
return reshapeCart(response.body);
}
export async function updateCart(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const activeCartResponse = await apiRoot.carts().withId({ ID: cartId }).get().execute();
const version = activeCartResponse.body.version;
const response = await apiRoot
.carts()
.withId({ ID: cartId })
.post({
body: {
version,
actions: lines.map((line) => ({
action: "changeLineItemQuantity",
lineItemId: line.id,
quantity: line.quantity
}))
}
})
.execute();
return reshapeCart(response.body);
}
export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const activeCartResponse = await apiRoot.carts().withId({ ID: cartId }).get().execute();
const version = activeCartResponse.body.version;
const response = await apiRoot
.carts()
.withId({ ID: cartId })
.post({
body: {
version,
actions: lines.map((line) => ({
action: "addLineItem",
sku: line.merchandiseId,
quantity: line.quantity
}))
}
})
.execute();
return reshapeCart(response.body);
}
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
const activeCartResponse = await apiRoot.carts().withId({ ID: cartId }).get().execute();
const version = activeCartResponse.body.version;
const response = await apiRoot
.carts()
.withId({ ID: cartId })
.post({
body: {
version,
actions: lineIds.map((lineId) => ({
action: "removeLineItem",
lineItemId: lineId
}))
}
})
.execute();
return reshapeCart(response.body);
}

View File

@ -0,0 +1,291 @@
import { Product, Cart, Collection, Menu, Page } from "./types";
import { DUMMY_CURRENCY_CODE, DUMMY_LOCALE, DUMMY_COUNTRY, DUMMY_LIMIT } from "./dummy-data";
import apiRoot from "lib/commercetools/client-builder";
import { NextRequest, NextResponse } from "next/server";
import {
reshapeCategory,
reshapeCategories,
reshapeProductProjection,
reshapeProductProjections,
reshapeCart
} from "./reshapes";
export async function getCollection(handle: string): Promise<Collection | undefined> {
try {
const response = await apiRoot
.categories()
.get({
queryArgs: {
where: `slug(${DUMMY_LOCALE}="${handle}")`
}
})
.execute();
const matchedCategory = response.body.results[0];
if (!matchedCategory) return undefined;
return reshapeCategory(matchedCategory);
} catch (err) {
return undefined;
}
}
export async function getCollections(): Promise<Collection[]> {
try {
const response = await apiRoot.categories().get().execute();
return reshapeCategories(response.body.results);
} catch (err) {
return [];
}
}
/**
* Requests a productprojection based on its handle (slug) from commercetools and reshapes it.
*
* @param {string} handle - The handle (slug) of the product.
* @returns {Promise<Product | undefined>} A promise resolving to the retrieved product or undefined if not found.
*/
export async function getProduct(handle: string): Promise<Product | undefined> {
try {
const response = await apiRoot
.productProjections()
.get({
queryArgs: {
where: `slug(${DUMMY_LOCALE}="${handle}")`,
priceCurrency: DUMMY_CURRENCY_CODE,
priceCountry: DUMMY_COUNTRY
}
})
.execute();
const matchedProductProjection = response.body.results[0];
if (!matchedProductProjection) return undefined;
return reshapeProductProjection(matchedProductProjection);
} catch (err) {
return undefined;
}
}
export async function getProductRecommendations(productId: string): Promise<Product[]> {
return [];
}
/**
* Requests a list of product projections based on optional query, sort key and reverse flag from commercetools and reshapes it.
*
* @param {Object} options - The options object containing query, reverse, and sortKey.
* @param {string} [options.query] - The optional query string for commercetools fulltext search.
* @param {boolean} [options.reverse] - Flag to indicate whether to reverse the products list.
* @param {string} [options.sortKey] - The key to sort the products ().
* @returns {Promise<Product[]>} A promise resolving to an array of products.
*/
export async function getProducts({
query,
reverse,
sortKey
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
try {
const response = await apiRoot
.productProjections()
.search()
.get({
queryArgs: {
limit: DUMMY_LIMIT,
[`text.${DUMMY_LOCALE}`]: query,
priceCurrency: DUMMY_CURRENCY_CODE,
priceCountry: DUMMY_COUNTRY,
sort: sortKey
}
})
.execute();
const products = reshapeProductProjections(response.body.results);
return reverse ? products.reverse() : products;
} catch (err) {
return [];
}
}
/**
* Requests a list of product projections filtered by a collection (category) based on optional sort key and reverse flag from commercetools and reshapes it.
*
* @param {Object} options - The options object containing query, reverse, and sortKey.
* @param {collection} [options.collection] - Handle (slug) of collection
* @param {boolean} [options.reverse] - Flag to indicate whether to reverse the products list.
* @param {string} [options.sortKey] - The key to sort the products ().
* @returns {Promise<Product[]>} A promise resolving to an array of products.
*/
export async function getCollectionProducts({
collection,
reverse,
sortKey
}: {
collection: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
try {
// Get category by slug
const categoriesResponse = await apiRoot
.categories()
.get({ queryArgs: { where: `slug(${DUMMY_LOCALE}="${collection}")` } })
.execute();
const categoryId = categoriesResponse.body.results[0]?.id;
if (!categoryId) return [];
// Get filtered product projections
const productProjectionsResponse = await apiRoot
.productProjections()
.search()
.get({
queryArgs: {
limit: DUMMY_LIMIT,
filter: `categories.id:"${categoryId}"`,
priceCurrency: DUMMY_CURRENCY_CODE,
priceCountry: DUMMY_COUNTRY,
sort: sortKey
}
})
.execute();
const products = reshapeProductProjections(productProjectionsResponse.body.results);
return reverse ? products.reverse() : products;
} catch (err) {
return [];
}
}
export async function getCart(cartId: string): Promise<Cart | undefined> {
try {
const response = await apiRoot.carts().withId({ ID: cartId }).get().execute();
return reshapeCart(response.body);
} catch (err) {
return undefined;
}
}
export async function createCart(): Promise<Cart> {
const response = await apiRoot
.carts()
.post({ body: { currency: DUMMY_CURRENCY_CODE, country: DUMMY_COUNTRY } })
.execute();
return reshapeCart(response.body);
}
export async function updateCart(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const activeCartResponse = await apiRoot.carts().withId({ ID: cartId }).get().execute();
const version = activeCartResponse.body.version;
const response = await apiRoot
.carts()
.withId({ ID: cartId })
.post({
body: {
version,
actions: lines.map((line) => ({
action: "changeLineItemQuantity",
lineItemId: line.id,
quantity: line.quantity
}))
}
})
.execute();
return reshapeCart(response.body);
}
export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const activeCartResponse = await apiRoot.carts().withId({ ID: cartId }).get().execute();
const version = activeCartResponse.body.version;
const response = await apiRoot
.carts()
.withId({ ID: cartId })
.post({
body: {
version,
actions: lines.map((line) => ({
action: "addLineItem",
sku: line.merchandiseId,
quantity: line.quantity
}))
}
})
.execute();
return reshapeCart(response.body);
}
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
const activeCartResponse = await apiRoot.carts().withId({ ID: cartId }).get().execute();
const version = activeCartResponse.body.version;
const response = await apiRoot
.carts()
.withId({ ID: cartId })
.post({
body: {
version,
actions: lineIds.map((lineId) => ({
action: "removeLineItem",
lineItemId: lineId
}))
}
})
.execute();
return reshapeCart(response.body);
}
export async function getMenu(handle: string): Promise<Menu[]> {
switch (handle) {
case "next-js-frontend-footer-menu":
return [
{ title: "Über uns", path: "/" },
{ title: "Kontakt", path: "/" },
{ title: "Impressum", path: "/" }
];
case "next-js-frontend-header-menu":
return [{ title: "Alle Produkte", path: "/search" }];
default:
return [];
}
}
export async function getPage(handle: string): Promise<Page> {
return {
id: handle,
title: handle,
handle: handle,
body: handle,
bodySummary: handle,
createdAt: new Date().toLocaleDateString(),
updatedAt: new Date().toLocaleDateString()
};
}
export async function getPages(): Promise<Page[]> {
return [];
}
export async function revalidate(req: NextRequest): Promise<NextResponse> {
return NextResponse.json({ status: 200 });
}

View File

@ -0,0 +1,285 @@
import {
TypedMoney as CommercetoolsTypedMoney,
Image as CommercetoolsImage,
ProductProjection as CommercetoolsProductProjection,
Cart as CommercetoolsCart,
LineItem as CommercetoolsLineItem,
ProductVariant as CommercetoolsProductVariant,
Attribute as CommercetoolsAttribute,
Category as CommercetoolsCategory
} from "@commercetools/platform-sdk";
import {
Image,
Money,
Product,
ProductOption,
ProductVariant,
Cart,
CartItem,
Collection,
SelectedOption
} from "./types";
import { HIDDEN_PRODUCT_TAG } from "lib/constants";
import { DUMMY_IMAGE, DUMMY_LOCALE, DUMMY_ZERO_EUR0, DUMMY_DELIMITER } from "./dummy-data";
import { isString, isNumber, hasLocalizedStringValue } from "./type-guards";
export function reshapeMoney(typedMoney: CommercetoolsTypedMoney): Money {
const { fractionDigits, currencyCode, type } = typedMoney;
const typedAmount = type === "centPrecision" ? typedMoney.centAmount : typedMoney.preciseAmount;
const amount = (typedAmount / Math.pow(10, fractionDigits)).toString();
return { amount, currencyCode };
}
export function reshapeImage(image: CommercetoolsImage): Image {
// Extracts the filename from the image URL (Extracts the segment between the last '/' and the following '.')
const filename = image.url.match(/.*\/(.*)\..*/)?.[1];
return {
url: image.url,
width: image.dimensions.w,
height: image.dimensions.h,
altText: image.label || filename || ""
};
}
export function reshapeProductProjection(
productProjection: CommercetoolsProductProjection,
filterHiddenProducts: boolean = true
): Product | undefined {
const {
id,
description,
masterVariant,
variants,
slug,
name,
metaTitle,
metaDescription,
metaKeywords,
lastModifiedAt
} = productProjection;
const handle = slug[DUMMY_LOCALE];
const title = name[DUMMY_LOCALE];
const reshapedVariants = reshapeProductVariants(variants);
const reshapedMasterVariant = reshapeProductVariant(masterVariant);
const tags =
metaKeywords?.[DUMMY_LOCALE]?.split(DUMMY_DELIMITER).map((word) => word.trim()) || [];
if (
!reshapedMasterVariant ||
!handle ||
!title ||
(filterHiddenProducts && tags.includes(HIDDEN_PRODUCT_TAG))
)
return undefined;
let priceRange = {
minVariantPrice: reshapedMasterVariant.price,
maxVariantPrice: reshapedMasterVariant.price
};
for (const variant of reshapedVariants) {
const { amount } = variant.price;
if (amount < priceRange.minVariantPrice.amount) priceRange.minVariantPrice = variant.price;
if (amount > priceRange.maxVariantPrice.amount) priceRange.maxVariantPrice = variant.price;
}
// Reduce all images of all variants in one array and remove duplicates.
const images: Image[] = [];
for (const variant of [masterVariant, ...variants]) {
if (!variant.images) continue;
for (const image of variant.images) {
if (!images.some((img) => img.url == image.url)) images.push(reshapeImage(image));
}
}
// Set featured Image to first image or placeholder image.
const featuredImage = images[0] || DUMMY_IMAGE;
const options = reshapeAttributesToOptions(
masterVariant.attributes,
variants.map((variant) => variant.attributes)
);
const allReshapedVariants = [reshapedMasterVariant, ...reshapedVariants];
const product = {
id,
handle,
availableForSale: allReshapedVariants.some((variant) => variant.availableForSale),
title,
description: description?.[DUMMY_LOCALE] || "",
descriptionHtml: description?.[DUMMY_LOCALE] || "", // needs to be replaced
options,
priceRange,
variants: allReshapedVariants,
featuredImage,
images,
seo: {
title: metaTitle?.[DUMMY_LOCALE] || undefined,
description: metaDescription?.[DUMMY_LOCALE] || undefined
},
tags,
updatedAt: lastModifiedAt
};
console.log(JSON.stringify(product));
return product;
}
export function reshapeProductProjections(
productProjections: CommercetoolsProductProjection[]
): Product[] {
return productProjections
.map((item) => reshapeProductProjection(item))
.filter((item) => item !== undefined) as Product[];
}
export function reshapeLineItem(lineItem: CommercetoolsLineItem): CartItem {
const firstVariantImage = lineItem.variant.images?.[0];
const featuredImage = firstVariantImage ? reshapeImage(firstVariantImage) : DUMMY_IMAGE; // needs to be replaced by placeholder image
const selectedOptions = reshapeAttributesToSelectedOptions(lineItem.variant.attributes || []);
return {
id: lineItem.id,
quantity: lineItem.quantity,
cost: { totalAmount: reshapeMoney(lineItem.totalPrice) },
merchandise: {
id: lineItem.variant.sku as string,
title: lineItem.name[DUMMY_LOCALE] || "",
selectedOptions,
product: {
handle: lineItem.productSlug?.[DUMMY_LOCALE] || "",
title: lineItem.name[DUMMY_LOCALE] || "",
featuredImage
}
}
};
}
export function reshapeCart(cart: CommercetoolsCart): Cart {
return {
id: cart.id,
checkoutUrl: "", // needs to be replaced
cost: {
totalAmount: reshapeMoney(cart.taxedPrice?.totalGross || cart.totalPrice),
totalTaxAmount: reshapeMoney(cart.taxedPrice?.totalTax || DUMMY_ZERO_EUR0)
},
lines: cart.lineItems.map((lineItem) => reshapeLineItem(lineItem)),
totalQuantity: cart.totalLineItemQuantity || 0
};
}
export function reshapeProductVariant(
variant: CommercetoolsProductVariant
): ProductVariant | undefined {
const id = variant.sku;
const price = variant.price?.value;
if (!id || !price) return undefined;
const selectedOptions = reshapeAttributesToSelectedOptions(variant.attributes || []);
return {
id,
availableForSale: variant.availability?.isOnStock || false,
selectedOptions,
price: reshapeMoney(price)
};
}
export function reshapeProductVariants(variants: CommercetoolsProductVariant[]): ProductVariant[] {
return variants
.map((variant) => reshapeProductVariant(variant))
.filter((variant) => variant !== undefined) as ProductVariant[];
}
/**
* Reshapes attributes of all variants of a product into product options.
* All key value pairs of the existing attributes are combined and then all values with a common key are combined.
*
* @param {CommercetoolsAttribute[] | undefined} masterVariantAttributes - Master variant attributes.
* @param {CommercetoolsAttribute[] | undefined} variantAttributes - Variant attributes.
* @returns {ProductOption[]} - Array of product options.
*/
export function reshapeAttributesToOptions(
masterVariantAttributes: CommercetoolsAttribute[] | undefined,
variantAttributes: (CommercetoolsAttribute[] | undefined)[]
): ProductOption[] {
const attributes = [masterVariantAttributes, ...variantAttributes].flat();
// Remove all attributes that are undefined and group the values with the same name
const options: ProductOption[] = [];
for (const attribute of attributes) {
if (!attribute) continue;
const { name, value } = attribute;
let valueAsString: string;
// Check whether the value is a string, a number or a localized string and convert it to a string, if it is not, stop the iteration.
if (isString(value)) valueAsString = value;
else if (isNumber(value)) valueAsString = value.toString();
else if (hasLocalizedStringValue(value, DUMMY_LOCALE)) valueAsString = value[DUMMY_LOCALE];
else continue;
// Add the current attribute to options if it does not yet exist. If it does exist, add the value of the attribute to the associated option if the value does not yet exist.
const index = options.findIndex((option) => option.name === name);
if (index === -1) {
options.push({ name, id: name, values: [valueAsString] });
} else {
const existingOption = options[index] as ProductOption;
if (!existingOption.values.includes(valueAsString)) existingOption.values.push(valueAsString);
}
}
return options;
}
/**
* Reshapes a list of attributes into selected options, extracting the name and value.
*
* @param {CommercetoolsAttribute[]} attributes - The list of attributes to reshape.
* @returns {SelectedOption[]} The array of selected options with their names and values.
*/
export function reshapeAttributesToSelectedOptions(
attributes: CommercetoolsAttribute[]
): SelectedOption[] {
const selectedOptions = [];
for (const { name, value } of attributes) {
if (isString(value)) selectedOptions.push({ name, value });
else if (isNumber(value)) selectedOptions.push({ name, value: value.toString() });
else if (hasLocalizedStringValue(value, DUMMY_LOCALE))
selectedOptions.push({ name, value: value[DUMMY_LOCALE] });
}
return selectedOptions;
}
export function reshapeCategory(category: CommercetoolsCategory): Collection | undefined {
const { name, slug, description, metaTitle, metaDescription, lastModifiedAt } = category;
const handle = slug[DUMMY_LOCALE];
const title = name[DUMMY_LOCALE];
if (!handle || !title) return undefined;
return {
title,
handle,
description: description?.[DUMMY_LOCALE] || "",
seo: {
title: metaTitle?.[DUMMY_LOCALE] || undefined,
description: metaDescription?.[DUMMY_LOCALE] || undefined
},
updatedAt: lastModifiedAt,
path: `/search/${handle}`
};
}
export function reshapeCategories(categories: CommercetoolsCategory[]): Collection[] {
return categories
.map((item) => reshapeCategory(item))
.filter((item) => item !== undefined) as Collection[];
}

View File

@ -92,11 +92,13 @@ export type ProductOption = {
export type ProductVariant = {
id: string;
availableForSale: boolean;
selectedOptions: {
selectedOptions: SelectedOption[];
price: Money;
};
export type SelectedOption = {
name: string;
value: string;
}[];
price: Money;
};
export type SEO = {

View File

@ -24,7 +24,7 @@ export const sorting: SortFilterItem[] = [
sortKey: "reviewRatingStatistics.averageRating desc",
reverse: false
},
{ title: "Latest arrivals", slug: "latest-desc", sortKey: "createdAt desc", reverse: false }, //ctp: createdAt
{ title: "Latest arrivals", slug: "latest-desc", sortKey: "createdAt desc", reverse: false },
{
title: "Price: Low to high",
slug: "price-asc",
@ -47,4 +47,3 @@ export const TAGS = {
export const HIDDEN_PRODUCT_TAG = "nextjs-frontend-hidden";
export const DEFAULT_OPTION = "Default Title";
export const SHOPIFY_GRAPHQL_API_ENDPOINT = "/api/2023-01/graphql.json";

View File

@ -1,53 +0,0 @@
import productFragment from "./product";
const cartFragment = /* GraphQL */ `
fragment cart on Cart {
id
checkoutUrl
cost {
subtotalAmount {
amount
currencyCode
}
totalAmount {
amount
currencyCode
}
totalTaxAmount {
amount
currencyCode
}
}
lines(first: 100) {
edges {
node {
id
quantity
cost {
totalAmount {
amount
currencyCode
}
}
merchandise {
... on ProductVariant {
id
title
selectedOptions {
name
value
}
product {
...product
}
}
}
}
}
}
totalQuantity
}
${productFragment}
`;
export default cartFragment;

View File

@ -1,10 +0,0 @@
const imageFragment = /* GraphQL */ `
fragment image on Image {
url
altText
width
height
}
`;
export default imageFragment;

View File

@ -1,64 +0,0 @@
import imageFragment from "./image";
import seoFragment from "./seo";
const productFragment = /* GraphQL */ `
fragment product on Product {
id
handle
availableForSale
title
description
descriptionHtml
options {
id
name
values
}
priceRange {
maxVariantPrice {
amount
currencyCode
}
minVariantPrice {
amount
currencyCode
}
}
variants(first: 250) {
edges {
node {
id
title
availableForSale
selectedOptions {
name
value
}
price {
amount
currencyCode
}
}
}
}
featuredImage {
...image
}
images(first: 20) {
edges {
node {
...image
}
}
}
seo {
...seo
}
tags
updatedAt
}
${imageFragment}
${seoFragment}
`;
export default productFragment;

View File

@ -1,8 +0,0 @@
const seoFragment = /* GraphQL */ `
fragment seo on SEO {
description
title
}
`;
export default seoFragment;

View File

@ -1,450 +0,0 @@
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from "lib/constants";
import { isShopifyError } from "lib/type-guards";
import { ensureStartsWith } from "lib/utils";
import { revalidateTag } from "next/cache";
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import {
addToCartMutation,
createCartMutation,
editCartItemsMutation,
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";
import {
getProductQuery,
getProductRecommendationsQuery,
getProductsQuery
} from "./queries/product";
import {
Cart,
Collection,
Connection,
Image,
Menu,
Page,
Product,
ShopifyAddToCartOperation,
ShopifyCart,
ShopifyCartOperation,
ShopifyCollection,
ShopifyCollectionOperation,
ShopifyCollectionProductsOperation,
ShopifyCollectionsOperation,
ShopifyCreateCartOperation,
ShopifyMenuOperation,
ShopifyPageOperation,
ShopifyPagesOperation,
ShopifyProduct,
ShopifyProductOperation,
ShopifyProductRecommendationsOperation,
ShopifyProductsOperation,
ShopifyRemoveFromCartOperation,
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!;
type ExtractVariables<T> = T extends { variables: object } ? T["variables"] : never;
export async function shopifyFetch<T>({
cache = "force-cache",
headers,
query,
tags,
variables
}: {
cache?: RequestCache;
headers?: HeadersInit;
query: string;
tags?: string[];
variables?: ExtractVariables<T>;
}): Promise<{ status: number; body: T } | never> {
try {
const result = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Storefront-Access-Token": key,
...headers
},
body: JSON.stringify({
...(query && { query }),
...(variables && { variables })
}),
cache,
...(tags && { next: { tags } })
});
const body = await result.json();
if (body.errors) {
throw body.errors[0];
}
return {
status: result.status,
body
};
} catch (e) {
if (isShopifyError(e)) {
throw {
cause: e.cause?.toString() || "unknown",
status: e.status || 500,
message: e.message,
query
};
}
throw {
error: e,
query
};
}
}
const removeEdgesAndNodes = (array: Connection<any>) => {
return array.edges.map((edge) => edge?.node);
};
const reshapeCart = (cart: ShopifyCart): Cart => {
if (!cart.cost?.totalTaxAmount) {
cart.cost.totalTaxAmount = {
amount: "0.0",
currencyCode: "USD"
};
}
return {
...cart,
lines: removeEdgesAndNodes(cart.lines)
};
};
const reshapeCollection = (collection: ShopifyCollection): Collection | undefined => {
if (!collection) {
return undefined;
}
return {
...collection,
path: `/search/${collection.handle}`
};
};
const reshapeCollections = (collections: ShopifyCollection[]) => {
const reshapedCollections = [];
for (const collection of collections) {
if (collection) {
const reshapedCollection = reshapeCollection(collection);
if (reshapedCollection) {
reshapedCollections.push(reshapedCollection);
}
}
}
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;
}
const { images, variants, ...rest } = product;
return {
...rest,
images: reshapeImages(images, product.title),
variants: removeEdgesAndNodes(variants)
};
};
const reshapeProducts = (products: ShopifyProduct[]) => {
const reshapedProducts = [];
for (const product of products) {
if (product) {
const reshapedProduct = reshapeProduct(product);
if (reshapedProduct) {
reshapedProducts.push(reshapedProduct);
}
}
}
return reshapedProducts;
};
export async function createCart(): Promise<Cart> {
const res = await shopifyFetch<ShopifyCreateCartOperation>({
query: createCartMutation,
cache: "no-store"
});
return reshapeCart(res.body.data.cartCreate.cart);
}
export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyAddToCartOperation>({
query: addToCartMutation,
variables: {
cartId,
lines
},
cache: "no-store"
});
return reshapeCart(res.body.data.cartLinesAdd.cart);
}
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
query: removeFromCartMutation,
variables: {
cartId,
lineIds
},
cache: "no-store"
});
return reshapeCart(res.body.data.cartLinesRemove.cart);
}
export async function updateCart(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyUpdateCartOperation>({
query: editCartItemsMutation,
variables: {
cartId,
lines
},
cache: "no-store"
});
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
export async function getCart(cartId: string): Promise<Cart | undefined> {
const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery,
variables: { cartId },
tags: [TAGS.cart],
cache: "no-store"
});
// Old carts becomes `null` when you checkout.
if (!res.body.data.cart) {
return undefined;
}
return reshapeCart(res.body.data.cart);
}
export async function getCollection(handle: string): Promise<Collection | undefined> {
const res = await shopifyFetch<ShopifyCollectionOperation>({
query: getCollectionQuery,
tags: [TAGS.collections],
variables: {
handle
}
});
return reshapeCollection(res.body.data.collection);
}
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: collection,
reverse,
sortKey: sortKey === "CREATED_AT" ? "CREATED" : sortKey
}
});
if (!res.body.data.collection) {
console.log(`No collection found for \`${collection}\``);
return [];
}
return reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products));
}
export async function getCollections(): Promise<Collection[]> {
const res = await shopifyFetch<ShopifyCollectionsOperation>({
query: getCollectionsQuery,
tags: [TAGS.collections]
});
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")
)
];
return collections;
}
export async function getMenu(handle: string): Promise<Menu[]> {
const res = await shopifyFetch<ShopifyMenuOperation>({
query: getMenuQuery,
tags: [TAGS.collections],
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", "")
})) || []
);
}
export async function getPage(handle: string): Promise<Page> {
const res = await shopifyFetch<ShopifyPageOperation>({
query: getPageQuery,
variables: { handle }
});
return res.body.data.pageByHandle;
}
export async function getPages(): Promise<Page[]> {
const res = await shopifyFetch<ShopifyPagesOperation>({
query: getPagesQuery
});
return removeEdgesAndNodes(res.body.data.pages);
}
export async function getProduct(handle: string): Promise<Product | undefined> {
const res = await shopifyFetch<ShopifyProductOperation>({
query: getProductQuery,
tags: [TAGS.products],
variables: {
handle
}
});
return reshapeProduct(res.body.data.product, false);
}
export async function getProductRecommendations(productId: string): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
query: getProductRecommendationsQuery,
tags: [TAGS.products],
variables: {
productId
}
});
return reshapeProducts(res.body.data.productRecommendations);
}
export async function getProducts({
query,
reverse,
sortKey
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductsOperation>({
query: getProductsQuery,
tags: [TAGS.products],
variables: {
query,
reverse,
sortKey
}
});
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

@ -1,45 +0,0 @@
import cartFragment from "../fragments/cart";
export const addToCartMutation = /* GraphQL */ `
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const createCartMutation = /* GraphQL */ `
mutation createCart($lineItems: [CartLineInput!]) {
cartCreate(input: { lines: $lineItems }) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const editCartItemsMutation = /* GraphQL */ `
mutation editCartItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const removeFromCartMutation = /* GraphQL */ `
mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
...cart
}
}
}
${cartFragment}
`;

View File

@ -1,10 +0,0 @@
import cartFragment from "../fragments/cart";
export const getCartQuery = /* GraphQL */ `
query getCart($cartId: ID!) {
cart(id: $cartId) {
...cart
}
}
${cartFragment}
`;

View File

@ -1,56 +0,0 @@
import productFragment from "../fragments/product";
import seoFragment from "../fragments/seo";
const collectionFragment = /* GraphQL */ `
fragment collection on Collection {
handle
title
description
seo {
...seo
}
updatedAt
}
${seoFragment}
`;
export const getCollectionQuery = /* GraphQL */ `
query getCollection($handle: String!) {
collection(handle: $handle) {
...collection
}
}
${collectionFragment}
`;
export const getCollectionsQuery = /* GraphQL */ `
query getCollections {
collections(first: 100, sortKey: TITLE) {
edges {
node {
...collection
}
}
}
}
${collectionFragment}
`;
export const getCollectionProductsQuery = /* GraphQL */ `
query getCollectionProducts(
$handle: String!
$sortKey: ProductCollectionSortKeys
$reverse: Boolean
) {
collection(handle: $handle) {
products(sortKey: $sortKey, reverse: $reverse, first: 100) {
edges {
node {
...product
}
}
}
}
}
${productFragment}
`;

View File

@ -1,10 +0,0 @@
export const getMenuQuery = /* GraphQL */ `
query getMenu($handle: String!) {
menu(handle: $handle) {
items {
title
url
}
}
}
`;

View File

@ -1,41 +0,0 @@
import seoFragment from "../fragments/seo";
const pageFragment = /* GraphQL */ `
fragment page on Page {
... on Page {
id
title
handle
body
bodySummary
seo {
...seo
}
createdAt
updatedAt
}
}
${seoFragment}
`;
export const getPageQuery = /* GraphQL */ `
query getPage($handle: String!) {
pageByHandle(handle: $handle) {
...page
}
}
${pageFragment}
`;
export const getPagesQuery = /* GraphQL */ `
query getPages {
pages(first: 100) {
edges {
node {
...page
}
}
}
}
${pageFragment}
`;

View File

@ -1,32 +0,0 @@
import productFragment from "../fragments/product";
export const getProductQuery = /* GraphQL */ `
query getProduct($handle: String!) {
product(handle: $handle) {
...product
}
}
${productFragment}
`;
export const getProductsQuery = /* GraphQL */ `
query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) {
products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
edges {
node {
...product
}
}
}
}
${productFragment}
`;
export const getProductRecommendationsQuery = /* GraphQL */ `
query getProductRecommendations($productId: ID!) {
productRecommendations(productId: $productId) {
...product
}
}
${productFragment}
`;

View File

@ -1,265 +0,0 @@
export type Maybe<T> = T | null;
export type Connection<T> = {
edges: Array<Edge<T>>;
};
export type Edge<T> = {
node: T;
};
export type Cart = Omit<ShopifyCart, "lines"> & {
lines: CartItem[];
};
export type CartItem = {
id: string;
quantity: number;
cost: {
totalAmount: Money;
};
merchandise: {
id: string;
title: string;
selectedOptions: {
name: string;
value: string;
}[];
product: Product;
};
};
export type Collection = ShopifyCollection & {
path: string;
};
export type Image = {
url: string;
altText: string;
width: number;
height: number;
};
export type Menu = {
title: string;
path: string;
};
export type Money = {
amount: string;
currencyCode: string;
};
export type Page = {
id: string;
title: string;
handle: string;
body: string;
bodySummary: string;
seo?: SEO;
createdAt: string;
updatedAt: string;
};
export type Product = Omit<ShopifyProduct, "variants" | "images"> & {
variants: ProductVariant[];
images: Image[];
};
export type ProductOption = {
id: string;
name: string;
values: string[];
};
export type ProductVariant = {
id: string;
title: string;
availableForSale: boolean;
selectedOptions: {
name: string;
value: string;
}[];
price: Money;
};
export type SEO = {
title: string;
description: string;
};
export type ShopifyCart = {
id: string;
checkoutUrl: string;
cost: {
subtotalAmount: Money;
totalAmount: Money;
totalTaxAmount: Money;
};
lines: Connection<CartItem>;
totalQuantity: number;
};
export type ShopifyCollection = {
handle: string;
title: string;
description: string;
seo: SEO;
updatedAt: string;
};
export type ShopifyProduct = {
id: string;
handle: string;
availableForSale: boolean;
title: string;
description: string;
descriptionHtml: string;
options: ProductOption[];
priceRange: {
maxVariantPrice: Money;
minVariantPrice: Money;
};
variants: Connection<ProductVariant>;
featuredImage: Image;
images: Connection<Image>;
seo: SEO;
tags: string[];
updatedAt: string;
};
export type ShopifyCartOperation = {
data: {
cart: ShopifyCart;
};
variables: {
cartId: string;
};
};
export type ShopifyCreateCartOperation = {
data: { cartCreate: { cart: ShopifyCart } };
};
export type ShopifyAddToCartOperation = {
data: {
cartLinesAdd: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyRemoveFromCartOperation = {
data: {
cartLinesRemove: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lineIds: string[];
};
};
export type ShopifyUpdateCartOperation = {
data: {
cartLinesUpdate: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
id: string;
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyCollectionOperation = {
data: {
collection: ShopifyCollection;
};
variables: {
handle: string;
};
};
export type ShopifyCollectionProductsOperation = {
data: {
collection: {
products: Connection<ShopifyProduct>;
};
};
variables: {
handle: string;
reverse?: boolean;
sortKey?: string;
};
};
export type ShopifyCollectionsOperation = {
data: {
collections: Connection<ShopifyCollection>;
};
};
export type ShopifyMenuOperation = {
data: {
menu?: {
items: {
title: string;
url: string;
}[];
};
};
variables: {
handle: string;
};
};
export type ShopifyPageOperation = {
data: { pageByHandle: Page };
variables: { handle: string };
};
export type ShopifyPagesOperation = {
data: {
pages: Connection<Page>;
};
};
export type ShopifyProductOperation = {
data: { product: ShopifyProduct };
variables: {
handle: string;
};
};
export type ShopifyProductRecommendationsOperation = {
data: {
productRecommendations: ShopifyProduct[];
};
variables: {
productId: string;
};
};
export type ShopifyProductsOperation = {
data: {
products: Connection<ShopifyProduct>;
};
variables: {
query?: string;
reverse?: boolean;
sortKey?: string;
};
};

View File

@ -1,27 +0,0 @@
export interface ShopifyErrorLike {
status: number;
message: Error;
cause?: Error;
}
export const isObject = (object: unknown): object is Record<string, unknown> => {
return typeof object === "object" && object !== null && !Array.isArray(object);
};
export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
if (!isObject(error)) return false;
if (error instanceof Error) return true;
return findError(error);
};
function findError<T extends object>(error: T): boolean {
if (Object.prototype.toString.call(error) === "[object Error]") {
return true;
}
const prototype = Object.getPrototypeOf(error) as T | null;
return prototype === null ? false : findError(prototype);
}

View File

@ -11,7 +11,14 @@ export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
export const validateEnvironmentVariables = () => {
const requiredEnvironmentVariables = ["SHOPIFY_STORE_DOMAIN", "SHOPIFY_STOREFRONT_ACCESS_TOKEN"];
const requiredEnvironmentVariables = [
"CTP_PROJECT_KEY",
"CTP_CLIENT_SECRET",
"CTP_CLIENT_ID",
"CTP_AUTH_URL",
"CTP_API_URL",
"CTP_SCOPES"
];
const missingEnvironmentVariables = [] as string[];
requiredEnvironmentVariables.forEach((envVar) => {
@ -22,18 +29,7 @@ export const validateEnvironmentVariables = () => {
if (missingEnvironmentVariables.length) {
throw new Error(
`The following environment variables are missing. Your site will not work without them. Read more: https://vercel.com/docs/integrations/shopify#configure-environment-variables\n\n${missingEnvironmentVariables.join(
"\n"
)}\n`
);
}
if (
process.env.SHOPIFY_STORE_DOMAIN?.includes("[") ||
process.env.SHOPIFY_STORE_DOMAIN?.includes("]")
) {
throw new Error(
"Your `SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them."
`The following environment variables are missing. Your site will not work without them.}\n`
);
}
};

View File

@ -9,11 +9,6 @@ module.exports = {
remotePatterns: [
{ hostname: "picsum.photos" },
{ hostname: "storage.googleapis.com" },
{
protocol: "https",
hostname: "cdn.shopify.com",
pathname: "/s/files/**"
},
{
hostname: "**.cf3.rackcdn.com",
protocol: "https",