Refactor SFCC SDK implementation and configuration

This commit is contained in:
Darek Rossman 2025-03-14 00:36:51 -04:00
parent 52842e11cb
commit 8de3aa431e
7 changed files with 1278 additions and 1949 deletions

3
.gitignore vendored
View File

@ -37,3 +37,6 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
.env*.local
# editors
.cursor

View File

@ -1,5 +1,5 @@
import OpengraphImage from 'components/opengraph-image';
import { fetchCollection as getCollection } from 'lib/sfcc/scapi';
import OpengraphImage from "components/opengraph-image";
import { getCollection } from "lib/sfcc";
export default async function Image({
params

View File

@ -1,10 +1,12 @@
import {
Checkout,
Customer,
Product as SalesforceProduct,
Search,
} from "commerce-sdk";
import { ShopperBaskets } from "commerce-sdk/dist/checkout/checkout";
helpers,
ShopperBaskets,
ShopperBasketsTypes,
ShopperLogin,
ShopperProducts,
ShopperProductsTypes,
ShopperSearch,
} from "commerce-sdk-isomorphic";
import { defaultSort, storeCatalog, TAGS } from "lib/constants";
import { unstable_cache as cache, revalidateTag } from "next/cache";
import { cookies, headers } from "next/headers";
@ -17,20 +19,21 @@ import {
Image,
Product,
ProductRecommendations,
SdkError,
} from "./types";
const config = {
headers: {},
const apiConfig = {
throwOnBadResponse: true,
parameters: {
clientId: process.env.SFCC_CLIENT_ID,
organizationId: process.env.SFCC_ORGANIZATIONID,
shortCode: process.env.SFCC_SHORTCODE,
siteId: process.env.SFCC_SITEID,
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;
productResult: ShopperProductsTypes.Product;
index: number;
};
@ -110,8 +113,8 @@ export async function createCart() {
// get the guest config
const config = await getGuestUserConfig(guestToken);
// initialize the basket config
const basketClient = new Checkout.ShopperBaskets(config);
// initialize the basket client
const basketClient = new ShopperBaskets(config);
// create an empty ShopperBaskets.Basket
const createdBasket = await basketClient.createBasket({
@ -133,13 +136,11 @@ export async function getCart(): Promise<Cart | undefined> {
if (!cartId) return;
try {
const basketClient = new Checkout.ShopperBaskets(config);
const basketClient = new ShopperBaskets(config);
const basket = await basketClient.getBasket({
parameters: {
basketId: cartId,
organizationId: process.env.SFCC_ORGANIZATIONID,
siteId: process.env.SFCC_SITEID,
},
});
@ -162,13 +163,11 @@ export async function addToCart(
const config = await getGuestUserConfig(guestToken);
try {
const basketClient = new Checkout.ShopperBaskets(config);
const basketClient = new 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 {
@ -198,7 +197,7 @@ export async function removeFromCart(lineIds: string[]) {
const guestToken = (await cookies()).get("guest_token")?.value;
const config = await getGuestUserConfig(guestToken);
const basketClient = new Checkout.ShopperBaskets(config);
const basketClient = new ShopperBaskets(config);
const basket = await basketClient.removeItemFromBasket({
parameters: {
@ -219,7 +218,7 @@ export async function updateCart(
const guestToken = (await cookies()).get("guest_token")?.value;
const config = await getGuestUserConfig(guestToken);
const basketClient = new Checkout.ShopperBaskets(config);
const basketClient = new 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
@ -273,8 +272,8 @@ export async function getProductRecommendations(productId: string) {
if (!ocProductRecommendations?.recommendations?.length) return [];
const clientConfig = await getGuestUserConfig();
const productsClient = new SalesforceProduct.ShopperProducts(clientConfig);
const config = await getGuestUserConfig();
const productsClient = new ShopperProducts(config);
const recommendedProducts: SortedProductResult[] = [];
@ -283,8 +282,6 @@ export async function getProductRecommendations(productId: string) {
async (recommendation, index) => {
const productResult = await productsClient.getProduct({
parameters: {
organizationId: clientConfig.parameters.organizationId,
siteId: clientConfig.parameters.siteId,
id: recommendation.recommended_item_id,
},
});
@ -294,7 +291,7 @@ export async function getProductRecommendations(productId: string) {
);
const sortedResults = recommendedProducts
.sort((a: any, b: any) => a.index - b.index)
.sort((a, b) => a.index - b.index)
.map((item) => item.productResult);
return reshapeProducts(sortedResults);
@ -338,30 +335,29 @@ export async function revalidate(req: NextRequest) {
}
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,
},
});
const loginClient = new ShopperLogin(apiConfig);
try {
return await helpers.loginGuestUserPrivate(
loginClient,
{},
{ clientSecret: process.env.SFCC_SECRET || "" }
);
} catch (e) {
// The commerce sdk is configured to throw a custom error for any 400 or 500 response.
// See https://github.com/SalesforceCommerceCloud/commerce-sdk-isomorphic/tree/main?tab=readme-ov-file#throwonbadresponse
const sdkError = e as SdkError;
if (sdkError.response) {
const error = await sdkError.response.json();
throw error;
}
throw new Error("Failed to retrieve access token");
}
}
async function getGuestUserConfig(token?: string) {
const guestToken = token || (await getGuestUserAuthToken()).access_token;
if (!guestToken) {
throw new Error("Failed to retrieve access token");
}
return {
...config,
...apiConfig,
headers: {
authorization: `Bearer ${guestToken}`,
},
@ -370,7 +366,7 @@ async function getGuestUserConfig(token?: string) {
async function getSFCCCollections() {
const config = await getGuestUserConfig();
const productsClient = new SalesforceProduct.ShopperProducts(config);
const productsClient = new ShopperProducts(config);
const result = await productsClient.getCategories({
parameters: {
@ -378,17 +374,15 @@ async function getSFCCCollections() {
},
});
return reshapeCategories(result.data || []);
return reshapeCategories(result?.data || []);
}
async function getSFCCProduct(id: string) {
const config = await getGuestUserConfig();
const productsClient = new SalesforceProduct.ShopperProducts(config);
const productsClient = new ShopperProducts(config);
const product = await productsClient.getProduct({
parameters: {
organizationId: config.parameters.organizationId,
siteId: config.parameters.siteId,
id,
},
});
@ -404,7 +398,7 @@ async function searchProducts(options: {
const { query, categoryId, sortKey = defaultSort.sortKey } = options;
const config = await getGuestUserConfig();
const searchClient = new Search.ShopperSearch(config);
const searchClient = new ShopperSearch(config);
const searchResults = await searchClient.productSearch({
parameters: {
q: query || "",
@ -416,30 +410,26 @@ async function searchProducts(options: {
const results: SortedProductResult[] = [];
const productsClient = new SalesforceProduct.ShopperProducts(config);
const productsClient = new 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 });
}
)
searchResults.hits.map(async (product, index) => {
const productResult = await productsClient.getProduct({
parameters: {
id: product.productId,
},
});
results.push({ productResult, index });
})
);
const sortedResults = results
.sort((a: any, b: any) => a.index - b.index)
.sort((a, b) => a.index - b.index)
.map((item) => item.productResult);
return reshapeProducts(sortedResults);
}
async function getCartItems(createdBasket: ShopperBaskets.Basket) {
async function getCartItems(createdBasket: ShopperBasketsTypes.Basket) {
const cartItems: CartItem[] = [];
if (createdBasket.productItems) {
@ -448,32 +438,30 @@ async function getCartItems(createdBasket: ShopperBaskets.Basket) {
// 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) => {
.filter((l) => l.productId)
.map(async (l) => {
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)!
)
);
}
);
createdBasket.productItems.map((productItem) => {
cartItems.push(
reshapeProductItem(
productItem,
createdBasket.currency || "USD",
productsInCart.find((p) => p.id === productItem.productId)!
)
);
});
}
return cartItems;
}
function reshapeCategory(
category: SalesforceProduct.ShopperProducts.Category
category: ShopperProductsTypes.Category
): Collection | undefined {
if (!category) {
return undefined;
@ -492,9 +480,7 @@ function reshapeCategory(
};
}
function reshapeCategories(
categories: SalesforceProduct.ShopperProducts.Category[]
) {
function reshapeCategories(categories: ShopperProductsTypes.Category[]) {
const reshapedCategories = [];
for (const category of categories) {
if (category) {
@ -507,7 +493,7 @@ function reshapeCategories(
return reshapedCategories;
}
function reshapeProduct(product: SalesforceProduct.ShopperProducts.Product) {
function reshapeProduct(product: ShopperProductsTypes.Product) {
if (!product.name) {
throw new Error("Product name is not set");
}
@ -547,17 +533,19 @@ function reshapeProduct(product: SalesforceProduct.ShopperProducts.Product) {
},
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!) || [],
};
}) || [],
product.variationAttributes?.map(
(attribute: ShopperProductsTypes.VariationAttribute) => {
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 || "",
@ -567,9 +555,7 @@ function reshapeProduct(product: SalesforceProduct.ShopperProducts.Product) {
};
}
function reshapeProducts(
products: SalesforceProduct.ShopperProducts.Product[]
) {
function reshapeProducts(products: ShopperProductsTypes.Product[]) {
const reshapedProducts = [];
for (const product of products) {
if (product) {
@ -583,7 +569,7 @@ function reshapeProducts(
}
function reshapeImages(
imageGroups: SalesforceProduct.ShopperProducts.ImageGroup[] | undefined
imageGroups: ShopperProductsTypes.ImageGroup[] | undefined
): Image[] {
if (!imageGroups) return [];
@ -602,15 +588,15 @@ function reshapeImages(
}
function reshapeVariants(
variants: SalesforceProduct.ShopperProducts.Variant[],
product: SalesforceProduct.ShopperProducts.Product
variants: ShopperProductsTypes.Variant[],
product: ShopperProductsTypes.Product
) {
return variants.map((variant) => reshapeVariant(variant, product));
}
function reshapeVariant(
variant: SalesforceProduct.ShopperProducts.Variant,
product: SalesforceProduct.ShopperProducts.Product
variant: ShopperProductsTypes.Variant,
product: ShopperProductsTypes.Product
) {
return {
id: variant.productId,
@ -636,7 +622,7 @@ function reshapeVariant(
}
function reshapeProductItem(
item: Checkout.ShopperBaskets.ProductItem,
item: ShopperBasketsTypes.ProductItem,
currency: string,
matchingProduct: Product
): CartItem {
@ -665,7 +651,7 @@ function reshapeProductItem(
}
function reshapeBasket(
basket: ShopperBaskets.Basket,
basket: ShopperBasketsTypes.Basket,
cartItems: CartItem[]
): Cart {
return {

View File

@ -1,55 +0,0 @@
import { Collection } from './types';
import { ExtractVariables, salesforceFetch } from './utils';
export async function scapiFetch<T>(options: {
method: 'POST' | 'GET';
apiEndpoint: string;
cache?: RequestCache;
headers?: HeadersInit;
tags?: string[];
variables?: ExtractVariables<T>;
}): Promise<{ status: number; body: T } | never> {
const scapiDomain = `https://${process.env.SFCC_SHORTCODE}.api.commercecloud.salesforce.com`;
const apiEndpoint = `${scapiDomain}${options.apiEndpoint}?siteId=${process.env.SFCC_SITEID}`;
return salesforceFetch<T>({
...options,
apiEndpoint
});
}
export async function fetchAccessToken() {
const response = await scapiFetch<{ access_token: string }>({
method: 'POST',
apiEndpoint: `/shopper/auth/v1/organizations/${process.env.SFCC_ORGANIZATIONID}/oauth2/token?grant_type=client_credentials&channel_id=${process.env.SFCC_SITEID}`,
headers: {
Authorization: `Basic ${Buffer.from(
`${process.env.SFCC_CLIENT_ID}:${process.env.SFCC_SECRET}`
).toString('base64')}`,
'content-type': 'application/x-www-form-urlencoded'
}
});
if (response.status !== 200 || !response.body.access_token) {
throw new Error('Failed to fetch access token');
}
return response.body.access_token;
}
export async function fetchCollection(handle: string): Promise<Collection | undefined> {
const accessToken = await fetchAccessToken();
const response = await scapiFetch<Collection>({
method: 'GET',
apiEndpoint: `/product/shopper-products/v1/organizations/${process.env.SFCC_ORGANIZATIONID}/products/${handle}`,
headers: {
Authorization: `Bearer ${accessToken}`
}
});
if (response.status !== 200) {
throw new Error('Failed to fetch collection');
}
return response.body;
}

View File

@ -35,7 +35,7 @@ export type SalesforceProduct = {
updatedAt: string;
};
export type Product = Omit<SalesforceProduct, 'variants' | 'images'> & {
export type Product = Omit<SalesforceProduct, "variants" | "images"> & {
variants: ProductVariant[];
images: Image[];
};
@ -86,7 +86,7 @@ export type SalesforceCart = {
totalQuantity: number;
};
export type Cart = Omit<SalesforceCart, 'lines'> & {
export type Cart = Omit<SalesforceCart, "lines"> & {
lines: CartItem[];
};
@ -145,3 +145,7 @@ export type Page = {
createdAt: string;
updatedAt: string;
};
export type SdkError = {
response?: Response;
};

View File

@ -11,19 +11,19 @@
"dependencies": {
"@headlessui/react": "^2.2.0",
"@heroicons/react": "^2.2.0",
"clsx": "^2.1.1",
"commerce-sdk": "^4.0.0",
"geist": "^1.3.1",
"next": "15.2.0-canary.67",
"react": "19.0.0",
"react-dom": "19.0.0",
"sonner": "^2.0.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"commerce-sdk-isomorphic": "^3.2.0",
"geist": "^1.3.1",
"lucide-react": "^0.438.0",
"next": "15.2.0-canary.67",
"react": "19.0.0",
"react-dom": "19.0.0",
"sonner": "^2.0.1",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7"
},

2943
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff