commerce/lib/sfcc/index.ts

693 lines
18 KiB
TypeScript

import {
Checkout,
Customer,
Product as SalesforceProduct,
Search,
} from "commerce-sdk";
import { ShopperBaskets } from "commerce-sdk/dist/checkout/checkout";
import { defaultSort, storeCatalog, TAGS } from "lib/constants";
import { unstable_cache as cache, revalidateTag } from "next/cache";
import { cookies, headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { getProductRecommendations as getOCProductRecommendations } from "./ocapi";
import {
Cart,
CartItem,
Collection,
Image,
Product,
ProductRecommendations,
} from "./types";
const config = {
headers: {},
parameters: {
clientId: process.env.SFCC_CLIENT_ID,
organizationId: process.env.SFCC_ORGANIZATIONID,
shortCode: process.env.SFCC_SHORTCODE,
siteId: process.env.SFCC_SITEID,
},
};
type SortedProductResult = {
productResult: SalesforceProduct.ShopperProducts.Product;
index: number;
};
export const getCollections = cache(
async () => {
return await getSFCCCollections();
},
["get-collections"],
{
tags: [TAGS.collections],
}
);
export function getCollection(handle: string) {
return getCollections().then((collections) =>
collections.find((c) => c.handle === handle)
);
}
export const getProduct = cache(
async (id: string) => getSFCCProduct(id),
["get-product"],
{
tags: [TAGS.products],
}
);
export const getCollectionProducts = cache(
async ({
collection,
reverse,
sortKey,
}: {
collection: string;
reverse?: boolean;
sortKey?: string;
}) => {
return await searchProducts({ categoryId: collection, sortKey });
},
["get-collection-products"],
{ tags: [TAGS.products, TAGS.collections] }
);
export const getProducts = cache(
async ({
query,
sortKey,
}: {
query?: string;
sortKey?: string;
reverse?: boolean;
}) => {
return await searchProducts({ query, sortKey });
},
["get-products"],
{
tags: [TAGS.products],
}
);
export async function createCart() {
let guestToken = (await cookies()).get("guest_token")?.value;
// if there is not a guest token, get one and store it in a cookie
if (!guestToken) {
const tokenResponse = await getGuestUserAuthToken();
guestToken = tokenResponse.access_token;
(await cookies()).set("guest_token", guestToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 60 * 30,
path: "/",
});
}
// get the guest config
const config = await getGuestUserConfig(guestToken);
// initialize the basket config
const basketClient = new Checkout.ShopperBaskets(config);
// create an empty ShopperBaskets.Basket
const createdBasket = await basketClient.createBasket({
body: {},
});
const cartItems = await getCartItems(createdBasket);
return reshapeBasket(createdBasket, cartItems);
}
export async function getCart(): Promise<Cart | undefined> {
const cartId = (await cookies()).get("cartId")?.value!;
// get the guest token to get the correct guest cart
const guestToken = (await cookies()).get("guest_token")?.value;
const config = await getGuestUserConfig(guestToken);
if (!cartId) return;
try {
const basketClient = new Checkout.ShopperBaskets(config);
const basket = await basketClient.getBasket({
parameters: {
basketId: cartId,
organizationId: process.env.SFCC_ORGANIZATIONID,
siteId: process.env.SFCC_SITEID,
},
});
if (!basket?.basketId) return;
const cartItems = await getCartItems(basket);
return reshapeBasket(basket, cartItems);
} catch (e: any) {
console.log(await e.response.text());
return;
}
}
export async function addToCart(
lines: { merchandiseId: string; quantity: number }[]
) {
const cartId = (await cookies()).get("cartId")?.value!;
// get the guest token to get the correct guest cart
const guestToken = (await cookies()).get("guest_token")?.value;
const config = await getGuestUserConfig(guestToken);
try {
const basketClient = new Checkout.ShopperBaskets(config);
const basket = await basketClient.addItemToBasket({
parameters: {
basketId: cartId,
organizationId: process.env.SFCC_ORGANIZATIONID,
siteId: process.env.SFCC_SITEID,
},
body: lines.map((line) => {
return {
productId: line.merchandiseId,
quantity: line.quantity,
};
}),
});
if (!basket?.basketId) return;
const cartItems = await getCartItems(basket);
return reshapeBasket(basket, cartItems);
} catch (e: any) {
console.log(await e.response.text());
return;
}
}
export async function removeFromCart(lineIds: string[]) {
const cartId = (await cookies()).get("cartId")?.value!;
// Next Commerce only sends one lineId at a time
if (lineIds.length !== 1)
throw new Error("Invalid number of line items provided");
// get the guest token to get the correct guest cart
const guestToken = (await cookies()).get("guest_token")?.value;
const config = await getGuestUserConfig(guestToken);
const basketClient = new Checkout.ShopperBaskets(config);
const basket = await basketClient.removeItemFromBasket({
parameters: {
basketId: cartId,
itemId: lineIds[0]!,
},
});
const cartItems = await getCartItems(basket);
return reshapeBasket(basket, cartItems);
}
export async function updateCart(
lines: { id: string; merchandiseId: string; quantity: number }[]
) {
const cartId = (await cookies()).get("cartId")?.value!;
// get the guest token to get the correct guest cart
const guestToken = (await cookies()).get("guest_token")?.value;
const config = await getGuestUserConfig(guestToken);
const basketClient = new Checkout.ShopperBaskets(config);
// ProductItem quantity can not be updated through the API
// Quantity updates need to remove all items from the cart and add them back with updated quantities
// See: https://developer.salesforce.com/docs/commerce/commerce-api/references/shopper-baskets?meta=updateBasket
// create removePromises for each line
const removePromises = lines.map((line) =>
basketClient.removeItemFromBasket({
parameters: {
basketId: cartId,
itemId: line.id,
},
})
);
// wait for all removals to resolve
await Promise.all(removePromises);
// create addPromises for each line
const addPromises = lines.map((line) =>
basketClient.addItemToBasket({
parameters: {
basketId: cartId,
},
body: [
{
productId: line.merchandiseId,
quantity: line.quantity,
},
],
})
);
// wait for all additions to resolve
await Promise.all(addPromises);
// all updates are done, get the updated basket
const updatedBasket = await basketClient.getBasket({
parameters: {
basketId: cartId,
},
});
const cartItems = await getCartItems(updatedBasket);
return reshapeBasket(updatedBasket, cartItems);
}
export async function getProductRecommendations(productId: string) {
const ocProductRecommendations =
await getOCProductRecommendations<ProductRecommendations>(productId);
if (!ocProductRecommendations?.recommendations?.length) return [];
const clientConfig = await getGuestUserConfig();
const productsClient = new SalesforceProduct.ShopperProducts(clientConfig);
const recommendedProducts: SortedProductResult[] = [];
await Promise.all(
ocProductRecommendations.recommendations.map(
async (recommendation, index) => {
const productResult = await productsClient.getProduct({
parameters: {
organizationId: clientConfig.parameters.organizationId,
siteId: clientConfig.parameters.siteId,
id: recommendation.recommended_item_id,
},
});
recommendedProducts.push({ productResult, index });
}
)
);
const sortedResults = recommendedProducts
.sort((a: any, b: any) => a.index - b.index)
.map((item) => item.productResult);
return reshapeProducts(sortedResults);
}
export async function revalidate(req: NextRequest) {
const collectionWebhooks = [
"collections/create",
"collections/delete",
"collections/update",
];
const productWebhooks = [
"products/create",
"products/delete",
"products/update",
];
const topic = (await headers()).get("x-sfcc-topic") || "unknown";
const secret = req.nextUrl.searchParams.get("secret");
const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.SFCC_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() });
}
async function getGuestUserAuthToken() {
const base64data = Buffer.from(
`${process.env.SFCC_CLIENT_ID}:${process.env.SFCC_SECRET}`
).toString("base64");
const headers = { Authorization: `Basic ${base64data}` };
const client = new Customer.ShopperLogin(config);
return await client.getAccessToken({
headers,
body: {
grant_type: "client_credentials",
channel_id: process.env.SFCC_SITEID,
},
});
}
async function getGuestUserConfig(token?: string) {
const guestToken = token || (await getGuestUserAuthToken()).access_token;
if (!guestToken) {
throw new Error("Failed to retrieve access token");
}
return {
...config,
headers: {
authorization: `Bearer ${guestToken}`,
},
};
}
async function getSFCCCollections() {
const config = await getGuestUserConfig();
const productsClient = new SalesforceProduct.ShopperProducts(config);
const result = await productsClient.getCategories({
parameters: {
ids: storeCatalog.ids,
},
});
return reshapeCategories(result.data || []);
}
async function getSFCCProduct(id: string) {
const config = await getGuestUserConfig();
const productsClient = new SalesforceProduct.ShopperProducts(config);
const product = await productsClient.getProduct({
parameters: {
organizationId: config.parameters.organizationId,
siteId: config.parameters.siteId,
id,
},
});
return reshapeProduct(product);
}
async function searchProducts(options: {
query?: string;
categoryId?: string;
sortKey?: string;
}) {
const { query, categoryId, sortKey = defaultSort.sortKey } = options;
const config = await getGuestUserConfig();
const searchClient = new Search.ShopperSearch(config);
const searchResults = await searchClient.productSearch({
parameters: {
q: query || "",
refine: categoryId ? [`cgid=${categoryId}`] : [],
sort: sortKey,
limit: 100,
},
});
const results: SortedProductResult[] = [];
const productsClient = new SalesforceProduct.ShopperProducts(config);
await Promise.all(
searchResults.hits.map(
async (product: { productId: string }, index: number) => {
const productResult = await productsClient.getProduct({
parameters: {
organizationId: config.parameters.organizationId,
siteId: config.parameters.siteId,
id: product.productId,
},
});
results.push({ productResult, index });
}
)
);
const sortedResults = results
.sort((a: any, b: any) => a.index - b.index)
.map((item) => item.productResult);
return reshapeProducts(sortedResults);
}
async function getCartItems(createdBasket: ShopperBaskets.Basket) {
const cartItems: CartItem[] = [];
if (createdBasket.productItems) {
const productsInCart: Product[] = [];
// Fetch all matching products for items in the cart
await Promise.all(
createdBasket.productItems
.filter((l: ShopperBaskets.ProductItem) => l.productId)
.map(async (l: ShopperBaskets.ProductItem) => {
const product = await getProduct(l.productId!);
productsInCart.push(product);
})
);
// Reshape the sfcc items and push them onto the cartItems
createdBasket.productItems.map(
(productItem: ShopperBaskets.ProductItem) => {
cartItems.push(
reshapeProductItem(
productItem,
createdBasket.currency || "USD",
productsInCart.find((p) => p.id === productItem.productId)!
)
);
}
);
}
return cartItems;
}
function reshapeCategory(
category: SalesforceProduct.ShopperProducts.Category
): Collection | undefined {
if (!category) {
return undefined;
}
return {
handle: category.id,
title: category.name || "",
description: category.description || "",
seo: {
title: category.pageTitle || "",
description: category.description || "",
},
updatedAt: "",
path: `/search/${category.id}`,
};
}
function reshapeCategories(
categories: SalesforceProduct.ShopperProducts.Category[]
) {
const reshapedCategories = [];
for (const category of categories) {
if (category) {
const reshapedCategory = reshapeCategory(category);
if (reshapedCategory) {
reshapedCategories.push(reshapedCategory);
}
}
}
return reshapedCategories;
}
function reshapeProduct(product: SalesforceProduct.ShopperProducts.Product) {
if (!product.name) {
throw new Error("Product name is not set");
}
const images = reshapeImages(product.imageGroups);
if (!images[0]) {
throw new Error("Product image is not set");
}
const flattenedPrices =
product.variants
?.filter((variant) => variant.price !== undefined)
.reduce((acc: number[], variant) => [...acc, variant.price!], [])
.sort((a, b) => a - b) || [];
return {
id: product.id,
handle: product.id,
title: product.name,
description: product.shortDescription || "",
descriptionHtml: product.longDescription || "",
tags: product["c_product-tags"] || [],
featuredImage: images[0],
// TODO: check dates for whether it is available
availableForSale: true,
priceRange: {
maxVariantPrice: {
// TODO: verify whether there is another property for this
amount: flattenedPrices[flattenedPrices.length - 1]?.toString() || "0",
currencyCode: product.currency || "USD",
},
minVariantPrice: {
amount: flattenedPrices[0]?.toString() || "0",
currencyCode: product.currency || "USD",
},
},
images: images,
options:
product.variationAttributes?.map((attribute) => {
return {
id: attribute.id,
name: attribute.name!,
// TODO: might be a better way to do this, we are providing the name as the value
values:
attribute.values
?.filter((v) => v.value !== undefined)
?.map((v) => v.name!) || [],
};
}) || [],
seo: {
title: product.pageTitle || "",
description: product.pageDescription || "",
},
variants: reshapeVariants(product.variants || [], product),
updatedAt: product["c_updated-date"],
};
}
function reshapeProducts(
products: SalesforceProduct.ShopperProducts.Product[]
) {
const reshapedProducts = [];
for (const product of products) {
if (product) {
const reshapedProduct = reshapeProduct(product);
if (reshapedProduct) {
reshapedProducts.push(reshapedProduct);
}
}
}
return reshapedProducts;
}
function reshapeImages(
imageGroups: SalesforceProduct.ShopperProducts.ImageGroup[] | undefined
): Image[] {
if (!imageGroups) return [];
const largeGroup = imageGroups.filter((g) => g.viewType === "large");
const images = [...largeGroup].map((group) => group.images).flat();
return images.map((image) => {
return {
altText: image.alt!,
url: image.disBaseLink || image.link,
width: image.width || 800,
height: image.height || 800,
};
});
}
function reshapeVariants(
variants: SalesforceProduct.ShopperProducts.Variant[],
product: SalesforceProduct.ShopperProducts.Product
) {
return variants.map((variant) => reshapeVariant(variant, product));
}
function reshapeVariant(
variant: SalesforceProduct.ShopperProducts.Variant,
product: SalesforceProduct.ShopperProducts.Product
) {
return {
id: variant.productId,
title: product.name || "",
availableForSale: variant.orderable || false,
selectedOptions:
Object.entries(variant.variationValues || {}).map(([key, value]) => ({
// TODO: we use the name here instead of the key because the frontend only uses names
name:
product.variationAttributes?.find((attr) => attr.id === key)?.name ||
key,
// TODO: might be a cleaner way to do this, we need to look up the name on the list of values from the variationAttributes
value:
product.variationAttributes
?.find((attr) => attr.id === key)
?.values?.find((v) => v.value === value)?.name || "",
})) || [],
price: {
amount: variant.price?.toString() || "0",
currencyCode: product.currency || "USD",
},
};
}
function reshapeProductItem(
item: Checkout.ShopperBaskets.ProductItem,
currency: string,
matchingProduct: Product
): CartItem {
return {
id: item.itemId || "",
quantity: item.quantity || 0,
cost: {
totalAmount: {
amount: item.price?.toString() || "0",
currencyCode: currency,
},
},
merchandise: {
id: item.productId || "",
title: item.productName || "",
selectedOptions:
item.optionItems?.map((o) => {
return {
name: o.optionId!,
value: o.optionValueId!,
};
}) || [],
product: matchingProduct,
},
};
}
function reshapeBasket(
basket: ShopperBaskets.Basket,
cartItems: CartItem[]
): Cart {
return {
id: basket.basketId!,
checkoutUrl: "/checkout",
cost: {
subtotalAmount: {
amount: basket.productSubTotal?.toString() || "0",
currencyCode: basket.currency || "USD",
},
totalAmount: {
amount: `${(basket.productSubTotal ?? 0) + (basket.merchandizeTotalTax ?? 0)}`,
currencyCode: basket.currency || "USD",
},
totalTaxAmount: {
amount: basket.merchandizeTotalTax?.toString() || "0",
currencyCode: basket.currency || "USD",
},
},
totalQuantity:
cartItems?.reduce((acc, item) => acc + (item?.quantity ?? 0), 0) ?? 0,
lines: cartItems,
};
}