menu.handle === handle)[0]?.links || [];
+}
+
+/**
+ * NOTE: This function currently returns a hardcoded menu structure for demonstration purposes.
+ * This should be replaced in a fetch to a CMS or other data source that is appropriate for the project.
+ */
+export function getMenus() {
+ return [
+ {
+ handle: 'next-js-frontend-footer-menu',
+ links: [
+ {
+ title: 'Home',
+ path: '/'
+ },
+ {
+ title: 'About',
+ path: '/about'
+ },
+ {
+ title: 'Terms & Conditions',
+ path: '/terms-conditions'
+ },
+ {
+ title: 'Shipping & Return Policy',
+ path: '/shipping-return-policy'
+ },
+ {
+ title: 'Privacy Policy',
+ path: '/privacy-policy'
+ },
+ {
+ title: 'FAQ',
+ path: '/freqently-asked-questions'
+ }
+ ]
+ },
+ {
+ handle: 'next-js-frontend-header-menu',
+ links: [
+ {
+ title: 'New Arrivals',
+ path: '/search/newarrivals'
+ },
+ {
+ title: 'Women',
+ path: '/search/womens'
+ },
+ {
+ title: 'Men',
+ path: '/search/mens'
+ }
+ ]
+ }
+ ];
+}
+
+/**
+ * NOTE: This function currently returns a hardcoded page for demonstration purposes.
+ * This should be replaced in a fetch to a CMS or other data source that is appropriate for the project.
+ */
+export function getPage(handle: string): Page | undefined {
+ return getPages().find((page) => page.handle === handle);
+}
+
+/**
+ * NOTE: This function currently returns hardcoded pages for demonstration purposes.
+ * This should be replaced in a fetch to a CMS or other data source that is appropriate for the project.
+ */
+export function getPages(): Page[] {
+ return [homePage, aboutPage, termsPage, shippingPage, privacyPage, faqPage];
+}
+
+/*
+ * For demonstration purposes, we've opted to hardcode the content for several pages in this project.
+ * In a real-world scenario, this content would typically be managed through a CMS to allow for
+ * easier updates and content management by non-developers. This hardcoding approach simplifies
+ * the setup for now but would be replaced with a CMS in a production environment.
+ */
+const homePage = {
+ id: 'home',
+ title: 'Acme Store',
+ handle: '',
+ body: ``,
+ bodySummary:
+ 'High-performance ecommerce store built with Next.js, Vercel, and Salesforce Commerce Cloud.',
+ seo: {
+ title: 'Acme Store',
+ description:
+ 'High-performance ecommerce store built with Next.js, Vercel, and Salesforce Commerce Cloud.'
+ },
+ createdAt: '2024-09-20T20:15:06Z',
+ updatedAt: '2024-09-20T20:15:06Z'
+};
+
+const aboutPage = {
+ id: 'about',
+ title: 'About',
+ handle: 'about',
+ body: `This website is built with Next.js Commerce, which is a ecommerce template for creating a headless Salesforce Commerce Cloud storefront.
+
Support for real-world commerce features including:
+
+- Out of stocks
+- Order history
+- Order status
+- Cross variant / option availability (aka. Amazon style)
+- Hidden products
+- Dynamically driven features via Salesforce Commerce Cloud (ie. collections, products, recommendations, etc.)
+
+- And more!
+
+
This template also allows us to highlight newer Next.js features including:
+
+- Next.js App Router
+- Optimized for SEO using Next.js's Metadata
+- React Server Components (RSCs) and Suspense
+- Server Actions for mutations
+- Edge runtime
+- New Next.js 13 fetching and caching paradigms
+- Dynamic OG images
+- Styling with Tailwind CSS
+- Automatic light/dark mode based on system settings
+- And more!
+
`,
+ bodySummary: 'This website is built with Next.js, Vercel, and Salesforce Commerce Cloud.',
+ seo: {
+ title: 'About',
+ description: 'This website is built with Next.js, Vercel, and Salesforce Commerce Cloud.'
+ },
+ createdAt: '2024-09-20T20:15:06Z',
+ updatedAt: '2024-09-20T20:15:06Z'
+};
+
+const termsPage = {
+ id: 'terms',
+ title: 'Terms & Conditions',
+ handle: 'terms-conditions',
+ body: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Nam libero justo laoreet sit amet cursus sit. Dictumst quisque sagittis purus sit amet volutpat consequat. Egestas diam in arcu cursus euismod. Sed faucibus turpis in eu mi bibendum. Consectetur libero id faucibus nisl. Quisque id diam vel quam elementum. Eros donec ac odio tempor orci dapibus ultrices. Turpis tincidunt id aliquet risus. Pellentesque eu tincidunt tortor aliquam nulla facilisi cras fermentum odio.
`,
+ bodySummary:
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
+ seo: {
+ title: 'Terms & Conditions',
+ description:
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. '
+ },
+ createdAt: '2024-09-20T20:15:06Z',
+ updatedAt: '2024-09-20T20:15:06Z'
+};
+
+const shippingPage = {
+ id: 'shipping',
+ title: 'Shipping & Return Policy',
+ handle: 'shipping-return-policy',
+ body: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Nam libero justo laoreet sit amet cursus sit. Dictumst quisque sagittis purus sit amet volutpat consequat. Egestas diam in arcu cursus euismod. Sed faucibus turpis in eu mi bibendum. Consectetur libero id faucibus nisl. Quisque id diam vel quam elementum. Eros donec ac odio tempor orci dapibus ultrices. Turpis tincidunt id aliquet risus. Pellentesque eu tincidunt tortor aliquam nulla facilisi cras fermentum odio.
`,
+ bodySummary:
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
+ seo: {
+ title: 'Shipping & Return Policy',
+ description:
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. '
+ },
+ createdAt: '2024-09-20T20:15:06Z',
+ updatedAt: '2024-09-20T20:15:06Z'
+};
+
+const privacyPage = {
+ id: 'privacy',
+ title: 'Privacy Policy',
+ handle: 'privacy-policy',
+ body: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Nam libero justo laoreet sit amet cursus sit. Dictumst quisque sagittis purus sit amet volutpat consequat. Egestas diam in arcu cursus euismod. Sed faucibus turpis in eu mi bibendum. Consectetur libero id faucibus nisl. Quisque id diam vel quam elementum. Eros donec ac odio tempor orci dapibus ultrices. Turpis tincidunt id aliquet risus. Pellentesque eu tincidunt tortor aliquam nulla facilisi cras fermentum odio.
`,
+ bodySummary:
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
+ seo: {
+ title: 'Privacy Policy',
+ description:
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. '
+ },
+ createdAt: '2024-09-20T20:15:06Z',
+ updatedAt: '2024-09-20T20:15:06Z'
+};
+
+const faqPage = {
+ id: 'faq',
+ title: 'Frequently Asked Questions',
+ handle: 'freqently-asked-questions',
+ body: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Nam libero justo laoreet sit amet cursus sit. Dictumst quisque sagittis purus sit amet volutpat consequat. Egestas diam in arcu cursus euismod. Sed faucibus turpis in eu mi bibendum. Consectetur libero id faucibus nisl. Quisque id diam vel quam elementum. Eros donec ac odio tempor orci dapibus ultrices. Turpis tincidunt id aliquet risus. Pellentesque eu tincidunt tortor aliquam nulla facilisi cras fermentum odio.
`,
+ bodySummary:
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
+ seo: {
+ title: 'Frequently Asked Questions',
+ description:
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. '
+ },
+ createdAt: '2024-09-20T20:15:06Z',
+ updatedAt: '2024-09-20T20:15:06Z'
+};
diff --git a/lib/sfcc/index.ts b/lib/sfcc/index.ts
new file mode 100644
index 000000000..05313b9df
--- /dev/null
+++ b/lib/sfcc/index.ts
@@ -0,0 +1,693 @@
+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 {
+ 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(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.link,
+ // TODO: add field for size
+ 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,
+ };
+}
diff --git a/lib/sfcc/ocapi.ts b/lib/sfcc/ocapi.ts
new file mode 100644
index 000000000..58c066967
--- /dev/null
+++ b/lib/sfcc/ocapi.ts
@@ -0,0 +1,34 @@
+import { TAGS } from 'lib/constants';
+import { ensureStartsWith } from 'lib/utils';
+import { ExtractVariables, salesforceFetch } from './utils';
+
+const ocapiDomain = process.env.SFCC_SANDBOX_DOMAIN
+ ? ensureStartsWith(process.env.SFCC_SANDBOX_DOMAIN, 'https://')
+ : '';
+
+export async function getProductRecommendations(productId: string): Promise {
+ const productRecommendationsEndpoint = `/products/${productId}/recommendations`;
+
+ const res = await ocFetch({
+ method: 'GET',
+ endpoint: productRecommendationsEndpoint,
+ tags: [TAGS.products]
+ });
+
+ return res.body as T;
+}
+
+async function ocFetch(options: {
+ method: 'POST' | 'GET';
+ endpoint: string;
+ cache?: RequestCache;
+ headers?: HeadersInit;
+ tags?: string[];
+ variables?: ExtractVariables;
+}): Promise<{ status: number; body: T } | never> {
+ const apiEndpoint = `${ocapiDomain}${process.env.SFCC_OPENCOMMERCE_SHOP_API_ENDPOINT}${options.endpoint}?client_id=${process.env.SFCC_CLIENT_ID}`;
+ return salesforceFetch({
+ ...options,
+ apiEndpoint
+ });
+}
diff --git a/lib/sfcc/scapi.ts b/lib/sfcc/scapi.ts
new file mode 100644
index 000000000..e32c05b8e
--- /dev/null
+++ b/lib/sfcc/scapi.ts
@@ -0,0 +1,55 @@
+import { Collection } from './types';
+import { ExtractVariables, salesforceFetch } from './utils';
+
+export async function scapiFetch(options: {
+ method: 'POST' | 'GET';
+ apiEndpoint: string;
+ cache?: RequestCache;
+ headers?: HeadersInit;
+ tags?: string[];
+ variables?: ExtractVariables;
+}): 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({
+ ...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 {
+ const accessToken = await fetchAccessToken();
+
+ const response = await scapiFetch({
+ 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;
+}
diff --git a/lib/type-guards.ts b/lib/sfcc/type-guards.ts
similarity index 70%
rename from lib/type-guards.ts
rename to lib/sfcc/type-guards.ts
index 6f920d1f6..2912144f0 100644
--- a/lib/type-guards.ts
+++ b/lib/sfcc/type-guards.ts
@@ -1,14 +1,18 @@
-export interface ShopifyErrorLike {
- status: number;
- message: Error;
- cause?: Error;
+export interface SFCCErrorLike {
+ _v?: string;
+ fault?: {
+ arguments?: unknown;
+ type?: string;
+ message?: string;
+ };
}
export const isObject = (object: unknown): object is Record => {
return typeof object === 'object' && object !== null && !Array.isArray(object);
};
-export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
+export const isSFCCError = (error: unknown): error is SFCCErrorLike => {
+ console.log({ error });
if (!isObject(error)) return false;
if (error instanceof Error) return true;
diff --git a/lib/sfcc/types.ts b/lib/sfcc/types.ts
new file mode 100644
index 000000000..e9460001f
--- /dev/null
+++ b/lib/sfcc/types.ts
@@ -0,0 +1,146 @@
+export type Connection = {
+ edges: Array>;
+};
+
+export type Edge = {
+ node: T;
+};
+
+export type Collection = {
+ handle: string;
+ title: string;
+ description: string;
+ seo: SEO;
+ updatedAt: string;
+ path: string;
+};
+
+export type SalesforceProduct = {
+ id: string;
+ title: string;
+ handle: string;
+ description: string;
+ descriptionHtml: string;
+ featuredImage: Image;
+ priceRange: {
+ maxVariantPrice: Money;
+ minVariantPrice: Money;
+ };
+ seo: SEO;
+ options: ProductOption[];
+ tags: string[];
+ variants: ProductVariant[];
+ images: Image[];
+ availableForSale: boolean;
+ updatedAt: string;
+};
+
+export type Product = Omit & {
+ variants: ProductVariant[];
+ images: Image[];
+};
+
+export type ProductVariant = {
+ id: string;
+ title: string;
+ availableForSale: boolean;
+ selectedOptions: {
+ name: string;
+ value: string;
+ }[];
+ price: Money;
+};
+
+export type ProductOption = {
+ id: string;
+ name: string;
+ values: string[];
+};
+
+export type Money = {
+ amount: string;
+ currencyCode: string;
+};
+
+export type Image = {
+ url: string;
+ altText: string;
+ height: number;
+ width: number;
+};
+
+export type SEO = {
+ title: string;
+ description: string;
+};
+
+export type SalesforceCart = {
+ id: string | undefined;
+ checkoutUrl: string;
+ cost: {
+ subtotalAmount: Money;
+ totalAmount: Money;
+ totalTaxAmount: Money;
+ };
+ lines: Connection;
+ totalQuantity: number;
+};
+
+export type Cart = Omit & {
+ lines: CartItem[];
+};
+
+export type CartItem = {
+ id: string | undefined;
+ quantity: number;
+ cost: {
+ totalAmount: Money;
+ };
+ merchandise: {
+ id: string;
+ title: string;
+ selectedOptions: {
+ name: string;
+ value: string;
+ }[];
+ product: CartProduct;
+ };
+};
+
+export type CartProduct = {
+ id: string;
+ handle: string;
+ title: string;
+ featuredImage: Image;
+};
+
+export type ProductRecommendations = {
+ id: string;
+ name: string;
+ recommendations: RecommendedProduct[];
+};
+
+export type RecommendedProduct = {
+ recommended_item_id: string;
+ recommendation_type: {
+ _type: string;
+ display_value: string;
+ value: number;
+ };
+};
+
+export type Menu = {
+ title: string;
+ path: string;
+};
+
+export type Page = {
+ id: string;
+ title: string;
+ handle: string;
+ body: string;
+ bodySummary: string;
+ seo?: SEO;
+ createdAt: string;
+ updatedAt: string;
+};
diff --git a/lib/sfcc/utils.ts b/lib/sfcc/utils.ts
new file mode 100644
index 000000000..6e072cf46
--- /dev/null
+++ b/lib/sfcc/utils.ts
@@ -0,0 +1,89 @@
+import { isSFCCError } from './type-guards';
+
+export type ExtractVariables = T extends { variables: object } ? T['variables'] : never;
+
+export async function salesforceFetch({
+ method,
+ cache = 'force-cache',
+ headers,
+ tags,
+ variables,
+ apiEndpoint
+}: {
+ method: 'POST' | 'GET';
+ apiEndpoint: string;
+ cache?: RequestCache;
+ headers?: HeadersInit;
+ tags?: string[];
+ variables?: ExtractVariables;
+}): Promise<{ status: number; body: T } | never> {
+ try {
+ const fetchOptions: RequestInit = {
+ method,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...headers
+ },
+ cache,
+ ...(tags && { next: { tags } })
+ };
+
+ if (method === 'POST' && variables) {
+ fetchOptions.body = JSON.stringify({ variables });
+ }
+
+ const res = await fetch(apiEndpoint, fetchOptions);
+
+ const body = await res.json();
+
+ if (body.errors) {
+ throw body.errors[0];
+ }
+
+ return {
+ status: res.status,
+ body
+ };
+ } catch (e) {
+ if (isSFCCError(e)) {
+ throw {
+ version: e._v || 'unknown',
+ fault: e?.fault || {},
+ apiEndpoint
+ };
+ }
+
+ throw {
+ error: e
+ };
+ }
+}
+
+export const validateEnvironmentVariables = () => {
+ const requiredEnvironmentVariables = [
+ 'SITE_NAME',
+ 'SFCC_CLIENT_ID',
+ 'SFCC_ORGANIZATIONID',
+ 'SFCC_SECRET',
+ 'SFCC_SHORTCODE',
+ 'SFCC_SITEID',
+ 'SFCC_SANDBOX_DOMAIN',
+ 'SFCC_OPENCOMMERCE_SHOP_API_ENDPOINT',
+ 'SFCC_REVALIDATION_SECRET'
+ ];
+ const missingEnvironmentVariables = [] as string[];
+
+ requiredEnvironmentVariables.forEach((envVar) => {
+ if (!process.env[envVar]) {
+ missingEnvironmentVariables.push(envVar);
+ }
+ });
+
+ if (missingEnvironmentVariables.length) {
+ throw new Error(
+ `The following environment variables are missing. Your site will not work without them. Read more: https://vercel.com/docs/integrations/salesforce-commerce-cloud#configure-environment-variables\n\n${missingEnvironmentVariables.join(
+ '\n'
+ )}\n`
+ );
+ }
+};
diff --git a/lib/shopify/fragments/cart.ts b/lib/shopify/fragments/cart.ts
deleted file mode 100644
index fc5c838dd..000000000
--- a/lib/shopify/fragments/cart.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import productFragment from './product';
-
-const cartFragment = /* GraphQL */ `
- fragment cart on Cart {
- id
- checkoutUrl
- cost {
- subtotalAmount {
- amount
- currencyCode
- }
- totalAmount {
- amount
- currencyCode
- }
- totalTaxAmount {
- amount
- currencyCode
- }
- }
- lines(first: 100) {
- edges {
- node {
- id
- quantity
- cost {
- totalAmount {
- amount
- currencyCode
- }
- }
- merchandise {
- ... on ProductVariant {
- id
- title
- selectedOptions {
- name
- value
- }
- product {
- ...product
- }
- }
- }
- }
- }
- }
- totalQuantity
- }
- ${productFragment}
-`;
-
-export default cartFragment;
diff --git a/lib/shopify/fragments/image.ts b/lib/shopify/fragments/image.ts
deleted file mode 100644
index 5d002f175..000000000
--- a/lib/shopify/fragments/image.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-const imageFragment = /* GraphQL */ `
- fragment image on Image {
- url
- altText
- width
- height
- }
-`;
-
-export default imageFragment;
diff --git a/lib/shopify/fragments/product.ts b/lib/shopify/fragments/product.ts
deleted file mode 100644
index be14dedca..000000000
--- a/lib/shopify/fragments/product.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import imageFragment from './image';
-import seoFragment from './seo';
-
-const productFragment = /* GraphQL */ `
- fragment product on Product {
- id
- handle
- availableForSale
- title
- description
- descriptionHtml
- options {
- id
- name
- values
- }
- priceRange {
- maxVariantPrice {
- amount
- currencyCode
- }
- minVariantPrice {
- amount
- currencyCode
- }
- }
- variants(first: 250) {
- edges {
- node {
- id
- title
- availableForSale
- selectedOptions {
- name
- value
- }
- price {
- amount
- currencyCode
- }
- }
- }
- }
- featuredImage {
- ...image
- }
- images(first: 20) {
- edges {
- node {
- ...image
- }
- }
- }
- seo {
- ...seo
- }
- tags
- updatedAt
- }
- ${imageFragment}
- ${seoFragment}
-`;
-
-export default productFragment;
diff --git a/lib/shopify/fragments/seo.ts b/lib/shopify/fragments/seo.ts
deleted file mode 100644
index 2d4786c4f..000000000
--- a/lib/shopify/fragments/seo.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-const seoFragment = /* GraphQL */ `
- fragment seo on SEO {
- description
- title
- }
-`;
-
-export default seoFragment;
diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts
deleted file mode 100644
index b90893172..000000000
--- a/lib/shopify/index.ts
+++ /dev/null
@@ -1,501 +0,0 @@
-import {
- HIDDEN_PRODUCT_TAG,
- SHOPIFY_GRAPHQL_API_ENDPOINT,
- TAGS
-} from 'lib/constants';
-import { isShopifyError } from 'lib/type-guards';
-import { ensureStartsWith } from 'lib/utils';
-import {
- revalidateTag,
- unstable_cacheTag as cacheTag,
- unstable_cacheLife as cacheLife
-} from 'next/cache';
-import { cookies, headers } from 'next/headers';
-import { NextRequest, NextResponse } from 'next/server';
-import {
- addToCartMutation,
- createCartMutation,
- editCartItemsMutation,
- removeFromCartMutation
-} from './mutations/cart';
-import { getCartQuery } from './queries/cart';
-import {
- getCollectionProductsQuery,
- getCollectionQuery,
- getCollectionsQuery
-} from './queries/collection';
-import { getMenuQuery } from './queries/menu';
-import { getPageQuery, getPagesQuery } from './queries/page';
-import {
- getProductQuery,
- getProductRecommendationsQuery,
- getProductsQuery
-} from './queries/product';
-import {
- Cart,
- Collection,
- Connection,
- Image,
- Menu,
- Page,
- Product,
- ShopifyAddToCartOperation,
- ShopifyCart,
- ShopifyCartOperation,
- ShopifyCollection,
- ShopifyCollectionOperation,
- ShopifyCollectionProductsOperation,
- ShopifyCollectionsOperation,
- ShopifyCreateCartOperation,
- ShopifyMenuOperation,
- ShopifyPageOperation,
- ShopifyPagesOperation,
- ShopifyProduct,
- ShopifyProductOperation,
- ShopifyProductRecommendationsOperation,
- ShopifyProductsOperation,
- ShopifyRemoveFromCartOperation,
- ShopifyUpdateCartOperation
-} from './types';
-
-const domain = process.env.SHOPIFY_STORE_DOMAIN
- ? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
- : '';
-const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
-const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
-
-type ExtractVariables = T extends { variables: object }
- ? T['variables']
- : never;
-
-export async function shopifyFetch({
- headers,
- query,
- variables
-}: {
- headers?: HeadersInit;
- query: string;
- variables?: ExtractVariables;
-}): Promise<{ status: number; body: T } | never> {
- try {
- const result = await fetch(endpoint, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-Shopify-Storefront-Access-Token': key,
- ...headers
- },
- body: JSON.stringify({
- ...(query && { query }),
- ...(variables && { variables })
- })
- });
-
- const body = await result.json();
-
- if (body.errors) {
- throw body.errors[0];
- }
-
- return {
- status: result.status,
- body
- };
- } catch (e) {
- if (isShopifyError(e)) {
- throw {
- cause: e.cause?.toString() || 'unknown',
- status: e.status || 500,
- message: e.message,
- query
- };
- }
-
- throw {
- error: e,
- query
- };
- }
-}
-
-const removeEdgesAndNodes = (array: Connection): T[] => {
- return array.edges.map((edge) => edge?.node);
-};
-
-const reshapeCart = (cart: ShopifyCart): Cart => {
- if (!cart.cost?.totalTaxAmount) {
- cart.cost.totalTaxAmount = {
- amount: '0.0',
- currencyCode: cart.cost.totalAmount.currencyCode
- };
- }
-
- return {
- ...cart,
- lines: removeEdgesAndNodes(cart.lines)
- };
-};
-
-const reshapeCollection = (
- collection: ShopifyCollection
-): Collection | undefined => {
- if (!collection) {
- return undefined;
- }
-
- return {
- ...collection,
- path: `/search/${collection.handle}`
- };
-};
-
-const reshapeCollections = (collections: ShopifyCollection[]) => {
- const reshapedCollections = [];
-
- for (const collection of collections) {
- if (collection) {
- const reshapedCollection = reshapeCollection(collection);
-
- if (reshapedCollection) {
- reshapedCollections.push(reshapedCollection);
- }
- }
- }
-
- return reshapedCollections;
-};
-
-const reshapeImages = (images: Connection, productTitle: string) => {
- const flattened = removeEdgesAndNodes(images);
-
- return flattened.map((image) => {
- const filename = image.url.match(/.*\/(.*)\..*/)?.[1];
- return {
- ...image,
- altText: image.altText || `${productTitle} - ${filename}`
- };
- });
-};
-
-const reshapeProduct = (
- product: ShopifyProduct,
- filterHiddenProducts: boolean = true
-) => {
- if (
- !product ||
- (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))
- ) {
- return undefined;
- }
-
- const { images, variants, ...rest } = product;
-
- return {
- ...rest,
- images: reshapeImages(images, product.title),
- variants: removeEdgesAndNodes(variants)
- };
-};
-
-const reshapeProducts = (products: ShopifyProduct[]) => {
- const reshapedProducts = [];
-
- for (const product of products) {
- if (product) {
- const reshapedProduct = reshapeProduct(product);
-
- if (reshapedProduct) {
- reshapedProducts.push(reshapedProduct);
- }
- }
- }
-
- return reshapedProducts;
-};
-
-export async function createCart(): Promise {
- const res = await shopifyFetch({
- query: createCartMutation
- });
-
- return reshapeCart(res.body.data.cartCreate.cart);
-}
-
-export async function addToCart(
- lines: { merchandiseId: string; quantity: number }[]
-): Promise {
- const cartId = (await cookies()).get('cartId')?.value!;
- const res = await shopifyFetch({
- query: addToCartMutation,
- variables: {
- cartId,
- lines
- }
- });
- return reshapeCart(res.body.data.cartLinesAdd.cart);
-}
-
-export async function removeFromCart(lineIds: string[]): Promise {
- const cartId = (await cookies()).get('cartId')?.value!;
- const res = await shopifyFetch({
- query: removeFromCartMutation,
- variables: {
- cartId,
- lineIds
- }
- });
-
- return reshapeCart(res.body.data.cartLinesRemove.cart);
-}
-
-export async function updateCart(
- lines: { id: string; merchandiseId: string; quantity: number }[]
-): Promise {
- const cartId = (await cookies()).get('cartId')?.value!;
- const res = await shopifyFetch({
- query: editCartItemsMutation,
- variables: {
- cartId,
- lines
- }
- });
-
- return reshapeCart(res.body.data.cartLinesUpdate.cart);
-}
-
-export async function getCart(): Promise {
- const cartId = (await cookies()).get('cartId')?.value;
-
- if (!cartId) {
- return undefined;
- }
-
- const res = await shopifyFetch({
- query: getCartQuery,
- variables: { cartId }
- });
-
- // Old carts becomes `null` when you checkout.
- if (!res.body.data.cart) {
- return undefined;
- }
-
- return reshapeCart(res.body.data.cart);
-}
-
-export async function getCollection(
- handle: string
-): Promise {
- 'use cache';
- cacheTag(TAGS.collections);
- cacheLife('days');
-
- const res = await shopifyFetch({
- query: getCollectionQuery,
- variables: {
- handle
- }
- });
-
- return reshapeCollection(res.body.data.collection);
-}
-
-export async function getCollectionProducts({
- collection,
- reverse,
- sortKey
-}: {
- collection: string;
- reverse?: boolean;
- sortKey?: string;
-}): Promise {
- 'use cache';
- cacheTag(TAGS.collections, TAGS.products);
- cacheLife('days');
-
- const res = await shopifyFetch({
- query: getCollectionProductsQuery,
- variables: {
- handle: collection,
- reverse,
- sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey
- }
- });
-
- if (!res.body.data.collection) {
- console.log(`No collection found for \`${collection}\``);
- return [];
- }
-
- return reshapeProducts(
- removeEdgesAndNodes(res.body.data.collection.products)
- );
-}
-
-export async function getCollections(): Promise {
- 'use cache';
- cacheTag(TAGS.collections);
- cacheLife('days');
-
- const res = await shopifyFetch({
- query: getCollectionsQuery
- });
- const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
- const collections = [
- {
- handle: '',
- title: 'All',
- description: 'All products',
- seo: {
- title: 'All',
- description: 'All products'
- },
- path: '/search',
- updatedAt: new Date().toISOString()
- },
- // Filter out the `hidden` collections.
- // Collections that start with `hidden-*` need to be hidden on the search page.
- ...reshapeCollections(shopifyCollections).filter(
- (collection) => !collection.handle.startsWith('hidden')
- )
- ];
-
- return collections;
-}
-
-export async function getMenu(handle: string): Promise