merge 15-2 upgrade into commerce sfcc

This commit is contained in:
Alex 2025-03-06 11:16:01 -06:00
commit 092b26ea0c
54 changed files with 3027 additions and 2096 deletions

View File

@ -1,5 +1,12 @@
COMPANY_NAME="Vercel Inc."
SITE_NAME="Next.js Commerce"
SHOPIFY_REVALIDATION_SECRET=""
SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
NEXT_PUBLIC_VERCEL_URL="http://localhost:3000"
SFCC_CLIENT_ID=""
SFCC_ORGANIZATIONID="f_ecom_0000_000"
SFCC_SECRET=""
SFCC_SHORTCODE="000123"
SFCC_SITEID="RefArch"
SITE_NAME="ACME Store"
SFCC_SANDBOX_DOMAIN="zylq-002.dx.commercecloud.salesforce.com"
SFCC_OPENCOMMERCE_SHOP_API_ENDPOINT="/s/RefArch/dw/shop/v24_5"
SFCC_REVALIDATION_SECRET=""

View File

@ -1,8 +1,8 @@
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=COMPANY_NAME,SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STORE_DOMAIN,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SITE_NAME)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce-sfcc&env=NEXT_PUBLIC_VERCEL_URL,SFCC_CLIENT_ID,SFCC_ORGANIZATIONID,SFCC_SECRET,SFCC_SHORTCODE,SFCC_SITEID,SITE_NAME,SFCC_SANDBOX_DOMAIN,SFCC_OPENCOMMERCE_SHOP_API_ENDPOINT,SFCC_REVALIDATION_SECRET&project-name=nextjs-commerce-sfcc&repository-name=nextjs-commerce-sfcc&demo-title=ACME%20Store&demo-description=A%20high-performance%20ecommerce%20store%20built%20with%20Next.js%2C%20Vercel%2C%20and%20Salesforce%20Commerce%20Cloud&demo-url=https%3A%2F%2Fnextjs-salesforce-commerce-cloud.vercel.app%2F)
# Next.js Commerce
A high-performance, server-rendered Next.js App Router ecommerce application.
A high-perfomance, server-rendered Next.js App Router ecommerce application.
This template uses React Server Components, Server Actions, `Suspense`, `useOptimistic`, and more.
@ -12,42 +12,26 @@ This template uses React Server Components, Server Actions, `Suspense`, `useOpti
## Providers
Vercel will only be actively maintaining a Shopify version [as outlined in our vision and strategy for Next.js Commerce](https://github.com/vercel/commerce/pull/966).
Vercel is happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/sfcc` file with their own implementation while leaving the rest of the template mostly unchanged.
Vercel is happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged.
- Shopify (this repository)
- Salesforce Commerce Cloud (this repository)
- [Shopify](https://github.com/vercel/commerce) ([Demo](https://demo.vercel.store/))
- [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
- [Ecwid by Lightspeed](https://github.com/Ecwid/ecwid-nextjs-commerce/) ([Demo](https://ecwid-nextjs-commerce.vercel.app/))
- [Geins](https://github.com/geins-io/vercel-nextjs-commerce) ([Demo](https://geins-nextjs-commerce-starter.vercel.app/))
- [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
- [Prodigy Commerce](https://github.com/prodigycommerce/nextjs-commerce) ([Demo](https://prodigy-nextjs-commerce.vercel.app/))
- [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
- [Shopware](https://github.com/shopwareLabs/vercel-commerce) ([Demo](https://shopware-vercel-commerce-react.vercel.app/))
- [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
- [Umbraco](https://github.com/umbraco/Umbraco.VercelCommerce.Demo) ([Demo](https://vercel-commerce-demo.umbraco.com/))
- [Wix](https://github.com/wix/headless-templates/tree/main/nextjs/commerce) ([Demo](https://wix-nextjs-commerce.vercel.app/))
- [Fourthwall](https://github.com/FourthwallHQ/vercel-commerce) ([Demo](https://vercel-storefront.fourthwall.app/))
- [Wix](https://github.com/wix/nextjs-commerce) ([Demo](https://wix-nextjs-commerce.vercel.app/))
> Note: Providers, if you are looking to use similar products for your demo, you can [download these assets](https://drive.google.com/file/d/1q_bKerjrwZgHwCw0ovfUMW6He9VtepO_/view?usp=sharing).
## Integrations
Integrations enable upgraded or additional functionality for Next.js Commerce
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
- Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
- Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
- [React Bricks](https://github.com/ReactBricks/nextjs-commerce-rb) ([Demo](https://nextjs-commerce.reactbricks.com/))
- Edit pages, product details, and footer content visually using [React Bricks](https://www.reactbricks.com) visual headless CMS.
## Running locally
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary.
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify store.
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Salesforce Commerce Cloud store.
1. Install Vercel CLI: `npm i -g vercel`
2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
@ -65,11 +49,7 @@ Your app should now be running on [localhost:3000](http://localhost:3000/).
1. Run `vc link`.
1. Select the `Vercel Solutions` scope.
1. Connect to the existing `commerce-shopify` project.
1. Connect to the existing `commerce-sfcc` project.
1. Run `vc env pull` to get environment variables.
1. Run `pnpm dev` to ensure everything is working correctly.
</details>
## Vercel, Next.js Commerce, and Shopify Integration Guide
You can use this comprehensive [integration guide](https://vercel.com/docs/integrations/ecommerce/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel.

View File

@ -1,8 +1,11 @@
import OpengraphImage from 'components/opengraph-image';
import { getPage } from 'lib/shopify';
import { getPage } from 'lib/sfcc/content';
export default async function Image({ params }: { params: { page: string } }) {
const page = await getPage(params.page);
const page = getPage(params.page);
if (!page) return;
const title = page.seo?.title || page.title;
return await OpengraphImage({ title });

View File

@ -1,8 +1,8 @@
import type { Metadata } from 'next';
import type { Metadata } from "next";
import Prose from 'components/prose';
import { getPage } from 'lib/shopify';
import { notFound } from 'next/navigation';
import Prose from "components/prose";
import { getPage } from "lib/sfcc/content";
import { notFound } from "next/navigation";
export async function generateMetadata(props: {
params: Promise<{ page: string }>;
@ -18,12 +18,14 @@ export async function generateMetadata(props: {
openGraph: {
publishedTime: page.createdAt,
modifiedTime: page.updatedAt,
type: 'article'
}
type: "article",
},
};
}
export default async function Page(props: { params: Promise<{ page: string }> }) {
export default async function Page(props: {
params: Promise<{ page: string }>;
}) {
const params = await props.params;
const page = await getPage(params.page);
@ -34,11 +36,14 @@ export default async function Page(props: { params: Promise<{ page: string }> })
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
<Prose className="mb-8" html={page.body} />
<p className="text-sm italic">
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(new Date(page.updatedAt))}.`}
{`This document was last updated on ${new Intl.DateTimeFormat(
undefined,
{
year: "numeric",
month: "long",
day: "numeric",
}
).format(new Date(page.updatedAt))}.`}
</p>
</>
);

View File

@ -1,4 +1,4 @@
import { revalidate } from 'lib/shopify';
import { revalidate } from 'lib/sfcc';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest): Promise<NextResponse> {

View File

@ -1,12 +1,12 @@
import { CartProvider } from 'components/cart/cart-context';
import { Navbar } from 'components/layout/navbar';
import { WelcomeToast } from 'components/welcome-toast';
import { GeistSans } from 'geist/font/sans';
import { getCart } from 'lib/shopify';
import { ReactNode } from 'react';
import { Toaster } from 'sonner';
import './globals.css';
import { baseUrl } from 'lib/utils';
import { CartProvider } from "components/cart/cart-context";
import { Navbar } from "components/layout/navbar";
import { WelcomeToast } from "components/welcome-toast";
import { GeistSans } from "geist/font/sans";
import { getCart } from "lib/sfcc";
import { baseUrl } from "lib/utils";
import { ReactNode } from "react";
import { Toaster } from "sonner";
import "./globals.css";
const { SITE_NAME } = process.env;
@ -14,16 +14,16 @@ export const metadata = {
metadataBase: new URL(baseUrl),
title: {
default: SITE_NAME!,
template: `%s | ${SITE_NAME}`
template: `%s | ${SITE_NAME}`,
},
robots: {
follow: true,
index: true
}
index: true,
},
};
export default async function RootLayout({
children
children,
}: {
children: ReactNode;
}) {

View File

@ -1,13 +1,13 @@
import { Carousel } from 'components/carousel';
import { ThreeItemGrid } from 'components/grid/three-items';
import Footer from 'components/layout/footer';
import { Carousel } from "components/carousel";
import { ThreeItemGrid } from "components/grid/three-items";
import Footer from "components/layout/footer";
export const metadata = {
description:
'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
"High-performance ecommerce store built with Next.js, Vercel, and Salesforce Commerce Cloud.",
openGraph: {
type: 'website'
}
type: "website",
},
};
export default function HomePage() {

View File

@ -7,8 +7,8 @@ import { Gallery } from 'components/product/gallery';
import { ProductProvider } from 'components/product/product-context';
import { ProductDescription } from 'components/product/product-description';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct, getProductRecommendations } from 'lib/shopify';
import { Image } from 'lib/shopify/types';
import { getProduct, getProductRecommendations } from 'lib/sfcc';
import { Image } from 'lib/sfcc/types';
import Link from 'next/link';
import { Suspense } from 'react';

View File

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

View File

@ -1,10 +1,10 @@
import { getCollection, getCollectionProducts } from 'lib/shopify';
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Grid from 'components/grid';
import ProductGridItems from 'components/layout/product-grid-items';
import { defaultSort, sorting } from 'lib/constants';
import { getCollection, getCollectionProducts } from 'lib/sfcc';
import { defaultSort, sorting } from 'lib/sfcc/constants';
export async function generateMetadata(props: {
params: Promise<{ collection: string }>;

View File

@ -1,7 +1,7 @@
import Grid from 'components/grid';
import ProductGridItems from 'components/layout/product-grid-items';
import { defaultSort, sorting } from 'lib/constants';
import { getProducts } from 'lib/shopify';
import { getProducts } from 'lib/sfcc';
import { defaultSort, sorting } from 'lib/sfcc/constants';
export const metadata = {
title: 'Search',

View File

@ -1,49 +1,47 @@
import { getCollections, getPages, getProducts } from 'lib/shopify';
import { baseUrl, validateEnvironmentVariables } from 'lib/utils';
import { MetadataRoute } from 'next';
import { getCollections, getProducts } from "lib/sfcc";
import { getPages } from "lib/sfcc/content";
import { baseUrl } from "lib/utils";
import { MetadataRoute } from "next";
type Route = {
url: string;
lastModified: string;
};
export const dynamic = 'force-dynamic';
export const dynamic = "force-dynamic";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
validateEnvironmentVariables();
const routesMap = [''].map((route) => ({
const routesMap = [""].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date().toISOString()
lastModified: new Date().toISOString(),
}));
const collectionsPromise = getCollections().then((collections) =>
collections.map((collection) => ({
url: `${baseUrl}${collection.path}`,
lastModified: collection.updatedAt
lastModified: collection.updatedAt,
}))
);
const productsPromise = getProducts({}).then((products) =>
products.map((product) => ({
url: `${baseUrl}/product/${product.handle}`,
lastModified: product.updatedAt
lastModified: product.updatedAt,
}))
);
const pagesPromise = getPages().then((pages) =>
pages.map((page) => ({
url: `${baseUrl}/${page.handle}`,
lastModified: page.updatedAt
}))
);
const pages = getPages().map((page) => ({
url: `${baseUrl}/${page.handle}`,
lastModified: page.updatedAt,
}));
let fetchedRoutes: Route[] = [];
try {
fetchedRoutes = (
await Promise.all([collectionsPromise, productsPromise, pagesPromise])
).flat();
fetchedRoutes = [
...(await Promise.all([collectionsPromise, productsPromise])).flat(),
...pages,
];
} catch (error) {
throw JSON.stringify(error, null, 2);
}

View File

@ -1,4 +1,4 @@
import { getCollectionProducts } from 'lib/shopify';
import { getCollectionProducts } from 'lib/sfcc';
import Link from 'next/link';
import { GridTileImage } from './grid/tile';

View File

@ -1,30 +1,30 @@
'use server';
"use server";
import { TAGS } from 'lib/constants';
import { TAGS } from "lib/constants";
import {
addToCart,
createCart,
getCart,
removeFromCart,
updateCart
} from 'lib/shopify';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
updateCart,
} from "lib/sfcc";
import { revalidateTag } from "next/cache";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export async function addItem(
prevState: any,
selectedVariantId: string | undefined
) {
if (!selectedVariantId) {
return 'Error adding item to cart';
return "Error adding item to cart";
}
try {
await addToCart([{ merchandiseId: selectedVariantId, quantity: 1 }]);
revalidateTag(TAGS.cart);
} catch (e) {
return 'Error adding item to cart';
return "Error adding item to cart";
}
}
@ -33,7 +33,7 @@ export async function removeItem(prevState: any, merchandiseId: string) {
const cart = await getCart();
if (!cart) {
return 'Error fetching cart';
return "Error fetching cart";
}
const lineItem = cart.lines.find(
@ -44,10 +44,10 @@ export async function removeItem(prevState: any, merchandiseId: string) {
await removeFromCart([lineItem.id]);
revalidateTag(TAGS.cart);
} else {
return 'Item not found in cart';
return "Item not found in cart";
}
} catch (e) {
return 'Error removing item from cart';
return "Error removing item from cart";
}
}
@ -64,7 +64,7 @@ export async function updateItemQuantity(
const cart = await getCart();
if (!cart) {
return 'Error fetching cart';
return "Error fetching cart";
}
const lineItem = cart.lines.find(
@ -79,8 +79,8 @@ export async function updateItemQuantity(
{
id: lineItem.id,
merchandiseId,
quantity
}
quantity,
},
]);
}
} else if (quantity > 0) {
@ -91,7 +91,7 @@ export async function updateItemQuantity(
revalidateTag(TAGS.cart);
} catch (e) {
console.error(e);
return 'Error updating item quantity';
return "Error updating item quantity";
}
}
@ -102,5 +102,5 @@ export async function redirectToCheckout() {
export async function createCartAndSetCookie() {
let cart = await createCart();
(await cookies()).set('cartId', cart.id!);
(await cookies()).set("cartId", cart.id!);
}

View File

@ -1,23 +1,23 @@
'use client';
"use client";
import { PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { addItem } from 'components/cart/actions';
import { useProduct } from 'components/product/product-context';
import { Product, ProductVariant } from 'lib/shopify/types';
import { useActionState } from 'react';
import { useCart } from './cart-context';
import { PlusIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import { addItem } from "components/cart/actions";
import { useProduct } from "components/product/product-context";
import { Product, ProductVariant } from "lib/sfcc/types";
import { useActionState } from "react";
import { useCart } from "./cart-context";
function SubmitButton({
availableForSale,
selectedVariantId
selectedVariantId,
}: {
availableForSale: boolean;
selectedVariantId: string | undefined;
}) {
const buttonClasses =
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';
"relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white";
const disabledClasses = "cursor-not-allowed opacity-60 hover:opacity-60";
if (!availableForSale) {
return (
@ -46,7 +46,7 @@ function SubmitButton({
<button
aria-label="Add to cart"
className={clsx(buttonClasses, {
'hover:opacity-90': true
"hover:opacity-90": true,
})}
>
<div className="absolute left-0 ml-4">

View File

@ -1,28 +1,23 @@
'use client';
"use client";
import type {
Cart,
CartItem,
Product,
ProductVariant
} from 'lib/shopify/types';
import { Cart, CartItem, Product, ProductVariant } from "lib/sfcc/types";
import React, {
createContext,
use,
useContext,
useMemo,
useOptimistic
} from 'react';
useOptimistic,
} from "react";
type UpdateType = 'plus' | 'minus' | 'delete';
type UpdateType = "plus" | "minus" | "delete";
type CartAction =
| {
type: 'UPDATE_ITEM';
type: "UPDATE_ITEM";
payload: { merchandiseId: string; updateType: UpdateType };
}
| {
type: 'ADD_ITEM';
type: "ADD_ITEM";
payload: { variant: ProductVariant; product: Product };
};
@ -40,10 +35,10 @@ function updateCartItem(
item: CartItem,
updateType: UpdateType
): CartItem | null {
if (updateType === 'delete') return null;
if (updateType === "delete") return null;
const newQuantity =
updateType === 'plus' ? item.quantity + 1 : item.quantity - 1;
updateType === "plus" ? item.quantity + 1 : item.quantity - 1;
if (newQuantity === 0) return null;
const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity;
@ -59,9 +54,9 @@ function updateCartItem(
...item.cost,
totalAmount: {
...item.cost.totalAmount,
amount: newTotalAmount
}
}
amount: newTotalAmount,
},
},
};
}
@ -79,8 +74,8 @@ function createOrUpdateCartItem(
cost: {
totalAmount: {
amount: totalAmount,
currencyCode: variant.price.currencyCode
}
currencyCode: variant.price.currencyCode,
},
},
merchandise: {
id: variant.id,
@ -90,43 +85,43 @@ function createOrUpdateCartItem(
id: product.id,
handle: product.handle,
title: product.title,
featuredImage: product.featuredImage
}
}
featuredImage: product.featuredImage,
},
},
};
}
function updateCartTotals(
lines: CartItem[]
): Pick<Cart, 'totalQuantity' | 'cost'> {
): Pick<Cart, "totalQuantity" | "cost"> {
const totalQuantity = lines.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = lines.reduce(
(sum, item) => sum + Number(item.cost.totalAmount.amount),
0
);
const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? 'USD';
const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? "USD";
return {
totalQuantity,
cost: {
subtotalAmount: { amount: totalAmount.toString(), currencyCode },
totalAmount: { amount: totalAmount.toString(), currencyCode },
totalTaxAmount: { amount: '0', currencyCode }
}
totalTaxAmount: { amount: "0", currencyCode },
},
};
}
function createEmptyCart(): Cart {
return {
id: undefined,
checkoutUrl: '',
checkoutUrl: "",
totalQuantity: 0,
lines: [],
cost: {
subtotalAmount: { amount: '0', currencyCode: 'USD' },
totalAmount: { amount: '0', currencyCode: 'USD' },
totalTaxAmount: { amount: '0', currencyCode: 'USD' }
}
subtotalAmount: { amount: "0", currencyCode: "USD" },
totalAmount: { amount: "0", currencyCode: "USD" },
totalTaxAmount: { amount: "0", currencyCode: "USD" },
},
};
}
@ -134,7 +129,7 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
const currentCart = state || createEmptyCart();
switch (action.type) {
case 'UPDATE_ITEM': {
case "UPDATE_ITEM": {
const { merchandiseId, updateType } = action.payload;
const updatedLines = currentCart.lines
.map((item) =>
@ -151,18 +146,18 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
totalQuantity: 0,
cost: {
...currentCart.cost,
totalAmount: { ...currentCart.cost.totalAmount, amount: '0' }
}
totalAmount: { ...currentCart.cost.totalAmount, amount: "0" },
},
};
}
return {
...currentCart,
...updateCartTotals(updatedLines),
lines: updatedLines
lines: updatedLines,
};
}
case 'ADD_ITEM': {
case "ADD_ITEM": {
const { variant, product } = action.payload;
const existingItem = currentCart.lines.find(
(item) => item.merchandise.id === variant.id
@ -182,7 +177,7 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
return {
...currentCart,
...updateCartTotals(updatedLines),
lines: updatedLines
lines: updatedLines,
};
}
default:
@ -192,7 +187,7 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
export function CartProvider({
children,
cartPromise
cartPromise,
}: {
children: React.ReactNode;
cartPromise: Promise<Cart | undefined>;
@ -207,7 +202,7 @@ export function CartProvider({
export function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart must be used within a CartProvider');
throw new Error("useCart must be used within a CartProvider");
}
const initialCart = use(context.cartPromise);
@ -218,20 +213,20 @@ export function useCart() {
const updateCartItem = (merchandiseId: string, updateType: UpdateType) => {
updateOptimisticCart({
type: 'UPDATE_ITEM',
payload: { merchandiseId, updateType }
type: "UPDATE_ITEM",
payload: { merchandiseId, updateType },
});
};
const addCartItem = (variant: ProductVariant, product: Product) => {
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } });
updateOptimisticCart({ type: "ADD_ITEM", payload: { variant, product } });
};
return useMemo(
() => ({
cart: optimisticCart,
updateCartItem,
addCartItem
addCartItem,
}),
[optimisticCart]
);

View File

@ -1,13 +1,13 @@
'use client';
"use client";
import { XMarkIcon } from '@heroicons/react/24/outline';
import { removeItem } from 'components/cart/actions';
import type { CartItem } from 'lib/shopify/types';
import { useActionState } from 'react';
import { XMarkIcon } from "@heroicons/react/24/outline";
import { removeItem } from "components/cart/actions";
import { CartItem } from "lib/sfcc/types";
import { useActionState } from "react";
export function DeleteItemButton({
item,
optimisticUpdate
optimisticUpdate,
}: {
item: CartItem;
optimisticUpdate: any;
@ -19,7 +19,7 @@ export function DeleteItemButton({
return (
<form
action={async () => {
optimisticUpdate(merchandiseId, 'delete');
optimisticUpdate(merchandiseId, "delete");
removeItemAction();
}}
>

View File

@ -1,26 +1,26 @@
'use client';
"use client";
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { updateItemQuantity } from 'components/cart/actions';
import type { CartItem } from 'lib/shopify/types';
import { useActionState } from 'react';
import { MinusIcon, PlusIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import { updateItemQuantity } from "components/cart/actions";
import { CartItem } from "lib/sfcc/types";
import { useActionState } from "react";
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
function SubmitButton({ type }: { type: "plus" | "minus" }) {
return (
<button
type="submit"
aria-label={
type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'
type === "plus" ? "Increase item quantity" : "Reduce item quantity"
}
className={clsx(
'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full p-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80',
"ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full p-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80",
{
'ml-auto': type === 'minus'
"ml-auto": type === "minus",
}
)}
>
{type === 'plus' ? (
{type === "plus" ? (
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
) : (
<MinusIcon className="h-4 w-4 dark:text-neutral-500" />
@ -32,16 +32,16 @@ function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
export function EditItemQuantityButton({
item,
type,
optimisticUpdate
optimisticUpdate,
}: {
item: CartItem;
type: 'plus' | 'minus';
type: "plus" | "minus";
optimisticUpdate: any;
}) {
const [message, formAction] = useActionState(updateItemQuantity, null);
const payload = {
merchandiseId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
quantity: type === "plus" ? item.quantity + 1 : item.quantity - 1,
};
const updateItemQuantityAction = formAction.bind(null, payload);

View File

@ -1,6 +1,6 @@
import { GridTileImage } from 'components/grid/tile';
import { getCollectionProducts } from 'lib/shopify';
import type { Product } from 'lib/shopify/types';
import { getCollectionProducts } from 'lib/sfcc';
import { Product } from 'lib/sfcc/types';
import Link from 'next/link';
function ThreeItemGridItem({

View File

@ -1,7 +1,7 @@
'use client';
import clsx from 'clsx';
import { Menu } from 'lib/shopify/types';
import { Menu } from 'lib/sfcc/types';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';

View File

@ -2,7 +2,7 @@ import Link from 'next/link';
import FooterMenu from 'components/layout/footer-menu';
import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/shopify';
import { getMenu } from 'lib/sfcc/content';
import { Suspense } from 'react';
const { COMPANY_NAME, SITE_NAME } = process.env;

View File

@ -1,7 +1,7 @@
import CartModal from 'components/cart/modal';
import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/shopify';
import { Menu } from 'lib/shopify/types';
import { getMenu } from 'lib/sfcc/content';
import { Menu } from 'lib/sfcc/types';
import Link from 'next/link';
import { Suspense } from 'react';
import MobileMenu from './mobile-menu';

View File

@ -6,7 +6,7 @@ import { usePathname, useSearchParams } from 'next/navigation';
import { Fragment, Suspense, useEffect, useState } from 'react';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import { Menu } from 'lib/shopify/types';
import { Menu } from 'lib/sfcc/types';
import Search, { SearchSkeleton } from './search';
export default function MobileMenu({ menu }: { menu: Menu[] }) {

View File

@ -1,6 +1,6 @@
import Grid from 'components/grid';
import { GridTileImage } from 'components/grid/tile';
import { Product } from 'lib/shopify/types';
import { Product } from 'lib/sfcc/types';
import Link from 'next/link';
export default function ProductGridItems({ products }: { products: Product[] }) {

View File

@ -1,7 +1,7 @@
import clsx from 'clsx';
import { Suspense } from 'react';
import { getCollections } from 'lib/shopify';
import { getCollections } from 'lib/sfcc';
import FilterList from './filter';
async function CollectionList() {

View File

@ -1,4 +1,4 @@
import { SortFilterItem } from 'lib/constants';
import { SortFilterItem } from 'lib/sfcc/constants';
import { Suspense } from 'react';
import FilterItemDropdown from './dropdown';
import { FilterItem } from './item';

View File

@ -1,7 +1,7 @@
import { AddToCart } from 'components/cart/add-to-cart';
import Price from 'components/price';
import Prose from 'components/prose';
import { Product } from 'lib/shopify/types';
import { Product } from 'lib/sfcc/types';
import { VariantSelector } from './variant-selector';
export function ProductDescription({ product }: { product: Product }) {

View File

@ -2,7 +2,7 @@
import clsx from 'clsx';
import { useProduct, useUpdateURL } from 'components/product/product-context';
import { ProductOption, ProductVariant } from 'lib/shopify/types';
import { ProductOption, ProductVariant } from 'lib/sfcc/types';
type Combination = {
id: string;

View File

@ -16,7 +16,8 @@ export function WelcomeToast() {
},
description: (
<>
This is a high-performance, SSR storefront powered by Shopify, Next.js, and Vercel.{' '}
This is a high-performance, SSR storefront powered by Salesforce Commerce Cloud,
Next.js, and Vercel.{' '}
<a
href="https://vercel.com/templates/next.js/nextjs-commerce"
className="text-blue-600 hover:underline"

View File

@ -1,23 +1,52 @@
export const storeCatalog = {
ids: 'mens,womens,newarrivals,top-seller'
};
export type SortFilterItem = {
title: string;
slug: string | null;
sortKey: 'RELEVANCE' | 'BEST_SELLING' | 'CREATED_AT' | 'PRICE';
sortKey:
| 'best-matches'
| 'price-low-to-high'
| 'price-high-to-low'
| 'product-name-ascending'
| 'product-name-descending';
reverse: boolean;
};
export const defaultSort: SortFilterItem = {
title: 'Relevance',
slug: null,
sortKey: 'RELEVANCE',
title: 'Best Matches',
slug: 'best-matches',
sortKey: 'best-matches',
reverse: false
};
export const sorting: SortFilterItem[] = [
defaultSort,
{ title: 'Trending', slug: 'trending-desc', sortKey: 'BEST_SELLING', reverse: false }, // asc
{ title: 'Latest arrivals', slug: 'latest-desc', sortKey: 'CREATED_AT', reverse: true },
{ title: 'Price: Low to high', slug: 'price-asc', sortKey: 'PRICE', reverse: false }, // asc
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
{
title: 'Price Low to High',
slug: 'price-low-to-high',
sortKey: 'price-low-to-high',
reverse: false
},
{
title: 'Price High to Low',
slug: 'price-high-to-low',
sortKey: 'price-high-to-low',
reverse: false
},
{
title: 'Name A - Z',
slug: 'product-name-ascending',
sortKey: 'product-name-ascending',
reverse: false
},
{
title: 'Name Z - A',
slug: 'product-name-descending',
sortKey: 'product-name-descending',
reverse: false
}
];
export const TAGS = {
@ -28,4 +57,3 @@ export const TAGS = {
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
export const DEFAULT_OPTION = 'Default Title';
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';

50
lib/sfcc/constants.ts Normal file
View File

@ -0,0 +1,50 @@
export type SortFilterItem = {
title: string;
slug: string | null;
sortKey:
| 'best-matches'
| 'price-low-to-high'
| 'price-high-to-low'
| 'product-name-ascending'
| 'product-name-descending';
reverse: boolean;
};
export const storeCatalog = {
ids: 'mens,womens,newarrivals,top-seller'
};
export const defaultSort: SortFilterItem = {
title: 'Best Matches',
slug: 'best-matches',
sortKey: 'best-matches',
reverse: false
};
export const sorting: SortFilterItem[] = [
defaultSort,
{
title: 'Price Low to High',
slug: 'price-low-to-high',
sortKey: 'price-low-to-high',
reverse: false
},
{
title: 'Price High to Low',
slug: 'price-high-to-low',
sortKey: 'price-high-to-low',
reverse: false
},
{
title: 'Name A - Z',
slug: 'product-name-ascending',
sortKey: 'product-name-ascending',
reverse: false
},
{
title: 'Name Z - A',
slug: 'product-name-descending',
sortKey: 'product-name-descending',
reverse: false
}
];

205
lib/sfcc/content.ts Normal file
View File

@ -0,0 +1,205 @@
import { Menu, Page } from './types';
/**
* NOTE: This function returns a hardcoded menu structure for demonstration purposes.
* In a production application, the engineering team should update to retrieve menu content from
* a CMS or other data source that is appropriate for the project.
*/
export function getMenu(handle: string): Menu[] {
return getMenus().filter((menu) => 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: `<div className="prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline hover:prose-a:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white mb-8"><p>This website is built with <a href="https://nextjs.org/commerce" title="Next.js Commerce">Next.js Commerce</a>, which is a ecommerce template for creating a headless Salesforce Commerce Cloud storefront.</p>
<p>Support for real-world commerce features including:</p>
<ul>
<li>Out of stocks</li>
<li>Order history</li>
<li>Order status</li>
<li>Cross variant / option availability (aka. Amazon style)</li>
<li><a href="https://demo.vercel.store/product/acme-webcam-cover" title="Example of a hidden product in Next.js Commerce">Hidden products</a></li>
<li>Dynamically driven features via Salesforce Commerce Cloud (ie. collections, products, recommendations, etc.)</li>
</li>
<li>And more!</li>
</ul>
<p>This template also allows us to highlight newer Next.js features including:</p>
<ul>
<li>Next.js App Router</li>
<li>Optimized for SEO using Next.js's Metadata</li>
<li>React Server Components (RSCs) and Suspense</li>
<li>Server Actions&nbsp;for mutations</li>
<li>Edge runtime</li>
<li>New Next.js 13 fetching and caching paradigms</li>
<li>Dynamic OG images</li>
<li>Styling with Tailwind CSS</li>
<li>Automatic light/dark mode based on system settings</li>
<li>And more!</li>
</ul></div>`,
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: `<div className="prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline hover:prose-a:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white mb-8">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.</div>`,
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: `<div className="prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline hover:prose-a:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white mb-8">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.</div>`,
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: `<div className="prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline hover:prose-a:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white mb-8">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.</div>`,
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: `<div className="prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline hover:prose-a:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white mb-8">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.</div>`,
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'
};

693
lib/sfcc/index.ts Normal file
View File

@ -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<Cart | undefined> {
const cartId = (await cookies()).get("cartId")?.value!;
// get the guest token to get the correct guest cart
const guestToken = (await cookies()).get("guest_token")?.value;
const config = await getGuestUserConfig(guestToken);
if (!cartId) return;
try {
const basketClient = new Checkout.ShopperBaskets(config);
const basket = await basketClient.getBasket({
parameters: {
basketId: cartId,
organizationId: process.env.SFCC_ORGANIZATIONID,
siteId: process.env.SFCC_SITEID,
},
});
if (!basket?.basketId) return;
const cartItems = await getCartItems(basket);
return reshapeBasket(basket, cartItems);
} catch (e: any) {
console.log(await e.response.text());
return;
}
}
export async function addToCart(
lines: { merchandiseId: string; quantity: number }[]
) {
const cartId = (await cookies()).get("cartId")?.value!;
// get the guest token to get the correct guest cart
const guestToken = (await cookies()).get("guest_token")?.value;
const config = await getGuestUserConfig(guestToken);
try {
const basketClient = new Checkout.ShopperBaskets(config);
const basket = await basketClient.addItemToBasket({
parameters: {
basketId: cartId,
organizationId: process.env.SFCC_ORGANIZATIONID,
siteId: process.env.SFCC_SITEID,
},
body: lines.map((line) => {
return {
productId: line.merchandiseId,
quantity: line.quantity,
};
}),
});
if (!basket?.basketId) return;
const cartItems = await getCartItems(basket);
return reshapeBasket(basket, cartItems);
} catch (e: any) {
console.log(await e.response.text());
return;
}
}
export async function removeFromCart(lineIds: string[]) {
const cartId = (await cookies()).get("cartId")?.value!;
// Next Commerce only sends one lineId at a time
if (lineIds.length !== 1)
throw new Error("Invalid number of line items provided");
// get the guest token to get the correct guest cart
const guestToken = (await cookies()).get("guest_token")?.value;
const config = await getGuestUserConfig(guestToken);
const basketClient = new Checkout.ShopperBaskets(config);
const basket = await basketClient.removeItemFromBasket({
parameters: {
basketId: cartId,
itemId: lineIds[0]!,
},
});
const cartItems = await getCartItems(basket);
return reshapeBasket(basket, cartItems);
}
export async function updateCart(
lines: { id: string; merchandiseId: string; quantity: number }[]
) {
const cartId = (await cookies()).get("cartId")?.value!;
// get the guest token to get the correct guest cart
const guestToken = (await cookies()).get("guest_token")?.value;
const config = await getGuestUserConfig(guestToken);
const basketClient = new Checkout.ShopperBaskets(config);
// ProductItem quantity can not be updated through the API
// Quantity updates need to remove all items from the cart and add them back with updated quantities
// See: https://developer.salesforce.com/docs/commerce/commerce-api/references/shopper-baskets?meta=updateBasket
// create removePromises for each line
const removePromises = lines.map((line) =>
basketClient.removeItemFromBasket({
parameters: {
basketId: cartId,
itemId: line.id,
},
})
);
// wait for all removals to resolve
await Promise.all(removePromises);
// create addPromises for each line
const addPromises = lines.map((line) =>
basketClient.addItemToBasket({
parameters: {
basketId: cartId,
},
body: [
{
productId: line.merchandiseId,
quantity: line.quantity,
},
],
})
);
// wait for all additions to resolve
await Promise.all(addPromises);
// all updates are done, get the updated basket
const updatedBasket = await basketClient.getBasket({
parameters: {
basketId: cartId,
},
});
const cartItems = await getCartItems(updatedBasket);
return reshapeBasket(updatedBasket, cartItems);
}
export async function getProductRecommendations(productId: string) {
const ocProductRecommendations =
await getOCProductRecommendations<ProductRecommendations>(productId);
if (!ocProductRecommendations?.recommendations?.length) return [];
const clientConfig = await getGuestUserConfig();
const productsClient = new SalesforceProduct.ShopperProducts(clientConfig);
const recommendedProducts: SortedProductResult[] = [];
await Promise.all(
ocProductRecommendations.recommendations.map(
async (recommendation, index) => {
const productResult = await productsClient.getProduct({
parameters: {
organizationId: clientConfig.parameters.organizationId,
siteId: clientConfig.parameters.siteId,
id: recommendation.recommended_item_id,
},
});
recommendedProducts.push({ productResult, index });
}
)
);
const sortedResults = recommendedProducts
.sort((a: any, b: any) => a.index - b.index)
.map((item) => item.productResult);
return reshapeProducts(sortedResults);
}
export async function revalidate(req: NextRequest) {
const collectionWebhooks = [
"collections/create",
"collections/delete",
"collections/update",
];
const productWebhooks = [
"products/create",
"products/delete",
"products/update",
];
const topic = (await headers()).get("x-sfcc-topic") || "unknown";
const secret = req.nextUrl.searchParams.get("secret");
const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.SFCC_REVALIDATION_SECRET) {
console.error("Invalid revalidation secret.");
return NextResponse.json({ status: 200 });
}
if (!isCollectionUpdate && !isProductUpdate) {
// We don't need to revalidate anything for any other topics.
return NextResponse.json({ status: 200 });
}
if (isCollectionUpdate) {
revalidateTag(TAGS.collections);
}
if (isProductUpdate) {
revalidateTag(TAGS.products);
}
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
}
async function getGuestUserAuthToken() {
const base64data = Buffer.from(
`${process.env.SFCC_CLIENT_ID}:${process.env.SFCC_SECRET}`
).toString("base64");
const headers = { Authorization: `Basic ${base64data}` };
const client = new Customer.ShopperLogin(config);
return await client.getAccessToken({
headers,
body: {
grant_type: "client_credentials",
channel_id: process.env.SFCC_SITEID,
},
});
}
async function getGuestUserConfig(token?: string) {
const guestToken = token || (await getGuestUserAuthToken()).access_token;
if (!guestToken) {
throw new Error("Failed to retrieve access token");
}
return {
...config,
headers: {
authorization: `Bearer ${guestToken}`,
},
};
}
async function getSFCCCollections() {
const config = await getGuestUserConfig();
const productsClient = new SalesforceProduct.ShopperProducts(config);
const result = await productsClient.getCategories({
parameters: {
ids: storeCatalog.ids,
},
});
return reshapeCategories(result.data || []);
}
async function getSFCCProduct(id: string) {
const config = await getGuestUserConfig();
const productsClient = new SalesforceProduct.ShopperProducts(config);
const product = await productsClient.getProduct({
parameters: {
organizationId: config.parameters.organizationId,
siteId: config.parameters.siteId,
id,
},
});
return reshapeProduct(product);
}
async function searchProducts(options: {
query?: string;
categoryId?: string;
sortKey?: string;
}) {
const { query, categoryId, sortKey = defaultSort.sortKey } = options;
const config = await getGuestUserConfig();
const searchClient = new Search.ShopperSearch(config);
const searchResults = await searchClient.productSearch({
parameters: {
q: query || "",
refine: categoryId ? [`cgid=${categoryId}`] : [],
sort: sortKey,
limit: 100,
},
});
const results: SortedProductResult[] = [];
const productsClient = new SalesforceProduct.ShopperProducts(config);
await Promise.all(
searchResults.hits.map(
async (product: { productId: string }, index: number) => {
const productResult = await productsClient.getProduct({
parameters: {
organizationId: config.parameters.organizationId,
siteId: config.parameters.siteId,
id: product.productId,
},
});
results.push({ productResult, index });
}
)
);
const sortedResults = results
.sort((a: any, b: any) => a.index - b.index)
.map((item) => item.productResult);
return reshapeProducts(sortedResults);
}
async function getCartItems(createdBasket: ShopperBaskets.Basket) {
const cartItems: CartItem[] = [];
if (createdBasket.productItems) {
const productsInCart: Product[] = [];
// Fetch all matching products for items in the cart
await Promise.all(
createdBasket.productItems
.filter((l: ShopperBaskets.ProductItem) => l.productId)
.map(async (l: ShopperBaskets.ProductItem) => {
const product = await getProduct(l.productId!);
productsInCart.push(product);
})
);
// Reshape the sfcc items and push them onto the cartItems
createdBasket.productItems.map(
(productItem: ShopperBaskets.ProductItem) => {
cartItems.push(
reshapeProductItem(
productItem,
createdBasket.currency || "USD",
productsInCart.find((p) => p.id === productItem.productId)!
)
);
}
);
}
return cartItems;
}
function reshapeCategory(
category: SalesforceProduct.ShopperProducts.Category
): Collection | undefined {
if (!category) {
return undefined;
}
return {
handle: category.id,
title: category.name || "",
description: category.description || "",
seo: {
title: category.pageTitle || "",
description: category.description || "",
},
updatedAt: "",
path: `/search/${category.id}`,
};
}
function reshapeCategories(
categories: SalesforceProduct.ShopperProducts.Category[]
) {
const reshapedCategories = [];
for (const category of categories) {
if (category) {
const reshapedCategory = reshapeCategory(category);
if (reshapedCategory) {
reshapedCategories.push(reshapedCategory);
}
}
}
return reshapedCategories;
}
function reshapeProduct(product: SalesforceProduct.ShopperProducts.Product) {
if (!product.name) {
throw new Error("Product name is not set");
}
const images = reshapeImages(product.imageGroups);
if (!images[0]) {
throw new Error("Product image is not set");
}
const flattenedPrices =
product.variants
?.filter((variant) => variant.price !== undefined)
.reduce((acc: number[], variant) => [...acc, variant.price!], [])
.sort((a, b) => a - b) || [];
return {
id: product.id,
handle: product.id,
title: product.name,
description: product.shortDescription || "",
descriptionHtml: product.longDescription || "",
tags: product["c_product-tags"] || [],
featuredImage: images[0],
// TODO: check dates for whether it is available
availableForSale: true,
priceRange: {
maxVariantPrice: {
// TODO: verify whether there is another property for this
amount: flattenedPrices[flattenedPrices.length - 1]?.toString() || "0",
currencyCode: product.currency || "USD",
},
minVariantPrice: {
amount: flattenedPrices[0]?.toString() || "0",
currencyCode: product.currency || "USD",
},
},
images: images,
options:
product.variationAttributes?.map((attribute) => {
return {
id: attribute.id,
name: attribute.name!,
// TODO: might be a better way to do this, we are providing the name as the value
values:
attribute.values
?.filter((v) => v.value !== undefined)
?.map((v) => v.name!) || [],
};
}) || [],
seo: {
title: product.pageTitle || "",
description: product.pageDescription || "",
},
variants: reshapeVariants(product.variants || [], product),
updatedAt: product["c_updated-date"],
};
}
function reshapeProducts(
products: SalesforceProduct.ShopperProducts.Product[]
) {
const reshapedProducts = [];
for (const product of products) {
if (product) {
const reshapedProduct = reshapeProduct(product);
if (reshapedProduct) {
reshapedProducts.push(reshapedProduct);
}
}
}
return reshapedProducts;
}
function reshapeImages(
imageGroups: SalesforceProduct.ShopperProducts.ImageGroup[] | undefined
): Image[] {
if (!imageGroups) return [];
const largeGroup = imageGroups.filter((g) => g.viewType === "large");
const images = [...largeGroup].map((group) => group.images).flat();
return images.map((image) => {
return {
altText: image.alt!,
url: image.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,
};
}

34
lib/sfcc/ocapi.ts Normal file
View File

@ -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<T>(productId: string): Promise<T> {
const productRecommendationsEndpoint = `/products/${productId}/recommendations`;
const res = await ocFetch<T>({
method: 'GET',
endpoint: productRecommendationsEndpoint,
tags: [TAGS.products]
});
return res.body as T;
}
async function ocFetch<T>(options: {
method: 'POST' | 'GET';
endpoint: string;
cache?: RequestCache;
headers?: HeadersInit;
tags?: string[];
variables?: ExtractVariables<T>;
}): 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<T>({
...options,
apiEndpoint
});
}

55
lib/sfcc/scapi.ts Normal file
View File

@ -0,0 +1,55 @@
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

@ -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<string, unknown> => {
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;

146
lib/sfcc/types.ts Normal file
View File

@ -0,0 +1,146 @@
export type Connection<T> = {
edges: Array<Edge<T>>;
};
export type Edge<T> = {
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<SalesforceProduct, 'variants' | 'images'> & {
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<CartItem>;
totalQuantity: number;
};
export type Cart = Omit<SalesforceCart, 'lines'> & {
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;
};

89
lib/sfcc/utils.ts Normal file
View File

@ -0,0 +1,89 @@
import { isSFCCError } from './type-guards';
export type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
export async function salesforceFetch<T>({
method,
cache = 'force-cache',
headers,
tags,
variables,
apiEndpoint
}: {
method: 'POST' | 'GET';
apiEndpoint: string;
cache?: RequestCache;
headers?: HeadersInit;
tags?: string[];
variables?: ExtractVariables<T>;
}): 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`
);
}
};

View File

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

View File

@ -1,10 +0,0 @@
const imageFragment = /* GraphQL */ `
fragment image on Image {
url
altText
width
height
}
`;
export default imageFragment;

View File

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

View File

@ -1,8 +0,0 @@
const seoFragment = /* GraphQL */ `
fragment seo on SEO {
description
title
}
`;
export default seoFragment;

View File

@ -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> = T extends { variables: object }
? T['variables']
: never;
export async function shopifyFetch<T>({
headers,
query,
variables
}: {
headers?: HeadersInit;
query: string;
variables?: ExtractVariables<T>;
}): 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 = <T>(array: Connection<T>): 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<Image>, 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<Cart> {
const res = await shopifyFetch<ShopifyCreateCartOperation>({
query: createCartMutation
});
return reshapeCart(res.body.data.cartCreate.cart);
}
export async function addToCart(
lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const cartId = (await cookies()).get('cartId')?.value!;
const res = await shopifyFetch<ShopifyAddToCartOperation>({
query: addToCartMutation,
variables: {
cartId,
lines
}
});
return reshapeCart(res.body.data.cartLinesAdd.cart);
}
export async function removeFromCart(lineIds: string[]): Promise<Cart> {
const cartId = (await cookies()).get('cartId')?.value!;
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
query: removeFromCartMutation,
variables: {
cartId,
lineIds
}
});
return reshapeCart(res.body.data.cartLinesRemove.cart);
}
export async function updateCart(
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const cartId = (await cookies()).get('cartId')?.value!;
const res = await shopifyFetch<ShopifyUpdateCartOperation>({
query: editCartItemsMutation,
variables: {
cartId,
lines
}
});
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
export async function getCart(): Promise<Cart | undefined> {
const cartId = (await cookies()).get('cartId')?.value;
if (!cartId) {
return undefined;
}
const res = await shopifyFetch<ShopifyCartOperation>({
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<Collection | undefined> {
'use cache';
cacheTag(TAGS.collections);
cacheLife('days');
const res = await shopifyFetch<ShopifyCollectionOperation>({
query: getCollectionQuery,
variables: {
handle
}
});
return reshapeCollection(res.body.data.collection);
}
export async function getCollectionProducts({
collection,
reverse,
sortKey
}: {
collection: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
'use cache';
cacheTag(TAGS.collections, TAGS.products);
cacheLife('days');
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
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<Collection[]> {
'use cache';
cacheTag(TAGS.collections);
cacheLife('days');
const res = await shopifyFetch<ShopifyCollectionsOperation>({
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<Menu[]> {
'use cache';
cacheTag(TAGS.collections);
cacheLife('days');
const res = await shopifyFetch<ShopifyMenuOperation>({
query: getMenuQuery,
variables: {
handle
}
});
return (
res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({
title: item.title,
path: item.url
.replace(domain, '')
.replace('/collections', '/search')
.replace('/pages', '')
})) || []
);
}
export async function getPage(handle: string): Promise<Page> {
const res = await shopifyFetch<ShopifyPageOperation>({
query: getPageQuery,
variables: { handle }
});
return res.body.data.pageByHandle;
}
export async function getPages(): Promise<Page[]> {
const res = await shopifyFetch<ShopifyPagesOperation>({
query: getPagesQuery
});
return removeEdgesAndNodes(res.body.data.pages);
}
export async function getProduct(handle: string): Promise<Product | undefined> {
'use cache';
cacheTag(TAGS.products);
cacheLife('days');
const res = await shopifyFetch<ShopifyProductOperation>({
query: getProductQuery,
variables: {
handle
}
});
return reshapeProduct(res.body.data.product, false);
}
export async function getProductRecommendations(
productId: string
): Promise<Product[]> {
'use cache';
cacheTag(TAGS.products);
cacheLife('days');
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
query: getProductRecommendationsQuery,
variables: {
productId
}
});
return reshapeProducts(res.body.data.productRecommendations);
}
export async function getProducts({
query,
reverse,
sortKey
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
'use cache';
cacheTag(TAGS.products);
cacheLife('days');
const res = await shopifyFetch<ShopifyProductsOperation>({
query: getProductsQuery,
variables: {
query,
reverse,
sortKey
}
});
return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
}
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
export async function revalidate(req: NextRequest): Promise<NextResponse> {
// We always need to respond with a 200 status code to Shopify,
// otherwise it will continue to retry the request.
const collectionWebhooks = [
'collections/create',
'collections/delete',
'collections/update'
];
const productWebhooks = [
'products/create',
'products/delete',
'products/update'
];
const topic = (await headers()).get('x-shopify-topic') || 'unknown';
const secret = req.nextUrl.searchParams.get('secret');
const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
console.error('Invalid revalidation secret.');
return NextResponse.json({ status: 401 });
}
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() });
}

View File

@ -1,45 +0,0 @@
import cartFragment from '../fragments/cart';
export const addToCartMutation = /* GraphQL */ `
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const createCartMutation = /* GraphQL */ `
mutation createCart($lineItems: [CartLineInput!]) {
cartCreate(input: { lines: $lineItems }) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const editCartItemsMutation = /* GraphQL */ `
mutation editCartItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const removeFromCartMutation = /* GraphQL */ `
mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
...cart
}
}
}
${cartFragment}
`;

View File

@ -1,10 +0,0 @@
import cartFragment from '../fragments/cart';
export const getCartQuery = /* GraphQL */ `
query getCart($cartId: ID!) {
cart(id: $cartId) {
...cart
}
}
${cartFragment}
`;

View File

@ -1,56 +0,0 @@
import productFragment from '../fragments/product';
import seoFragment from '../fragments/seo';
const collectionFragment = /* GraphQL */ `
fragment collection on Collection {
handle
title
description
seo {
...seo
}
updatedAt
}
${seoFragment}
`;
export const getCollectionQuery = /* GraphQL */ `
query getCollection($handle: String!) {
collection(handle: $handle) {
...collection
}
}
${collectionFragment}
`;
export const getCollectionsQuery = /* GraphQL */ `
query getCollections {
collections(first: 100, sortKey: TITLE) {
edges {
node {
...collection
}
}
}
}
${collectionFragment}
`;
export const getCollectionProductsQuery = /* GraphQL */ `
query getCollectionProducts(
$handle: String!
$sortKey: ProductCollectionSortKeys
$reverse: Boolean
) {
collection(handle: $handle) {
products(sortKey: $sortKey, reverse: $reverse, first: 100) {
edges {
node {
...product
}
}
}
}
}
${productFragment}
`;

View File

@ -1,10 +0,0 @@
export const getMenuQuery = /* GraphQL */ `
query getMenu($handle: String!) {
menu(handle: $handle) {
items {
title
url
}
}
}
`;

View File

@ -1,41 +0,0 @@
import seoFragment from '../fragments/seo';
const pageFragment = /* GraphQL */ `
fragment page on Page {
... on Page {
id
title
handle
body
bodySummary
seo {
...seo
}
createdAt
updatedAt
}
}
${seoFragment}
`;
export const getPageQuery = /* GraphQL */ `
query getPage($handle: String!) {
pageByHandle(handle: $handle) {
...page
}
}
${pageFragment}
`;
export const getPagesQuery = /* GraphQL */ `
query getPages {
pages(first: 100) {
edges {
node {
...page
}
}
}
}
${pageFragment}
`;

View File

@ -1,32 +0,0 @@
import productFragment from '../fragments/product';
export const getProductQuery = /* GraphQL */ `
query getProduct($handle: String!) {
product(handle: $handle) {
...product
}
}
${productFragment}
`;
export const getProductsQuery = /* GraphQL */ `
query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) {
products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
edges {
node {
...product
}
}
}
}
${productFragment}
`;
export const getProductRecommendationsQuery = /* GraphQL */ `
query getProductRecommendations($productId: ID!) {
productRecommendations(productId: $productId) {
...product
}
}
${productFragment}
`;

View File

@ -1,272 +0,0 @@
export type Maybe<T> = T | null;
export type Connection<T> = {
edges: Array<Edge<T>>;
};
export type Edge<T> = {
node: T;
};
export type Cart = Omit<ShopifyCart, 'lines'> & {
lines: CartItem[];
};
export type CartProduct = {
id: string;
handle: string;
title: string;
featuredImage: Image;
};
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 Collection = ShopifyCollection & {
path: string;
};
export type Image = {
url: string;
altText: string;
width: number;
height: number;
};
export type Menu = {
title: string;
path: string;
};
export type Money = {
amount: string;
currencyCode: string;
};
export type Page = {
id: string;
title: string;
handle: string;
body: string;
bodySummary: string;
seo?: SEO;
createdAt: string;
updatedAt: string;
};
export type Product = Omit<ShopifyProduct, 'variants' | 'images'> & {
variants: ProductVariant[];
images: Image[];
};
export type ProductOption = {
id: string;
name: string;
values: string[];
};
export type ProductVariant = {
id: string;
title: string;
availableForSale: boolean;
selectedOptions: {
name: string;
value: string;
}[];
price: Money;
};
export type SEO = {
title: string;
description: string;
};
export type ShopifyCart = {
id: string | undefined;
checkoutUrl: string;
cost: {
subtotalAmount: Money;
totalAmount: Money;
totalTaxAmount: Money;
};
lines: Connection<CartItem>;
totalQuantity: number;
};
export type ShopifyCollection = {
handle: string;
title: string;
description: string;
seo: SEO;
updatedAt: string;
};
export type ShopifyProduct = {
id: string;
handle: string;
availableForSale: boolean;
title: string;
description: string;
descriptionHtml: string;
options: ProductOption[];
priceRange: {
maxVariantPrice: Money;
minVariantPrice: Money;
};
variants: Connection<ProductVariant>;
featuredImage: Image;
images: Connection<Image>;
seo: SEO;
tags: string[];
updatedAt: string;
};
export type ShopifyCartOperation = {
data: {
cart: ShopifyCart;
};
variables: {
cartId: string;
};
};
export type ShopifyCreateCartOperation = {
data: { cartCreate: { cart: ShopifyCart } };
};
export type ShopifyAddToCartOperation = {
data: {
cartLinesAdd: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyRemoveFromCartOperation = {
data: {
cartLinesRemove: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lineIds: string[];
};
};
export type ShopifyUpdateCartOperation = {
data: {
cartLinesUpdate: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
id: string;
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyCollectionOperation = {
data: {
collection: ShopifyCollection;
};
variables: {
handle: string;
};
};
export type ShopifyCollectionProductsOperation = {
data: {
collection: {
products: Connection<ShopifyProduct>;
};
};
variables: {
handle: string;
reverse?: boolean;
sortKey?: string;
};
};
export type ShopifyCollectionsOperation = {
data: {
collections: Connection<ShopifyCollection>;
};
};
export type ShopifyMenuOperation = {
data: {
menu?: {
items: {
title: string;
url: string;
}[];
};
};
variables: {
handle: string;
};
};
export type ShopifyPageOperation = {
data: { pageByHandle: Page };
variables: { handle: string };
};
export type ShopifyPagesOperation = {
data: {
pages: Connection<Page>;
};
};
export type ShopifyProductOperation = {
data: { product: ShopifyProduct };
variables: {
handle: string;
};
};
export type ShopifyProductRecommendationsOperation = {
data: {
productRecommendations: ShopifyProduct[];
};
variables: {
productId: string;
};
};
export type ShopifyProductsOperation = {
data: {
products: Connection<ShopifyProduct>;
};
variables: {
query?: string;
reverse?: boolean;
sortKey?: string;
};
};

View File

@ -1,15 +1,15 @@
import { ReadonlyURLSearchParams } from 'next/navigation';
import { ReadonlyURLSearchParams } from "next/navigation";
export const baseUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: 'http://localhost:3000';
: "http://localhost:3000";
export const createUrl = (
pathname: string,
params: URLSearchParams | ReadonlyURLSearchParams
) => {
const paramsString = params.toString();
const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;
const queryString = `${paramsString.length ? "?" : ""}${paramsString}`;
return `${pathname}${queryString}`;
};
@ -18,34 +18,3 @@ export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
stringToCheck.startsWith(startsWith)
? stringToCheck
: `${startsWith}${stringToCheck}`;
export const validateEnvironmentVariables = () => {
const requiredEnvironmentVariables = [
'SHOPIFY_STORE_DOMAIN',
'SHOPIFY_STOREFRONT_ACCESS_TOKEN'
];
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/shopify#configure-environment-variables\n\n${missingEnvironmentVariables.join(
'\n'
)}\n`
);
}
if (
process.env.SHOPIFY_STORE_DOMAIN?.includes('[') ||
process.env.SHOPIFY_STORE_DOMAIN?.includes(']')
) {
throw new Error(
'Your `SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.'
);
}
};

View File

@ -4,16 +4,15 @@ export default {
inlineCss: true,
useCache: true,
reactOwnerStack: true,
newDevOverlay: true
newDevOverlay: true,
},
images: {
formats: ['image/avif', 'image/webp'],
formats: ["image/avif", "image/webp"],
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.shopify.com',
pathname: '/s/files/**'
}
]
}
protocol: "https",
hostname: "zylq-002.dx.commercecloud.salesforce.com",
},
],
},
};

View File

@ -12,6 +12,7 @@
"@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",

2251
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff