mirror of
https://github.com/vercel/commerce.git
synced 2025-04-28 13:57:50 +00:00
Refactor SFCC SDK implementation and configuration
This commit is contained in:
parent
52842e11cb
commit
8de3aa431e
3
.gitignore
vendored
3
.gitignore
vendored
@ -37,3 +37,6 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env*.local
|
||||
|
||||
# editors
|
||||
.cursor
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
14
package.json
14
package.json
@ -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
2943
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user