Squashed commit of the following:

commit 408d6eb7583470eb84fd0e85895f97dad864b981
Author: Alex <alex.hawley@vercel.com>
Date:   Wed Sep 4 21:28:45 2024 -0500

    added content

commit af62089872de543c8f741c3092f431a8b790feec
Author: Alex <alex.hawley@vercel.com>
Date:   Wed Sep 4 20:43:02 2024 -0500

    fixed product recommendations

commit 5c921be7b1eab4ea3b4acc922d2bde842bb0c5c8
Author: Alex <alex.hawley@vercel.com>
Date:   Wed Sep 4 20:33:28 2024 -0500

    fixed cart total

commit 63e150e822ab0b4f7690221ee5d1eafaaf5f930a
Author: Alex <alex.hawley@vercel.com>
Date:   Wed Sep 4 20:14:47 2024 -0500

    fixed update cart

commit 85bd6bee403e19c7b3f66c0d6e938a8432cee62b
Author: Alex <alex.hawley@vercel.com>
Date:   Wed Sep 4 19:00:42 2024 -0500

    remove unnecessary cookie usage from sfcc calls

commit 2401bed81143508993fdd403d9d5a419ac8904e5
Author: Alex <alex.hawley@vercel.com>
Date:   Wed Sep 4 18:55:39 2024 -0500

    fixed issue with broken getCart

commit f8cc8c3c3c1c64d7cf4b69a60ed87497ad626e65
Author: Alex <alex.hawley@vercel.com>
Date:   Wed Sep 4 18:23:03 2024 -0500

    updated lib/sfcc for guest tokens

commit bd6129e3ca15125c87c8186e9ff27d835fb2f683
Author: Alex <alex.hawley@vercel.com>
Date:   Wed Sep 4 15:19:40 2024 -0500

    added now required channel_id

commit eeb805fd11219d8512c1cadefe047019d63d4b60
Author: Alex <alex.hawley@vercel.com>
Date:   Tue Sep 3 17:43:27 2024 -0500

    split out scapi

commit e4f3bb1c827137245367152c1ff0401db76e7082
Author: Alex <alex.hawley@vercel.com>
Date:   Tue Sep 3 16:55:11 2024 -0500

    carried over sfcc work

commit 2616869f56f330f44ad3dfff9ad488eaaf1dbe51
Author: Alex <alex.hawley@vercel.com>
Date:   Thu Aug 22 15:03:30 2024 -0400

    initial sfcc work
This commit is contained in:
Alex 2024-09-04 21:47:12 -05:00
parent 694c5c17ba
commit 83856a4941
53 changed files with 2236 additions and 1147 deletions

View File

@ -1,7 +1,10 @@
COMPANY_NAME="Vercel Inc."
TWITTER_CREATOR="@vercel"
TWITTER_SITE="https://nextjs.org/commerce"
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,10 +1,13 @@
import OpengraphImage from 'components/opengraph-image';
import { getPage } from 'lib/shopify';
import { getPage } from 'lib/sfcc/content';
export const runtime = 'edge';
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,7 +1,7 @@
import type { Metadata } from 'next';
import Prose from 'components/prose';
import { getPage } from 'lib/shopify';
import { getPage } from 'lib/sfcc/content';
import { notFound } from 'next/navigation';
export async function generateMetadata({
@ -9,7 +9,7 @@ export async function generateMetadata({
}: {
params: { page: string };
}): Promise<Metadata> {
const page = await getPage(params.page);
const page = getPage(params.page);
if (!page) return notFound();
@ -24,8 +24,8 @@ export async function generateMetadata({
};
}
export default async function Page({ params }: { params: { page: string } }) {
const page = await getPage(params.page);
export default function Page({ params }: { params: { page: string } }) {
const page = getPage(params.page);
if (!page) return notFound();

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

@ -2,7 +2,7 @@ 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 { getCart } from 'lib/sfcc';
import { ensureStartsWith } from 'lib/utils';
import { cookies } from 'next/headers';
import { ReactNode } from 'react';

View File

@ -3,7 +3,8 @@ 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.',
description:
'High-performance ecommerce store built with Next.js, Vercel, and Salesforce Commerce Cloud.',
openGraph: {
type: 'website'
}

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 const runtime = 'edge';

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({
params

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,5 +1,6 @@
import { getCollections, getPages, getProducts } from 'lib/shopify';
import { validateEnvironmentVariables } from 'lib/utils';
import { getCollections, getProducts } from 'lib/sfcc';
import { getPages } from 'lib/sfcc/content';
import { validateEnvironmentVariables } from 'lib/sfcc/utils';
import { MetadataRoute } from 'next';
type Route = {

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,7 +1,7 @@
'use server';
import { TAGS } from 'lib/constants';
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/sfcc';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
@ -114,5 +114,8 @@ export async function redirectToCheckout() {
export async function createCartAndSetCookie() {
let cart = await createCart();
cookies().set('cartId', cart.id!);
// set the cartId to the same duration as the guest
cookies().set('cartId', cart.id!, {
maxAge: 60 * 30
});
}

View File

@ -4,7 +4,7 @@ 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 { Product, ProductVariant } from 'lib/sfcc/types';
import { useFormState } from 'react-dom';
import { useCart } from './cart-context';
@ -27,7 +27,6 @@ function SubmitButton({
);
}
console.log(selectedVariantId);
if (!selectedVariantId) {
return (
<button

View File

@ -1,6 +1,6 @@
'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';
type UpdateType = 'plus' | 'minus' | 'delete';

View File

@ -2,7 +2,7 @@
import { XMarkIcon } from '@heroicons/react/24/outline';
import { removeItem } from 'components/cart/actions';
import type { CartItem } from 'lib/shopify/types';
import { CartItem } from 'lib/sfcc/types';
import { useFormState } from 'react-dom';
export function DeleteItemButton({

View File

@ -3,7 +3,7 @@
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 { CartItem } from 'lib/sfcc/types';
import { useFormState } from 'react-dom';
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {

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

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

@ -0,0 +1,631 @@
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 = 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;
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(cartId: string | undefined): Promise<Cart | undefined> {
// get the guest token to get the correct guest cart
const guestToken = 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(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
) {
// get the guest token to get the correct guest cart
const guestToken = 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(cartId: string, lineIds: string[]) {
// 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 = 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(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
) {
// get the guest token to get the correct guest cart
const guestToken = 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 = 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,455 +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 } from 'next/cache';
import { 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>({
cache = 'force-cache',
headers,
query,
tags,
variables
}: {
cache?: RequestCache;
headers?: HeadersInit;
query: string;
tags?: 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 })
}),
cache,
...(tags && { next: { tags } })
});
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: 'USD'
};
}
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,
cache: 'no-store'
});
return reshapeCart(res.body.data.cartCreate.cart);
}
export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyAddToCartOperation>({
query: addToCartMutation,
variables: {
cartId,
lines
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesAdd.cart);
}
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
query: removeFromCartMutation,
variables: {
cartId,
lineIds
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesRemove.cart);
}
export async function updateCart(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyUpdateCartOperation>({
query: editCartItemsMutation,
variables: {
cartId,
lines
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
export async function getCart(cartId: string | undefined): Promise<Cart | undefined> {
if (!cartId) {
return undefined;
}
const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery,
variables: { cartId },
tags: [TAGS.cart]
});
// 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> {
const res = await shopifyFetch<ShopifyCollectionOperation>({
query: getCollectionQuery,
tags: [TAGS.collections],
variables: {
handle
}
});
return reshapeCollection(res.body.data.collection);
}
export async function getCollectionProducts({
collection,
reverse,
sortKey
}: {
collection: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
query: getCollectionProductsQuery,
tags: [TAGS.collections, TAGS.products],
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[]> {
const res = await shopifyFetch<ShopifyCollectionsOperation>({
query: getCollectionsQuery,
tags: [TAGS.collections]
});
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[]> {
const res = await shopifyFetch<ShopifyMenuOperation>({
query: getMenuQuery,
tags: [TAGS.collections],
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,
cache: 'no-store',
variables: { handle }
});
return res.body.data.pageByHandle;
}
export async function getPages(): Promise<Page[]> {
const res = await shopifyFetch<ShopifyPagesOperation>({
query: getPagesQuery,
cache: 'no-store'
});
return removeEdgesAndNodes(res.body.data.pages);
}
export async function getProduct(handle: string): Promise<Product | undefined> {
const res = await shopifyFetch<ShopifyProductOperation>({
query: getProductQuery,
tags: [TAGS.products],
variables: {
handle
}
});
return reshapeProduct(res.body.data.product, false);
}
export async function getProductRecommendations(productId: string): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
query: getProductRecommendationsQuery,
tags: [TAGS.products],
variables: {
productId
}
});
return reshapeProducts(res.body.data.productRecommendations);
}
export async function getProducts({
query,
reverse,
sortKey
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductsOperation>({
query: getProductsQuery,
tags: [TAGS.products],
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 = 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: 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() });
}

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

@ -9,31 +9,3 @@ export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyUR
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

@ -7,6 +7,10 @@ module.exports = {
protocol: 'https',
hostname: 'cdn.shopify.com',
pathname: '/s/files/**'
},
{
protocol: 'https',
hostname: 'zylq-002.dx.commercecloud.salesforce.com',
}
]
}

View File

@ -5,7 +5,7 @@
"pnpm": ">=9"
},
"scripts": {
"dev": "next dev --turbo",
"dev": "next dev",
"build": "next build",
"start": "next start",
"prettier": "prettier --write --ignore-unknown .",
@ -16,6 +16,7 @@
"@headlessui/react": "^2.1.2",
"@heroicons/react": "^2.1.5",
"clsx": "^2.1.1",
"commerce-sdk": "^4.0.0",
"geist": "^1.3.1",
"next": "15.0.0-canary.113",
"react": "19.0.0-rc-3208e73e-20240730",

919
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff