diff --git a/app/product/[handle]/page.tsx b/app/product/[handle]/page.tsx index 6c3d4ee91..c3770826d 100644 --- a/app/product/[handle]/page.tsx +++ b/app/product/[handle]/page.tsx @@ -7,8 +7,8 @@ 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/shopify"; -import { Image } from "lib/shopify/types"; +import { getProduct, getProductRecommendations } from "lib/commercetools"; +import { Image } from "lib/commercetools/types"; 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 = { diff --git a/app/search/[collection]/opengraph-image.tsx b/app/search/[collection]/opengraph-image.tsx index 09472dcef..33848df51 100644 --- a/app/search/[collection]/opengraph-image.tsx +++ b/app/search/[collection]/opengraph-image.tsx @@ -1,5 +1,5 @@ import OpengraphImage from "components/opengraph-image"; -import { getCollection } from "lib/shopify"; +import { getCollection } from "lib/commercetools"; export const runtime = "edge"; diff --git a/app/search/[collection]/page.tsx b/app/search/[collection]/page.tsx index 1caf3bc58..92fe7021a 100644 --- a/app/search/[collection]/page.tsx +++ b/app/search/[collection]/page.tsx @@ -1,4 +1,4 @@ -import { getCollection, getCollectionProducts } from "lib/shopify"; +import { getCollection, getCollectionProducts } from "lib/commercetools"; import { Metadata } from "next"; import { notFound } from "next/navigation"; diff --git a/app/search/page.tsx b/app/search/page.tsx index 1fe8c9628..6b79da8a8 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -1,7 +1,7 @@ import Grid from "components/grid"; import ProductGridItems from "components/layout/product-grid-items"; import { defaultSort, sorting } from "lib/constants"; -import { getProducts } from "lib/shopify"; +import { getProducts } from "lib/commercetools"; export const runtime = "edge"; diff --git a/app/sitemap.ts b/app/sitemap.ts index 01ab20b34..af8f09c8b 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,4 +1,5 @@ -import { getCollections, getPages, getProducts } from "lib/shopify"; +import { getCollections, getProducts } from "lib/commercetools"; +import { getPages } from "lib/shopify"; import { validateEnvironmentVariables } from "lib/utils"; import { MetadataRoute } from "next"; diff --git a/components/carousel.tsx b/components/carousel.tsx index 9dc3d40fe..9b191eb8c 100644 --- a/components/carousel.tsx +++ b/components/carousel.tsx @@ -1,4 +1,4 @@ -import { getCollectionProducts } from "lib/shopify"; +import { getCollectionProducts } from "lib/commercetools"; import Link from "next/link"; import { GridTileImage } from "./grid/tile"; diff --git a/components/cart/actions.ts b/components/cart/actions.ts index 187ede5a4..06a968335 100644 --- a/components/cart/actions.ts +++ b/components/cart/actions.ts @@ -1,7 +1,7 @@ "use server"; import { TAGS } from "lib/constants"; -import { addToCart, createCart, getCart, removeFromCart, updateCart } from "lib/shopify"; +import { addToCart, createCart, getCart, removeFromCart, updateCart } from "lib/commercetools"; import { revalidateTag } from "next/cache"; import { cookies } from "next/headers"; diff --git a/components/cart/add-to-cart.tsx b/components/cart/add-to-cart.tsx index 77b5196b3..e5ed612c7 100644 --- a/components/cart/add-to-cart.tsx +++ b/components/cart/add-to-cart.tsx @@ -4,9 +4,10 @@ import { PlusIcon } from "@heroicons/react/24/outline"; import clsx from "clsx"; import { addItem } from "components/cart/actions"; import LoadingDots from "components/loading-dots"; -import { ProductVariant } from "lib/shopify/types"; +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, @@ -82,7 +83,7 @@ export function AddToCart({ const actionWithVariant = formAction.bind(null, selectedVariantId); return ( -
+ {}}>

{message} diff --git a/components/cart/delete-item-button.tsx b/components/cart/delete-item-button.tsx index 451765256..793172ed8 100644 --- a/components/cart/delete-item-button.tsx +++ b/components/cart/delete-item-button.tsx @@ -4,7 +4,7 @@ import { XMarkIcon } from "@heroicons/react/24/outline"; import clsx from "clsx"; import { removeItem } from "components/cart/actions"; import LoadingDots from "components/loading-dots"; -import type { CartItem } from "lib/shopify/types"; +import type { CartItem } from "lib/commercetools/types"; import { useFormState, useFormStatus } from "react-dom"; function SubmitButton() { diff --git a/components/cart/edit-item-quantity-button.tsx b/components/cart/edit-item-quantity-button.tsx index 7828bd330..d87dc3a53 100644 --- a/components/cart/edit-item-quantity-button.tsx +++ b/components/cart/edit-item-quantity-button.tsx @@ -4,7 +4,7 @@ import { MinusIcon, PlusIcon } from "@heroicons/react/24/outline"; import clsx from "clsx"; import { updateItemQuantity } from "components/cart/actions"; import LoadingDots from "components/loading-dots"; -import type { CartItem } from "lib/shopify/types"; +import type { CartItem } from "lib/commercetools/types"; import { useFormState, useFormStatus } from "react-dom"; function SubmitButton({ type }: { type: "plus" | "minus" }) { diff --git a/components/cart/index.tsx b/components/cart/index.tsx index eeb6e983a..6116f025c 100644 --- a/components/cart/index.tsx +++ b/components/cart/index.tsx @@ -1,4 +1,4 @@ -import { getCart } from "lib/shopify"; +import { getCart } from "lib/commercetools"; import { cookies } from "next/headers"; import CartModal from "./modal"; diff --git a/components/cart/modal.tsx b/components/cart/modal.tsx index 56c600eaa..72780b145 100644 --- a/components/cart/modal.tsx +++ b/components/cart/modal.tsx @@ -4,7 +4,7 @@ import { Dialog, Transition } from "@headlessui/react"; import { ShoppingCartIcon } from "@heroicons/react/24/outline"; import Price from "components/price"; import { DEFAULT_OPTION } from "lib/constants"; -import type { Cart } from "lib/shopify/types"; +import type { Cart } from "lib/commercetools/types"; import { createUrl } from "lib/utils"; import Image from "next/image"; import Link from "next/link"; diff --git a/components/grid/three-items.tsx b/components/grid/three-items.tsx index 06d8e7424..bd233e93f 100644 --- a/components/grid/three-items.tsx +++ b/components/grid/three-items.tsx @@ -1,6 +1,6 @@ import { GridTileImage } from "components/grid/tile"; -import { getCollectionProducts } from "lib/shopify"; -import type { Product } from "lib/shopify/types"; +import { getCollectionProducts } from "lib/commercetools"; +import type { Product } from "lib/commercetools/types"; import Link from "next/link"; function ThreeItemGridItem({ diff --git a/components/layout/product-grid-items.tsx b/components/layout/product-grid-items.tsx index c495775ff..477123152 100644 --- a/components/layout/product-grid-items.tsx +++ b/components/layout/product-grid-items.tsx @@ -1,6 +1,6 @@ import Grid from "components/grid"; import { GridTileImage } from "components/grid/tile"; -import { Product } from "lib/shopify/types"; +import { Product } from "lib/commercetools/types"; import Link from "next/link"; export default function ProductGridItems({ products }: { products: Product[] }) { diff --git a/components/layout/search/collections.tsx b/components/layout/search/collections.tsx index 0da36d025..e2ebed9af 100644 --- a/components/layout/search/collections.tsx +++ b/components/layout/search/collections.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import { Suspense } from "react"; -import { getCollections } from "lib/shopify"; +import { getCollections } from "lib/commercetools"; import FilterList from "./filter"; async function CollectionList() { diff --git a/components/product/product-description.tsx b/components/product/product-description.tsx index e0fe5d763..30413ad57 100644 --- a/components/product/product-description.tsx +++ b/components/product/product-description.tsx @@ -1,7 +1,7 @@ import { AddToCart } from "components/cart/add-to-cart"; import Price from "components/price"; import Prose from "components/prose"; -import { Product } from "lib/shopify/types"; +import { Product } from "lib/commercetools/types"; import { VariantSelector } from "./variant-selector"; export function ProductDescription({ product }: { product: Product }) { diff --git a/components/product/variant-selector.tsx b/components/product/variant-selector.tsx index 5d48d9181..da1d3cccc 100644 --- a/components/product/variant-selector.tsx +++ b/components/product/variant-selector.tsx @@ -1,7 +1,7 @@ "use client"; import clsx from "clsx"; -import { ProductOption, ProductVariant } from "lib/shopify/types"; +import { ProductOption, ProductVariant } from "lib/commercetools/types"; import { createUrl } from "lib/utils"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; diff --git a/jest.config.js b/jest.config.js index d7cf85757..fb222caf3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,8 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: "ts-jest", - testEnvironment: "node" + testEnvironment: "node", + moduleNameMapper: { + "^lib/(.*)$": "/lib/$1" + } }; diff --git a/lib/client-builder.ts b/lib/client-builder.ts index 3de41d5af..6ac795992 100644 --- a/lib/client-builder.ts +++ b/lib/client-builder.ts @@ -30,7 +30,6 @@ const ctpClient = new ClientBuilder() .withProjectKey(projectKey) .withClientCredentialsFlow(authMiddlewareOptions) .withHttpMiddleware(httpMiddlewareOptions) - .withLoggerMiddleware() .build(); const apiRoot = createApiBuilderFromCtpClient(ctpClient).withProjectKey({ diff --git a/lib/commercetools/dummy-data.ts b/lib/commercetools/dummy-data.ts new file mode 100644 index 000000000..1bcd1d5f7 --- /dev/null +++ b/lib/commercetools/dummy-data.ts @@ -0,0 +1,34 @@ +import { CentPrecisionMoney as CommercetoolsCentPrecisionMoney } from "@commercetools/platform-sdk"; + +// Dummy locale to be used as key for 'LocalizedString' and locale projections (https://docs.commercetools.com/api/types#localizedstring) +export const DUMMY_LOCALE = "de-DE"; + +// Dummy country for query parameters for product selection +export const DUMMY_COUNTRY = "DE"; + +// Dummy currency code for query parameters for product selection +export const DUMMY_CURRENCY_CODE = "EUR"; + +// Dummy pricerange for products +export const DUMMY_PRICERANGE = { + minVariantPrice: { amount: "1", currencyCode: "EUR" }, + maxVariantPrice: { amount: "99.99", currencyCode: "EUR" } +}; + +export const DUMMY_DELIMITER = " "; +export const DUMMY_LIMIT = 200; + +// Dummy image for products +export const DUMMY_IMAGE = { + url: "https://picsum.photos/800/600", + width: 800, + height: 800, + altText: "dummy-image" +}; + +export const DUMMY_ZERO_EUR0: CommercetoolsCentPrecisionMoney = { + centAmount: 0, + fractionDigits: 2, + currencyCode: "EUR", + type: "centPrecision" +}; diff --git a/lib/commercetools/index.jest.test.ts b/lib/commercetools/index.jest.test.ts deleted file mode 100644 index 3a9b184d8..000000000 --- a/lib/commercetools/index.jest.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { reshapeMoney, reshapeImage } from "."; -import { - CentPrecisionMoney as CommercetoolsCentPrecisionMoney, - HighPrecisionMoney as CommercetoolsHighPrecisionMoney -} from "@commercetools/platform-sdk"; - -describe("reshape & fetch functions", () => { - describe("reshapeMoney", () => { - it("returns correct amount for cent precision", () => { - const centPrecisionMoney: CommercetoolsCentPrecisionMoney = { - type: "centPrecision", - centAmount: 1001, - currencyCode: "EUR", - fractionDigits: 2 - }; - - const result = reshapeMoney(centPrecisionMoney); - - const expectedResult = { amount: "10.01", currencyCode: "EUR" }; - expect(result).toEqual(expectedResult); - }); - - it("returns correct amount for high precision", () => { - const highPrecisionMoney: CommercetoolsHighPrecisionMoney = { - type: "highPrecision", - preciseAmount: 100001, - centAmount: 1000, - currencyCode: "EUR", - fractionDigits: 4 - }; - - const result = reshapeMoney(highPrecisionMoney); - - const expectedResult = { amount: "10.0001", currencyCode: "EUR" }; - expect(result).toEqual(expectedResult); - }); - }); - - describe("reshapeImage", () => { - it("returns correctly reshaped image with alt text", () => { - const image = { - url: "an-url", - dimensions: { w: 800, h: 600 }, - label: "a-label" - }; - - const result = reshapeImage(image); - - const expectedResult = { url: "an-url", width: 800, height: 600, altText: "a-label" }; - - expect(result).toEqual(expectedResult); - }); - - it("returns correctly reshaped image with empty string as alt text", () => { - const image = { - url: "an-url", - dimensions: { w: 800, h: 600 } - }; - - const result = reshapeImage(image); - - const expectedResult = { url: "an-url", width: 800, height: 600, altText: "" }; - - expect(result).toEqual(expectedResult); - }); - }); -}); diff --git a/lib/commercetools/index.ts b/lib/commercetools/index.ts index d98c5d087..b67e72b79 100644 --- a/lib/commercetools/index.ts +++ b/lib/commercetools/index.ts @@ -1,13 +1,37 @@ import { TypedMoney as CommercetoolsTypedMoney, Image as CommercetoolsImage, - CentPrecisionMoney, - HighPrecisionMoney, - TypedMoney + 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 } from "./types"; +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"; -export function reshapeMoney(typedMoney: TypedMoney): Money { +function reshapeMoney(typedMoney: CommercetoolsTypedMoney): Money { const { fractionDigits, currencyCode, type } = typedMoney; const typedAmount = type === "centPrecision" ? typedMoney.centAmount : typedMoney.preciseAmount; @@ -15,11 +39,479 @@ export function reshapeMoney(typedMoney: TypedMoney): Money { return { amount, currencyCode }; } -export function reshapeImage(image: CommercetoolsImage): Image { +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 || "" + 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 { + 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 { + 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} A promise resolving to the retrieved product or undefined if not found. + */ +export async function getProduct(handle: string): Promise { + 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 { + 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} A promise resolving to an array of products. + */ +export async function getProducts({ + query, + reverse, + sortKey +}: { + query?: string; + reverse?: boolean; + sortKey?: string; +}): Promise { + 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} A promise resolving to an array of products. + */ +export async function getCollectionProducts({ + collection, + reverse, + sortKey +}: { + collection: string; + reverse?: boolean; + sortKey?: string; +}): Promise { + 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 { + try { + const response = await apiRoot.carts().withId({ ID: cartId }).get().execute(); + return reshapeCart(response.body); + } catch (err) { + return undefined; + } +} + +export async function createCart(): Promise { + 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 { + 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 { + 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 { + 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); +} diff --git a/lib/commercetools/type-guards.ts b/lib/commercetools/type-guards.ts new file mode 100644 index 000000000..3ddb6cf30 --- /dev/null +++ b/lib/commercetools/type-guards.ts @@ -0,0 +1,12 @@ +export function isString(value: unknown): value is string { + return typeof value === "string"; +} + +export function isNumber(value: unknown): value is number { + return typeof value === "number"; +} + +export function hasLocalizedStringValue(obj: unknown, locale: string): boolean { + if (!obj || typeof obj !== "object" || !(locale in obj)) return false; + return typeof (obj as Record)[locale] === "string"; +} diff --git a/lib/commercetools/types.ts b/lib/commercetools/types.ts index 078b86223..874187ec5 100644 --- a/lib/commercetools/types.ts +++ b/lib/commercetools/types.ts @@ -2,7 +2,6 @@ export type Cart = { id: string; checkoutUrl: string; cost: { - subtotalAmount: Money; totalAmount: Money; totalTaxAmount: Money; }; @@ -23,7 +22,7 @@ export type CartItem = { name: string; value: string; }[]; - product: Product; + product: { handle: string; title: string; featuredImage: Image }; }; }; @@ -92,7 +91,6 @@ export type ProductOption = { export type ProductVariant = { id: string; - title: string; availableForSale: boolean; selectedOptions: { name: string; @@ -102,6 +100,6 @@ export type ProductVariant = { }; export type SEO = { - title: string; - description: string; + title?: string; + description?: string; }; diff --git a/lib/constants.ts b/lib/constants.ts index d458d80cc..39dd761c3 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,23 +1,42 @@ export type SortFilterItem = { title: string; slug: string | null; - sortKey: "RELEVANCE" | "BEST_SELLING" | "CREATED_AT" | "PRICE"; + sortKey: + | "price desc" + | "createdAt desc" + | "reviewRatingStatistics.averageRating desc" + | undefined; reverse: boolean; }; export const defaultSort: SortFilterItem = { title: "Relevance", slug: null, - sortKey: "RELEVANCE", + sortKey: undefined, reverse: false }; export const sorting: SortFilterItem[] = [ defaultSort, - { title: "Trending", slug: "trending-desc", sortKey: "BEST_SELLING", reverse: false }, // asc - { title: "Latest arrivals", slug: "latest-desc", sortKey: "CREATED_AT", reverse: true }, - { title: "Price: Low to high", slug: "price-asc", sortKey: "PRICE", reverse: false }, // asc - { title: "Price: High to low", slug: "price-desc", sortKey: "PRICE", reverse: true } + { + title: "Rating", + slug: "rating-desc", + sortKey: "reviewRatingStatistics.averageRating desc", + reverse: false + }, + { title: "Latest arrivals", slug: "latest-desc", sortKey: "createdAt desc", reverse: false }, //ctp: createdAt + { + title: "Price: Low to high", + slug: "price-asc", + sortKey: "price desc", + reverse: true + }, + { + title: "Price: High to low", + slug: "price-desc", + sortKey: "price desc", + reverse: false + } ]; export const TAGS = { diff --git a/next.config.js b/next.config.js index 7a73c52f2..c9b6675b4 100644 --- a/next.config.js +++ b/next.config.js @@ -7,10 +7,18 @@ module.exports = { images: { formats: ["image/avif", "image/webp"], remotePatterns: [ + { hostname: "picsum.photos" }, + { hostname: "storage.googleapis.com" }, { protocol: "https", hostname: "cdn.shopify.com", pathname: "/s/files/**" + }, + { + hostname: "**.cf3.rackcdn.com", + protocol: "https", + port: "", + pathname: "/*" } ] },