1
0
mirror of https://github.com/vercel/commerce.git synced 2025-05-14 21:47:51 +00:00

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

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

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

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

@ -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;
}

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

@ -11,19 +11,19 @@
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@heroicons/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-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.1", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.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", "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", "tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },

2943
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff