diff --git a/.env.example b/.env.example index f7fa990d4..a4706d45c 100644 --- a/.env.example +++ b/.env.example @@ -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="" diff --git a/README.md b/README.md index 5052aec5b..e306847da 100644 --- a/README.md +++ b/README.md @@ -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. - -## 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. diff --git a/app/[page]/opengraph-image.tsx b/app/[page]/opengraph-image.tsx index 031e73fc8..a99134b89 100644 --- a/app/[page]/opengraph-image.tsx +++ b/app/[page]/opengraph-image.tsx @@ -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 }); diff --git a/app/[page]/page.tsx b/app/[page]/page.tsx index aa0c15603..0f12ed8b6 100644 --- a/app/[page]/page.tsx +++ b/app/[page]/page.tsx @@ -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 }> })

{page.title}

- {`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))}.`}

); diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts index 4ecc0b45d..d744ff093 100644 --- a/app/api/revalidate/route.ts +++ b/app/api/revalidate/route.ts @@ -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 { diff --git a/app/layout.tsx b/app/layout.tsx index 5e3355ce9..2819024d0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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; }) { diff --git a/app/page.tsx b/app/page.tsx index 7c4a7d74f..9d4f27df8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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() { diff --git a/app/product/[handle]/page.tsx b/app/product/[handle]/page.tsx index 33de3c0ba..89941f992 100644 --- a/app/product/[handle]/page.tsx +++ b/app/product/[handle]/page.tsx @@ -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'; diff --git a/app/search/[collection]/opengraph-image.tsx b/app/search/[collection]/opengraph-image.tsx index 2a7fbc386..f56d9a0bc 100644 --- a/app/search/[collection]/opengraph-image.tsx +++ b/app/search/[collection]/opengraph-image.tsx @@ -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 diff --git a/app/search/[collection]/page.tsx b/app/search/[collection]/page.tsx index dfb07e3c0..bb22dfbd4 100644 --- a/app/search/[collection]/page.tsx +++ b/app/search/[collection]/page.tsx @@ -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 }>; diff --git a/app/search/page.tsx b/app/search/page.tsx index dce5f0556..32afb4045 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -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', diff --git a/app/sitemap.ts b/app/sitemap.ts index df3eb4736..2f2921774 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -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 { - 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); } diff --git a/components/carousel.tsx b/components/carousel.tsx index 751cf4b48..fc4893034 100644 --- a/components/carousel.tsx +++ b/components/carousel.tsx @@ -1,4 +1,4 @@ -import { getCollectionProducts } from 'lib/shopify'; +import { getCollectionProducts } from 'lib/sfcc'; import Link from 'next/link'; import { GridTileImage } from './grid/tile'; diff --git a/components/cart/actions.ts b/components/cart/actions.ts index 361daf64d..5114f875b 100644 --- a/components/cart/actions.ts +++ b/components/cart/actions.ts @@ -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!); } diff --git a/components/cart/add-to-cart.tsx b/components/cart/add-to-cart.tsx index 85e13073d..8eb38eb18 100644 --- a/components/cart/add-to-cart.tsx +++ b/components/cart/add-to-cart.tsx @@ -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({