mirror of
https://github.com/vercel/commerce.git
synced 2025-05-18 23:46:58 +00:00
Feat/lac 207 reshape categories (#11)
This commit is contained in:
parent
397efc9ff4
commit
33c82d2d70
@ -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 = {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import OpengraphImage from "components/opengraph-image";
|
||||
import { getCollection } from "lib/shopify";
|
||||
import { getCollection } from "lib/commercetools";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { getCollectionProducts } from "lib/shopify";
|
||||
import { getCollectionProducts } from "lib/commercetools";
|
||||
import Link from "next/link";
|
||||
import { GridTileImage } from "./grid/tile";
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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 (
|
||||
<form action={actionWithVariant}>
|
||||
<form action={availableForSale && selectedVariantId ? actionWithVariant : () => {}}>
|
||||
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} />
|
||||
<p aria-live="polite" className="sr-only" role="status">
|
||||
{message}
|
||||
|
@ -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() {
|
||||
|
@ -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" }) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { getCart } from "lib/shopify";
|
||||
import { getCart } from "lib/commercetools";
|
||||
import { cookies } from "next/headers";
|
||||
import CartModal from "./modal";
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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({
|
||||
|
@ -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[] }) {
|
||||
|
@ -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() {
|
||||
|
@ -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 }) {
|
||||
|
@ -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";
|
||||
|
||||
|
@ -1,5 +1,8 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node"
|
||||
testEnvironment: "node",
|
||||
moduleNameMapper: {
|
||||
"^lib/(.*)$": "<rootDir>/lib/$1"
|
||||
}
|
||||
};
|
||||
|
@ -30,7 +30,6 @@ const ctpClient = new ClientBuilder()
|
||||
.withProjectKey(projectKey)
|
||||
.withClientCredentialsFlow(authMiddlewareOptions)
|
||||
.withHttpMiddleware(httpMiddlewareOptions)
|
||||
.withLoggerMiddleware()
|
||||
.build();
|
||||
|
||||
const apiRoot = createApiBuilderFromCtpClient(ctpClient).withProjectKey({
|
||||
|
34
lib/commercetools/dummy-data.ts
Normal file
34
lib/commercetools/dummy-data.ts
Normal file
@ -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"
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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<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);
|
||||
}
|
||||
|
12
lib/commercetools/type-guards.ts
Normal file
12
lib/commercetools/type-guards.ts
Normal file
@ -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<string, unknown>)[locale] === "string";
|
||||
}
|
@ -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;
|
||||
};
|
||||
|
@ -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 = {
|
||||
|
@ -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: "/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user