mirror of
https://github.com/vercel/commerce.git
synced 2025-05-18 23:46:58 +00:00
Feat/remove shopify (#12)
This commit is contained in:
parent
33c82d2d70
commit
bb2c00c5c7
13
.env.example
13
.env.example
@ -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"
|
@ -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`
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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 = {
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { getCart } from "lib/commercetools";
|
||||
import { getCart } from "lib/commercetools/queries";
|
||||
import { cookies } from "next/headers";
|
||||
import CartModal from "./modal";
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
|
@ -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";
|
||||
|
@ -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[] }) {
|
||||
|
@ -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() {
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
291
lib/commercetools/queries.ts
Normal file
291
lib/commercetools/queries.ts
Normal 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 });
|
||||
}
|
285
lib/commercetools/reshapes.ts
Normal file
285
lib/commercetools/reshapes.ts
Normal 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[];
|
||||
}
|
@ -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 = {
|
||||
|
@ -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";
|
||||
|
@ -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;
|
@ -1,10 +0,0 @@
|
||||
const imageFragment = /* GraphQL */ `
|
||||
fragment image on Image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
`;
|
||||
|
||||
export default imageFragment;
|
@ -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;
|
@ -1,8 +0,0 @@
|
||||
const seoFragment = /* GraphQL */ `
|
||||
fragment seo on SEO {
|
||||
description
|
||||
title
|
||||
}
|
||||
`;
|
||||
|
||||
export default seoFragment;
|
@ -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() });
|
||||
}
|
@ -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}
|
||||
`;
|
@ -1,10 +0,0 @@
|
||||
import cartFragment from "../fragments/cart";
|
||||
|
||||
export const getCartQuery = /* GraphQL */ `
|
||||
query getCart($cartId: ID!) {
|
||||
cart(id: $cartId) {
|
||||
...cart
|
||||
}
|
||||
}
|
||||
${cartFragment}
|
||||
`;
|
@ -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}
|
||||
`;
|
@ -1,10 +0,0 @@
|
||||
export const getMenuQuery = /* GraphQL */ `
|
||||
query getMenu($handle: String!) {
|
||||
menu(handle: $handle) {
|
||||
items {
|
||||
title
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
@ -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}
|
||||
`;
|
@ -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}
|
||||
`;
|
@ -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;
|
||||
};
|
||||
};
|
@ -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);
|
||||
}
|
22
lib/utils.ts
22
lib/utils.ts
@ -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`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user