mirror of
https://github.com/vercel/commerce.git
synced 2025-04-27 13:27:50 +00:00
merge in latest next commerce changes
This commit is contained in:
commit
7297c2e6ea
@ -1,3 +1,5 @@
|
||||
COMPANY_NAME="Vercel Inc."
|
||||
SITE_NAME="Next.js Commerce"
|
||||
NEXT_PUBLIC_VERCEL_URL="http://localhost:3000"
|
||||
SFCC_CLIENT_ID=""
|
||||
SFCC_ORGANIZATIONID="f_ecom_0000_000"
|
||||
@ -7,4 +9,4 @@ 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=""
|
||||
SFCC_REVALIDATION_SECRET=""
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -36,3 +36,4 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env*.local
|
||||
|
28
README.md
28
README.md
@ -1,4 +1,4 @@
|
||||
[](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,TWITTER_CREATOR,TWITTER_SITE)
|
||||
[](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
|
||||
|
||||
@ -12,11 +12,10 @@ 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/))
|
||||
- [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
|
||||
@ -28,22 +27,11 @@ Vercel is happy to partner and work with any commerce provider to help them get
|
||||
|
||||
> 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`
|
||||
@ -61,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.
|
||||
|
@ -1,8 +1,6 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
import { getPage } from 'lib/sfcc/content';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image({ params }: { params: { page: string } }) {
|
||||
const page = getPage(params.page);
|
||||
|
||||
|
@ -1,15 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import Prose from 'components/prose';
|
||||
import { getPage } from 'lib/sfcc/content';
|
||||
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({
|
||||
params
|
||||
}: {
|
||||
params: { page: string };
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ page: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const page = getPage(params.page);
|
||||
const params = await props.params;
|
||||
const page = await getPage(params.page);
|
||||
|
||||
if (!page) return notFound();
|
||||
|
||||
@ -19,26 +18,32 @@ export async function generateMetadata({
|
||||
openGraph: {
|
||||
publishedTime: page.createdAt,
|
||||
modifiedTime: page.updatedAt,
|
||||
type: 'article'
|
||||
}
|
||||
type: "article",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page({ params }: { params: { page: string } }) {
|
||||
const page = getPage(params.page);
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ page: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = await getPage(params.page);
|
||||
|
||||
if (!page) return notFound();
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
|
||||
<Prose className="mb-8" html={page.body as string} />
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
@ -1,6 +1,17 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
|
||||
@plugin "@tailwindcss/container-queries";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
@ -17,5 +28,5 @@
|
||||
a,
|
||||
input,
|
||||
button {
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900;
|
||||
@apply focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900;
|
||||
}
|
||||
|
@ -1,45 +1,34 @@
|
||||
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 { ensureStartsWith } from 'lib/utils';
|
||||
import { cookies } from 'next/headers';
|
||||
import { ReactNode } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import './globals.css';
|
||||
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 { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
|
||||
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: 'http://localhost:3000';
|
||||
const twitterCreator = TWITTER_CREATOR ? ensureStartsWith(TWITTER_CREATOR, '@') : undefined;
|
||||
const twitterSite = TWITTER_SITE ? ensureStartsWith(TWITTER_SITE, 'https://') : undefined;
|
||||
const { SITE_NAME } = process.env;
|
||||
|
||||
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,
|
||||
},
|
||||
...(twitterCreator &&
|
||||
twitterSite && {
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
creator: twitterCreator,
|
||||
site: twitterSite
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// Don't await the fetch, pass the Promise to the context provider
|
||||
const cart = getCart(cartId);
|
||||
const cart = getCart();
|
||||
|
||||
return (
|
||||
<html lang="en" className={GeistSans.variable}>
|
||||
|
@ -1,7 +1,5 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image() {
|
||||
return await OpengraphImage();
|
||||
}
|
||||
|
12
app/page.tsx
12
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 Salesforce Commerce Cloud.',
|
||||
"High-performance ecommerce store built with Next.js, Vercel, and Salesforce Commerce Cloud.",
|
||||
openGraph: {
|
||||
type: 'website'
|
||||
}
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
|
@ -12,11 +12,10 @@ import { Image } from 'lib/sfcc/types';
|
||||
import Link from 'next/link';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { handle: string };
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ handle: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const product = await getProduct(params.handle);
|
||||
|
||||
if (!product) return notFound();
|
||||
@ -50,7 +49,8 @@ export async function generateMetadata({
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductPage({ params }: { params: { handle: string } }) {
|
||||
export default async function ProductPage(props: { params: Promise<{ handle: string }> }) {
|
||||
const params = await props.params;
|
||||
const product = await getProduct(params.handle);
|
||||
|
||||
if (!product) return notFound();
|
||||
@ -80,7 +80,7 @@ export default async function ProductPage({ params }: { params: { handle: string
|
||||
__html: JSON.stringify(productJsonLd)
|
||||
}}
|
||||
/>
|
||||
<div className="mx-auto max-w-screen-2xl px-4">
|
||||
<div className="mx-auto max-w-(--breakpoint-2xl) px-4">
|
||||
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black">
|
||||
<div className="h-full w-full basis-full lg:basis-4/6">
|
||||
<Suspense
|
||||
|
@ -1,6 +1,4 @@
|
||||
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: 'http://localhost:3000';
|
||||
import { baseUrl } from 'lib/utils';
|
||||
|
||||
export default function robots() {
|
||||
return {
|
||||
|
@ -1,9 +1,11 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
import { fetchCollection as getCollection } from 'lib/sfcc/scapi';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image({ params }: { params: { collection: string } }) {
|
||||
export default async function Image({
|
||||
params
|
||||
}: {
|
||||
params: { collection: string };
|
||||
}) {
|
||||
const collection = await getCollection(params.collection);
|
||||
const title = collection?.seo?.title || collection?.title;
|
||||
|
||||
|
@ -6,11 +6,10 @@ import ProductGridItems from 'components/layout/product-grid-items';
|
||||
import { getCollection, getCollectionProducts } from 'lib/sfcc';
|
||||
import { defaultSort, sorting } from 'lib/sfcc/constants';
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { collection: string };
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ collection: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const collection = await getCollection(params.collection);
|
||||
|
||||
if (!collection) return notFound();
|
||||
@ -22,13 +21,12 @@ export async function generateMetadata({
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CategoryPage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: { collection: string };
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
export default async function CategoryPage(props: {
|
||||
params: Promise<{ collection: string }>;
|
||||
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const { sort } = searchParams as { [key: string]: string };
|
||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });
|
||||
|
@ -3,16 +3,23 @@ import Collections from 'components/layout/search/collections';
|
||||
import FilterList from 'components/layout/search/filter';
|
||||
import { sorting } from 'lib/constants';
|
||||
import ChildrenWrapper from './children-wrapper';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export default function SearchLayout({ children }: { children: React.ReactNode }) {
|
||||
export default function SearchLayout({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black md:flex-row dark:text-white">
|
||||
<div className="mx-auto flex max-w-(--breakpoint-2xl) flex-col gap-8 px-4 pb-4 text-black md:flex-row dark:text-white">
|
||||
<div className="order-first w-full flex-none md:max-w-[125px]">
|
||||
<Collections />
|
||||
</div>
|
||||
<div className="order-last min-h-screen w-full md:order-none">
|
||||
<ChildrenWrapper>{children}</ChildrenWrapper>
|
||||
<Suspense fallback={null}>
|
||||
<ChildrenWrapper>{children}</ChildrenWrapper>
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="order-none flex-none md:order-last md:w-[125px]">
|
||||
<FilterList list={sorting} title="Sort by" />
|
||||
|
@ -8,11 +8,10 @@ export const metadata = {
|
||||
description: 'Search for products in the store.'
|
||||
};
|
||||
|
||||
export default async function SearchPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
export default async function SearchPage(props: {
|
||||
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const { sort, q: searchValue } = searchParams as { [key: string]: string };
|
||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
|
||||
|
@ -1,44 +1,38 @@
|
||||
import { getCollections, getProducts } from 'lib/sfcc';
|
||||
import { getPages } from 'lib/sfcc/content';
|
||||
import { validateEnvironmentVariables } from 'lib/sfcc/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;
|
||||
};
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: 'http://localhost:3000';
|
||||
|
||||
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 pages = getPages().map((page) => ({
|
||||
url: `${baseUrl}/${page.handle}`,
|
||||
lastModified: page.updatedAt
|
||||
lastModified: page.updatedAt,
|
||||
}));
|
||||
|
||||
let fetchedRoutes: Route[] = [];
|
||||
@ -46,7 +40,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
try {
|
||||
fetchedRoutes = [
|
||||
...(await Promise.all([collectionsPromise, productsPromise])).flat(),
|
||||
...pages
|
||||
...pages,
|
||||
];
|
||||
} catch (error) {
|
||||
throw JSON.stringify(error, null, 2);
|
||||
|
@ -1,50 +1,53 @@
|
||||
'use server';
|
||||
"use server";
|
||||
|
||||
import { TAGS } from 'lib/constants';
|
||||
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/sfcc';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { TAGS } from "lib/constants";
|
||||
import {
|
||||
addToCart,
|
||||
createCart,
|
||||
getCart,
|
||||
removeFromCart,
|
||||
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) {
|
||||
let cartId = cookies().get('cartId')?.value;
|
||||
|
||||
if (!cartId || !selectedVariantId) {
|
||||
return 'Error adding item to cart';
|
||||
export async function addItem(
|
||||
prevState: any,
|
||||
selectedVariantId: string | undefined
|
||||
) {
|
||||
if (!selectedVariantId) {
|
||||
return "Error adding item to cart";
|
||||
}
|
||||
|
||||
try {
|
||||
await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]);
|
||||
await addToCart([{ merchandiseId: selectedVariantId, quantity: 1 }]);
|
||||
revalidateTag(TAGS.cart);
|
||||
} catch (e) {
|
||||
return 'Error adding item to cart';
|
||||
return "Error adding item to cart";
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeItem(prevState: any, merchandiseId: string) {
|
||||
let cartId = cookies().get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return 'Missing cart ID';
|
||||
}
|
||||
|
||||
try {
|
||||
const cart = await getCart(cartId);
|
||||
const cart = await getCart();
|
||||
|
||||
if (!cart) {
|
||||
return 'Error fetching cart';
|
||||
return "Error fetching cart";
|
||||
}
|
||||
|
||||
const lineItem = cart.lines.find((line) => line.merchandise.id === merchandiseId);
|
||||
const lineItem = cart.lines.find(
|
||||
(line) => line.merchandise.id === merchandiseId
|
||||
);
|
||||
|
||||
if (lineItem && lineItem.id) {
|
||||
await removeFromCart(cartId, [lineItem.id]);
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,67 +58,49 @@ export async function updateItemQuantity(
|
||||
quantity: number;
|
||||
}
|
||||
) {
|
||||
let cartId = cookies().get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return 'Missing cart ID';
|
||||
}
|
||||
|
||||
const { merchandiseId, quantity } = payload;
|
||||
|
||||
try {
|
||||
const cart = await getCart(cartId);
|
||||
const cart = await getCart();
|
||||
|
||||
if (!cart) {
|
||||
return 'Error fetching cart';
|
||||
return "Error fetching cart";
|
||||
}
|
||||
|
||||
const lineItem = cart.lines.find((line) => line.merchandise.id === merchandiseId);
|
||||
const lineItem = cart.lines.find(
|
||||
(line) => line.merchandise.id === merchandiseId
|
||||
);
|
||||
|
||||
if (lineItem && lineItem.id) {
|
||||
if (quantity === 0) {
|
||||
await removeFromCart(cartId, [lineItem.id]);
|
||||
await removeFromCart([lineItem.id]);
|
||||
} else {
|
||||
await updateCart(cartId, [
|
||||
await updateCart([
|
||||
{
|
||||
id: lineItem.id,
|
||||
merchandiseId,
|
||||
quantity
|
||||
}
|
||||
quantity,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else if (quantity > 0) {
|
||||
// If the item doesn't exist in the cart and quantity > 0, add it
|
||||
await addToCart(cartId, [{ merchandiseId, quantity }]);
|
||||
await addToCart([{ merchandiseId, quantity }]);
|
||||
}
|
||||
|
||||
revalidateTag(TAGS.cart);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return 'Error updating item quantity';
|
||||
return "Error updating item quantity";
|
||||
}
|
||||
}
|
||||
|
||||
export async function redirectToCheckout() {
|
||||
let cartId = cookies().get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return 'Missing cart ID';
|
||||
}
|
||||
|
||||
let cart = await getCart(cartId);
|
||||
|
||||
if (!cart) {
|
||||
return 'Error fetching cart';
|
||||
}
|
||||
|
||||
redirect(cart.checkoutUrl);
|
||||
let cart = await getCart();
|
||||
redirect(cart!.checkoutUrl);
|
||||
}
|
||||
|
||||
export async function createCartAndSetCookie() {
|
||||
let cart = await createCart();
|
||||
// set the cartId to the same duration as the guest
|
||||
cookies().set('cartId', cart.id!, {
|
||||
maxAge: 60 * 30
|
||||
});
|
||||
(await cookies()).set("cartId", cart.id!);
|
||||
}
|
||||
|
@ -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/sfcc/types';
|
||||
import { useFormState } from 'react-dom';
|
||||
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">
|
||||
@ -61,24 +61,31 @@ export function AddToCart({ product }: { product: Product }) {
|
||||
const { variants, availableForSale } = product;
|
||||
const { addCartItem } = useCart();
|
||||
const { state } = useProduct();
|
||||
const [message, formAction] = useFormState(addItem, null);
|
||||
const [message, formAction] = useActionState(addItem, null);
|
||||
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
variant.selectedOptions.every((option) => option.value === state[option.name.toLowerCase()])
|
||||
variant.selectedOptions.every(
|
||||
(option) => option.value === state[option.name.toLowerCase()]
|
||||
)
|
||||
);
|
||||
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
|
||||
const selectedVariantId = variant?.id || defaultVariantId;
|
||||
const actionWithVariant = formAction.bind(null, selectedVariantId);
|
||||
const finalVariant = variants.find((variant) => variant.id === selectedVariantId)!;
|
||||
const addItemAction = formAction.bind(null, selectedVariantId);
|
||||
const finalVariant = variants.find(
|
||||
(variant) => variant.id === selectedVariantId
|
||||
)!;
|
||||
|
||||
return (
|
||||
<form
|
||||
action={async () => {
|
||||
addCartItem(finalVariant, product);
|
||||
await actionWithVariant();
|
||||
addItemAction();
|
||||
}}
|
||||
>
|
||||
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} />
|
||||
<SubmitButton
|
||||
availableForSale={availableForSale}
|
||||
selectedVariantId={selectedVariantId}
|
||||
/>
|
||||
<p aria-live="polite" className="sr-only" role="status">
|
||||
{message}
|
||||
</p>
|
||||
|
@ -1,18 +1,28 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Cart, CartItem, Product, ProductVariant } from 'lib/sfcc/types';
|
||||
import React, { createContext, use, useContext, useMemo, useOptimistic } from 'react';
|
||||
import { Cart, CartItem, Product, ProductVariant } from "lib/sfcc/types";
|
||||
import React, {
|
||||
createContext,
|
||||
use,
|
||||
useContext,
|
||||
useMemo,
|
||||
useOptimistic,
|
||||
} from "react";
|
||||
|
||||
type UpdateType = 'plus' | 'minus' | 'delete';
|
||||
type UpdateType = "plus" | "minus" | "delete";
|
||||
|
||||
type CartAction =
|
||||
| { type: 'UPDATE_ITEM'; payload: { merchandiseId: string; updateType: UpdateType } }
|
||||
| { type: 'ADD_ITEM'; payload: { variant: ProductVariant; product: Product } };
|
||||
| {
|
||||
type: "UPDATE_ITEM";
|
||||
payload: { merchandiseId: string; updateType: UpdateType };
|
||||
}
|
||||
| {
|
||||
type: "ADD_ITEM";
|
||||
payload: { variant: ProductVariant; product: Product };
|
||||
};
|
||||
|
||||
type CartContextType = {
|
||||
cart: Cart | undefined;
|
||||
updateCartItem: (merchandiseId: string, updateType: UpdateType) => void;
|
||||
addCartItem: (variant: ProductVariant, product: Product) => void;
|
||||
cartPromise: Promise<Cart | undefined>;
|
||||
};
|
||||
|
||||
const CartContext = createContext<CartContextType | undefined>(undefined);
|
||||
@ -21,14 +31,21 @@ function calculateItemCost(quantity: number, price: string): string {
|
||||
return (Number(price) * quantity).toString();
|
||||
}
|
||||
|
||||
function updateCartItem(item: CartItem, updateType: UpdateType): CartItem | null {
|
||||
if (updateType === 'delete') return null;
|
||||
function updateCartItem(
|
||||
item: CartItem,
|
||||
updateType: UpdateType
|
||||
): CartItem | null {
|
||||
if (updateType === "delete") return null;
|
||||
|
||||
const newQuantity = updateType === 'plus' ? item.quantity + 1 : item.quantity - 1;
|
||||
const newQuantity =
|
||||
updateType === "plus" ? item.quantity + 1 : item.quantity - 1;
|
||||
if (newQuantity === 0) return null;
|
||||
|
||||
const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity;
|
||||
const newTotalAmount = calculateItemCost(newQuantity, singleItemAmount.toString());
|
||||
const newTotalAmount = calculateItemCost(
|
||||
newQuantity,
|
||||
singleItemAmount.toString()
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
@ -37,9 +54,9 @@ function updateCartItem(item: CartItem, updateType: UpdateType): CartItem | null
|
||||
...item.cost,
|
||||
totalAmount: {
|
||||
...item.cost.totalAmount,
|
||||
amount: newTotalAmount
|
||||
}
|
||||
}
|
||||
amount: newTotalAmount,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -57,8 +74,8 @@ function createOrUpdateCartItem(
|
||||
cost: {
|
||||
totalAmount: {
|
||||
amount: totalAmount,
|
||||
currencyCode: variant.price.currencyCode
|
||||
}
|
||||
currencyCode: variant.price.currencyCode,
|
||||
},
|
||||
},
|
||||
merchandise: {
|
||||
id: variant.id,
|
||||
@ -68,38 +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'> {
|
||||
function updateCartTotals(
|
||||
lines: CartItem[]
|
||||
): 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 totalAmount = lines.reduce(
|
||||
(sum, item) => sum + Number(item.cost.totalAmount.amount),
|
||||
0
|
||||
);
|
||||
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" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -107,11 +129,13 @@ 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) =>
|
||||
item.merchandise.id === merchandiseId ? updateCartItem(item, updateType) : item
|
||||
item.merchandise.id === merchandiseId
|
||||
? updateCartItem(item, updateType)
|
||||
: item
|
||||
)
|
||||
.filter(Boolean) as CartItem[];
|
||||
|
||||
@ -122,23 +146,39 @@ 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 };
|
||||
return {
|
||||
...currentCart,
|
||||
...updateCartTotals(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);
|
||||
const updatedItem = createOrUpdateCartItem(existingItem, variant, product);
|
||||
const existingItem = currentCart.lines.find(
|
||||
(item) => item.merchandise.id === variant.id
|
||||
);
|
||||
const updatedItem = createOrUpdateCartItem(
|
||||
existingItem,
|
||||
variant,
|
||||
product
|
||||
);
|
||||
|
||||
const updatedLines = existingItem
|
||||
? currentCart.lines.map((item) => (item.merchandise.id === variant.id ? updatedItem : item))
|
||||
? currentCart.lines.map((item) =>
|
||||
item.merchandise.id === variant.id ? updatedItem : item
|
||||
)
|
||||
: [...currentCart.lines, updatedItem];
|
||||
|
||||
return { ...currentCart, ...updateCartTotals(updatedLines), lines: updatedLines };
|
||||
return {
|
||||
...currentCart,
|
||||
...updateCartTotals(updatedLines),
|
||||
lines: updatedLines,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return currentCart;
|
||||
@ -147,38 +187,47 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
|
||||
|
||||
export function CartProvider({
|
||||
children,
|
||||
cartPromise
|
||||
cartPromise,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cartPromise: Promise<Cart | undefined>;
|
||||
}) {
|
||||
const initialCart = use(cartPromise);
|
||||
const [optimisticCart, updateOptimisticCart] = useOptimistic(initialCart, cartReducer);
|
||||
|
||||
const updateCartItem = (merchandiseId: string, updateType: UpdateType) => {
|
||||
updateOptimisticCart({ type: 'UPDATE_ITEM', payload: { merchandiseId, updateType } });
|
||||
};
|
||||
|
||||
const addCartItem = (variant: ProductVariant, product: Product) => {
|
||||
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } });
|
||||
};
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
cart: optimisticCart,
|
||||
updateCartItem,
|
||||
addCartItem
|
||||
}),
|
||||
[optimisticCart]
|
||||
return (
|
||||
<CartContext.Provider value={{ cartPromise }}>
|
||||
{children}
|
||||
</CartContext.Provider>
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
return context;
|
||||
|
||||
const initialCart = use(context.cartPromise);
|
||||
const [optimisticCart, updateOptimisticCart] = useOptimistic(
|
||||
initialCart,
|
||||
cartReducer
|
||||
);
|
||||
|
||||
const updateCartItem = (merchandiseId: string, updateType: UpdateType) => {
|
||||
updateOptimisticCart({
|
||||
type: "UPDATE_ITEM",
|
||||
payload: { merchandiseId, updateType },
|
||||
});
|
||||
};
|
||||
|
||||
const addCartItem = (variant: ProductVariant, product: Product) => {
|
||||
updateOptimisticCart({ type: "ADD_ITEM", payload: { variant, product } });
|
||||
};
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
cart: optimisticCart,
|
||||
updateCartItem,
|
||||
addCartItem,
|
||||
}),
|
||||
[optimisticCart]
|
||||
);
|
||||
}
|
||||
|
@ -1,10 +0,0 @@
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function CloseCart({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
|
||||
<XMarkIcon className={clsx('h-6 transition-all ease-in-out hover:scale-110', className)} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,26 +1,26 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { removeItem } from 'components/cart/actions';
|
||||
import { CartItem } from 'lib/sfcc/types';
|
||||
import { useFormState } from 'react-dom';
|
||||
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;
|
||||
}) {
|
||||
const [message, formAction] = useFormState(removeItem, null);
|
||||
const [message, formAction] = useActionState(removeItem, null);
|
||||
const merchandiseId = item.merchandise.id;
|
||||
const actionWithVariant = formAction.bind(null, merchandiseId);
|
||||
const removeItemAction = formAction.bind(null, merchandiseId);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={async () => {
|
||||
optimisticUpdate(merchandiseId, 'delete');
|
||||
await actionWithVariant();
|
||||
optimisticUpdate(merchandiseId, "delete");
|
||||
removeItemAction();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
|
@ -1,24 +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 { CartItem } from 'lib/sfcc/types';
|
||||
import { useFormState } from 'react-dom';
|
||||
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'}
|
||||
aria-label={
|
||||
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" />
|
||||
@ -30,24 +32,24 @@ 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] = useFormState(updateItemQuantity, null);
|
||||
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 actionWithVariant = formAction.bind(null, payload);
|
||||
const updateItemQuantityAction = formAction.bind(null, payload);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={async () => {
|
||||
optimisticUpdate(payload.merchandiseId, type);
|
||||
await actionWithVariant();
|
||||
updateItemQuantityAction();
|
||||
}}
|
||||
>
|
||||
<SubmitButton type={type} />
|
||||
|
@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
||||
import { ShoppingCartIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import Price from 'components/price';
|
||||
import { DEFAULT_OPTION } from 'lib/constants';
|
||||
@ -12,7 +13,6 @@ import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { useFormStatus } from 'react-dom';
|
||||
import { createCartAndSetCookie, redirectToCheckout } from './actions';
|
||||
import { useCart } from './cart-context';
|
||||
import CloseCart from './close-cart';
|
||||
import { DeleteItemButton } from './delete-item-button';
|
||||
import { EditItemQuantityButton } from './edit-item-quantity-button';
|
||||
import OpenCart from './open-cart';
|
||||
@ -85,23 +85,31 @@ export default function CartModal() {
|
||||
{!cart || cart.lines.length === 0 ? (
|
||||
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
|
||||
<ShoppingCartIcon className="h-16" />
|
||||
<p className="mt-6 text-center text-2xl font-bold">Your cart is empty.</p>
|
||||
<p className="mt-6 text-center text-2xl font-bold">
|
||||
Your cart is empty.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col justify-between overflow-hidden p-1">
|
||||
<ul className="flex-grow overflow-auto py-4">
|
||||
<ul className="grow overflow-auto py-4">
|
||||
{cart.lines
|
||||
.sort((a, b) =>
|
||||
a.merchandise.product.title.localeCompare(b.merchandise.product.title)
|
||||
a.merchandise.product.title.localeCompare(
|
||||
b.merchandise.product.title
|
||||
)
|
||||
)
|
||||
.map((item, i) => {
|
||||
const merchandiseSearchParams = {} as MerchandiseSearchParams;
|
||||
const merchandiseSearchParams =
|
||||
{} as MerchandiseSearchParams;
|
||||
|
||||
item.merchandise.selectedOptions.forEach(({ name, value }) => {
|
||||
if (value !== DEFAULT_OPTION) {
|
||||
merchandiseSearchParams[name.toLowerCase()] = value;
|
||||
item.merchandise.selectedOptions.forEach(
|
||||
({ name, value }) => {
|
||||
if (value !== DEFAULT_OPTION) {
|
||||
merchandiseSearchParams[name.toLowerCase()] =
|
||||
value;
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
const merchandiseUrl = createUrl(
|
||||
`/product/${item.merchandise.product.handle}`,
|
||||
@ -115,7 +123,10 @@ export default function CartModal() {
|
||||
>
|
||||
<div className="relative flex w-full flex-row justify-between px-1 py-4">
|
||||
<div className="absolute z-40 -ml-1 -mt-2">
|
||||
<DeleteItemButton item={item} optimisticUpdate={updateCartItem} />
|
||||
<DeleteItemButton
|
||||
item={item}
|
||||
optimisticUpdate={updateCartItem}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<div className="relative h-16 w-16 overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
|
||||
@ -124,10 +135,13 @@ export default function CartModal() {
|
||||
width={64}
|
||||
height={64}
|
||||
alt={
|
||||
item.merchandise.product.featuredImage.altText ||
|
||||
item.merchandise.product.featuredImage
|
||||
.altText ||
|
||||
item.merchandise.product.title
|
||||
}
|
||||
src={item.merchandise.product.featuredImage.url}
|
||||
src={
|
||||
item.merchandise.product.featuredImage.url
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
@ -139,7 +153,8 @@ export default function CartModal() {
|
||||
<span className="leading-tight">
|
||||
{item.merchandise.product.title}
|
||||
</span>
|
||||
{item.merchandise.title !== DEFAULT_OPTION ? (
|
||||
{item.merchandise.title !==
|
||||
DEFAULT_OPTION ? (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{item.merchandise.title}
|
||||
</p>
|
||||
@ -151,7 +166,9 @@ export default function CartModal() {
|
||||
<Price
|
||||
className="flex justify-end space-y-2 text-right text-sm"
|
||||
amount={item.cost.totalAmount.amount}
|
||||
currencyCode={item.cost.totalAmount.currencyCode}
|
||||
currencyCode={
|
||||
item.cost.totalAmount.currencyCode
|
||||
}
|
||||
/>
|
||||
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
|
||||
<EditItemQuantityButton
|
||||
@ -160,7 +177,9 @@ export default function CartModal() {
|
||||
optimisticUpdate={updateCartItem}
|
||||
/>
|
||||
<p className="w-6 text-center">
|
||||
<span className="w-full text-sm">{item.quantity}</span>
|
||||
<span className="w-full text-sm">
|
||||
{item.quantity}
|
||||
</span>
|
||||
</p>
|
||||
<EditItemQuantityButton
|
||||
item={item}
|
||||
@ -209,6 +228,19 @@ export default function CartModal() {
|
||||
);
|
||||
}
|
||||
|
||||
function CloseCart({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
|
||||
<XMarkIcon
|
||||
className={clsx(
|
||||
'h-6 transition-all ease-in-out hover:scale-110',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckoutButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
|
@ -15,7 +15,7 @@ export default function OpenCart({
|
||||
/>
|
||||
|
||||
{quantity ? (
|
||||
<div className="absolute right-0 top-0 -mr-2 -mt-2 h-4 w-4 rounded bg-blue-600 text-[11px] font-medium text-white">
|
||||
<div className="absolute right-0 top-0 -mr-2 -mt-2 h-4 w-4 rounded-sm bg-blue-600 text-[11px] font-medium text-white">
|
||||
{quantity}
|
||||
</div>
|
||||
) : null}
|
||||
|
@ -52,7 +52,7 @@ export async function ThreeItemGrid() {
|
||||
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
|
||||
|
||||
return (
|
||||
<section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2 lg:max-h-[calc(100vh-200px)]">
|
||||
<section className="mx-auto grid max-w-(--breakpoint-2xl) gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2 lg:max-h-[calc(100vh-200px)]">
|
||||
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
|
||||
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
|
||||
<ThreeItemGridItem size="half" item={thirdProduct} />
|
||||
|
@ -19,7 +19,7 @@ const Label = ({
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center rounded-full border bg-white/70 p-1 text-xs font-semibold text-black backdrop-blur-md dark:border-neutral-800 dark:bg-black/70 dark:text-white">
|
||||
<h3 className="mr-4 line-clamp-2 flex-grow pl-2 leading-none tracking-tight">{title}</h3>
|
||||
<h3 className="mr-4 line-clamp-2 grow pl-2 leading-none tracking-tight">{title}</h3>
|
||||
<Price
|
||||
className="flex-none rounded-full bg-blue-600 p-2 text-white"
|
||||
amount={amount}
|
||||
|
@ -10,7 +10,7 @@ const { COMPANY_NAME, SITE_NAME } = process.env;
|
||||
export default async function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
|
||||
const skeleton = 'w-full h-6 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700';
|
||||
const skeleton = 'w-full h-6 animate-pulse rounded-sm bg-neutral-200 dark:bg-neutral-700';
|
||||
const menu = await getMenu('next-js-frontend-footer-menu');
|
||||
const copyrightName = COMPANY_NAME || SITE_NAME || '';
|
||||
|
||||
|
@ -9,7 +9,7 @@ async function CollectionList() {
|
||||
return <FilterList list={collections} title="Collections" />;
|
||||
}
|
||||
|
||||
const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded';
|
||||
const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded-sm';
|
||||
const activeAndTitles = 'bg-neutral-800 dark:bg-neutral-300';
|
||||
const items = 'bg-neutral-400 dark:bg-neutral-700';
|
||||
|
||||
|
@ -42,7 +42,7 @@ export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
|
||||
onClick={() => {
|
||||
setOpenSelect(!openSelect);
|
||||
}}
|
||||
className="flex w-full items-center justify-between rounded border border-black/30 px-4 py-2 text-sm dark:border-white/30"
|
||||
className="flex w-full items-center justify-between rounded-sm border border-black/30 px-4 py-2 text-sm dark:border-white/30"
|
||||
>
|
||||
<div>{active}</div>
|
||||
<ChevronDownIcon className="h-4" />
|
||||
|
@ -1,11 +1,15 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import LogoIcon from './icons/logo';
|
||||
import { join } from 'path';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
export type Props = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export default async function OpengraphImage(props?: Props): Promise<ImageResponse> {
|
||||
export default async function OpengraphImage(
|
||||
props?: Props
|
||||
): Promise<ImageResponse> {
|
||||
const { title } = {
|
||||
...{
|
||||
title: process.env.SITE_NAME
|
||||
@ -13,6 +17,9 @@ export default async function OpengraphImage(props?: Props): Promise<ImageRespon
|
||||
...props
|
||||
};
|
||||
|
||||
const file = await readFile(join(process.cwd(), './fonts/Inter-Bold.ttf'));
|
||||
const font = Uint8Array.from(file).buffer;
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div tw="flex h-full w-full flex-col items-center justify-center bg-black">
|
||||
@ -28,9 +35,7 @@ export default async function OpengraphImage(props?: Props): Promise<ImageRespon
|
||||
fonts: [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: await fetch(new URL('../fonts/Inter-Bold.ttf', import.meta.url)).then((res) =>
|
||||
res.arrayBuffer()
|
||||
),
|
||||
data: font,
|
||||
style: 'normal',
|
||||
weight: 700
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
|
||||
|
||||
{images.length > 1 ? (
|
||||
<div className="absolute bottom-[15%] flex w-full justify-center">
|
||||
<div className="mx-auto flex h-11 items-center rounded-full border border-white bg-neutral-50/80 text-neutral-500 backdrop-blur dark:border-black dark:bg-neutral-900/80">
|
||||
<div className="mx-auto flex h-11 items-center rounded-full border border-white bg-neutral-50/80 text-neutral-500 backdrop-blur-sm dark:border-black dark:bg-neutral-900/80">
|
||||
<button
|
||||
formAction={() => {
|
||||
const newState = updateImage(previousImageIndex.toString());
|
||||
@ -60,7 +60,7 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
|
||||
</div>
|
||||
|
||||
{images.length > 1 ? (
|
||||
<ul className="my-12 flex items-center justify-center gap-2 overflow-auto py-1 lg:mb-0">
|
||||
<ul className="my-12 flex items-center flex-wrap justify-center gap-2 overflow-auto py-1 lg:mb-0">
|
||||
{images.map((image, index) => {
|
||||
const isActive = index === imageIndex;
|
||||
|
||||
|
@ -77,7 +77,7 @@ export function VariantSelector({
|
||||
'cursor-default ring-2 ring-blue-600': isActive,
|
||||
'ring-1 ring-transparent transition duration-300 ease-in-out hover:ring-blue-600':
|
||||
!isActive && isAvailableForSale,
|
||||
'relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 before:dark:bg-neutral-700':
|
||||
'relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 dark:before:bg-neutral-700':
|
||||
!isAvailableForSale
|
||||
}
|
||||
)}
|
||||
|
@ -1,19 +1,13 @@
|
||||
import clsx from 'clsx';
|
||||
import type { FunctionComponent } from 'react';
|
||||
|
||||
interface TextProps {
|
||||
html: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Prose: FunctionComponent<TextProps> = ({ html, className }) => {
|
||||
const Prose = ({ html, className }: { html: string; className?: string }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'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',
|
||||
'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 prose-a:hover: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',
|
||||
className
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: html as string }}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -1,11 +1,23 @@
|
||||
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';
|
||||
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: {},
|
||||
@ -13,8 +25,8 @@ const config = {
|
||||
clientId: process.env.SFCC_CLIENT_ID,
|
||||
organizationId: process.env.SFCC_ORGANIZATIONID,
|
||||
shortCode: process.env.SFCC_SHORTCODE,
|
||||
siteId: process.env.SFCC_SITEID
|
||||
}
|
||||
siteId: process.env.SFCC_SITEID,
|
||||
},
|
||||
};
|
||||
|
||||
type SortedProductResult = {
|
||||
@ -26,25 +38,31 @@ export const getCollections = cache(
|
||||
async () => {
|
||||
return await getSFCCCollections();
|
||||
},
|
||||
['get-collections'],
|
||||
["get-collections"],
|
||||
{
|
||||
tags: [TAGS.collections]
|
||||
tags: [TAGS.collections],
|
||||
}
|
||||
);
|
||||
|
||||
export function getCollection(handle: string) {
|
||||
return getCollections().then((collections) => collections.find((c) => c.handle === handle));
|
||||
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 getProduct = cache(
|
||||
async (id: string) => getSFCCProduct(id),
|
||||
["get-product"],
|
||||
{
|
||||
tags: [TAGS.products],
|
||||
}
|
||||
);
|
||||
|
||||
export const getCollectionProducts = cache(
|
||||
async ({
|
||||
collection,
|
||||
reverse,
|
||||
sortKey
|
||||
sortKey,
|
||||
}: {
|
||||
collection: string;
|
||||
reverse?: boolean;
|
||||
@ -52,33 +70,40 @@ export const getCollectionProducts = cache(
|
||||
}) => {
|
||||
return await searchProducts({ categoryId: collection, sortKey });
|
||||
},
|
||||
['get-collection-products'],
|
||||
["get-collection-products"],
|
||||
{ tags: [TAGS.products, TAGS.collections] }
|
||||
);
|
||||
|
||||
export const getProducts = cache(
|
||||
async ({ query, sortKey }: { query?: string; sortKey?: string; reverse?: boolean }) => {
|
||||
async ({
|
||||
query,
|
||||
sortKey,
|
||||
}: {
|
||||
query?: string;
|
||||
sortKey?: string;
|
||||
reverse?: boolean;
|
||||
}) => {
|
||||
return await searchProducts({ query, sortKey });
|
||||
},
|
||||
['get-products'],
|
||||
["get-products"],
|
||||
{
|
||||
tags: [TAGS.products]
|
||||
tags: [TAGS.products],
|
||||
}
|
||||
);
|
||||
|
||||
export async function createCart() {
|
||||
let guestToken = cookies().get('guest_token')?.value;
|
||||
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;
|
||||
cookies().set('guest_token', guestToken, {
|
||||
(await cookies()).set("guest_token", guestToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
maxAge: 60 * 30,
|
||||
path: '/'
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
@ -90,7 +115,7 @@ export async function createCart() {
|
||||
|
||||
// create an empty ShopperBaskets.Basket
|
||||
const createdBasket = await basketClient.createBasket({
|
||||
body: {}
|
||||
body: {},
|
||||
});
|
||||
|
||||
const cartItems = await getCartItems(createdBasket);
|
||||
@ -98,9 +123,10 @@ export async function createCart() {
|
||||
return reshapeBasket(createdBasket, cartItems);
|
||||
}
|
||||
|
||||
export async function getCart(cartId: string | undefined): Promise<Cart | undefined> {
|
||||
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 = cookies().get('guest_token')?.value;
|
||||
const guestToken = (await cookies()).get("guest_token")?.value;
|
||||
|
||||
const config = await getGuestUserConfig(guestToken);
|
||||
|
||||
@ -113,8 +139,8 @@ export async function getCart(cartId: string | undefined): Promise<Cart | undefi
|
||||
parameters: {
|
||||
basketId: cartId,
|
||||
organizationId: process.env.SFCC_ORGANIZATIONID,
|
||||
siteId: process.env.SFCC_SITEID
|
||||
}
|
||||
siteId: process.env.SFCC_SITEID,
|
||||
},
|
||||
});
|
||||
|
||||
if (!basket?.basketId) return;
|
||||
@ -128,11 +154,11 @@ export async function getCart(cartId: string | undefined): Promise<Cart | undefi
|
||||
}
|
||||
|
||||
export async function addToCart(
|
||||
cartId: string,
|
||||
lines: { merchandiseId: string; quantity: number }[]
|
||||
) {
|
||||
const cartId = (await cookies()).get("cartId")?.value!;
|
||||
// get the guest token to get the correct guest cart
|
||||
const guestToken = cookies().get('guest_token')?.value;
|
||||
const guestToken = (await cookies()).get("guest_token")?.value;
|
||||
const config = await getGuestUserConfig(guestToken);
|
||||
|
||||
try {
|
||||
@ -142,14 +168,14 @@ export async function addToCart(
|
||||
parameters: {
|
||||
basketId: cartId,
|
||||
organizationId: process.env.SFCC_ORGANIZATIONID,
|
||||
siteId: process.env.SFCC_SITEID
|
||||
siteId: process.env.SFCC_SITEID,
|
||||
},
|
||||
body: lines.map((line) => {
|
||||
return {
|
||||
productId: line.merchandiseId,
|
||||
quantity: line.quantity
|
||||
quantity: line.quantity,
|
||||
};
|
||||
})
|
||||
}),
|
||||
});
|
||||
|
||||
if (!basket?.basketId) return;
|
||||
@ -162,12 +188,14 @@ export async function addToCart(
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeFromCart(cartId: string, lineIds: string[]) {
|
||||
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');
|
||||
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 guestToken = (await cookies()).get("guest_token")?.value;
|
||||
const config = await getGuestUserConfig(guestToken);
|
||||
|
||||
const basketClient = new Checkout.ShopperBaskets(config);
|
||||
@ -175,8 +203,8 @@ export async function removeFromCart(cartId: string, lineIds: string[]) {
|
||||
const basket = await basketClient.removeItemFromBasket({
|
||||
parameters: {
|
||||
basketId: cartId,
|
||||
itemId: lineIds[0]!
|
||||
}
|
||||
itemId: lineIds[0]!,
|
||||
},
|
||||
});
|
||||
|
||||
const cartItems = await getCartItems(basket);
|
||||
@ -184,11 +212,11 @@ export async function removeFromCart(cartId: string, lineIds: string[]) {
|
||||
}
|
||||
|
||||
export async function updateCart(
|
||||
cartId: string,
|
||||
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 = cookies().get('guest_token')?.value;
|
||||
const guestToken = (await cookies()).get("guest_token")?.value;
|
||||
const config = await getGuestUserConfig(guestToken);
|
||||
|
||||
const basketClient = new Checkout.ShopperBaskets(config);
|
||||
@ -202,8 +230,8 @@ export async function updateCart(
|
||||
basketClient.removeItemFromBasket({
|
||||
parameters: {
|
||||
basketId: cartId,
|
||||
itemId: line.id
|
||||
}
|
||||
itemId: line.id,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@ -214,14 +242,14 @@ export async function updateCart(
|
||||
const addPromises = lines.map((line) =>
|
||||
basketClient.addItemToBasket({
|
||||
parameters: {
|
||||
basketId: cartId
|
||||
basketId: cartId,
|
||||
},
|
||||
body: [
|
||||
{
|
||||
productId: line.merchandiseId,
|
||||
quantity: line.quantity
|
||||
}
|
||||
]
|
||||
quantity: line.quantity,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
@ -231,8 +259,8 @@ export async function updateCart(
|
||||
// all updates are done, get the updated basket
|
||||
const updatedBasket = await basketClient.getBasket({
|
||||
parameters: {
|
||||
basketId: cartId
|
||||
}
|
||||
basketId: cartId,
|
||||
},
|
||||
});
|
||||
|
||||
const cartItems = await getCartItems(updatedBasket);
|
||||
@ -251,16 +279,18 @@ export async function getProductRecommendations(productId: string) {
|
||||
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 });
|
||||
})
|
||||
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
|
||||
@ -271,15 +301,23 @@ export async function getProductRecommendations(productId: string) {
|
||||
}
|
||||
|
||||
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 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.');
|
||||
console.error("Invalid revalidation secret.");
|
||||
return NextResponse.json({ status: 200 });
|
||||
}
|
||||
|
||||
@ -302,13 +340,16 @@ export async function revalidate(req: NextRequest) {
|
||||
async function getGuestUserAuthToken() {
|
||||
const base64data = Buffer.from(
|
||||
`${process.env.SFCC_CLIENT_ID}:${process.env.SFCC_SECRET}`
|
||||
).toString('base64');
|
||||
).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 }
|
||||
body: {
|
||||
grant_type: "client_credentials",
|
||||
channel_id: process.env.SFCC_SITEID,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -316,14 +357,14 @@ async function getGuestUserConfig(token?: string) {
|
||||
const guestToken = token || (await getGuestUserAuthToken()).access_token;
|
||||
|
||||
if (!guestToken) {
|
||||
throw new Error('Failed to retrieve access token');
|
||||
throw new Error("Failed to retrieve access token");
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
headers: {
|
||||
authorization: `Bearer ${guestToken}`
|
||||
}
|
||||
authorization: `Bearer ${guestToken}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -333,8 +374,8 @@ async function getSFCCCollections() {
|
||||
|
||||
const result = await productsClient.getCategories({
|
||||
parameters: {
|
||||
ids: storeCatalog.ids
|
||||
}
|
||||
ids: storeCatalog.ids,
|
||||
},
|
||||
});
|
||||
|
||||
return reshapeCategories(result.data || []);
|
||||
@ -348,41 +389,47 @@ async function getSFCCProduct(id: string) {
|
||||
parameters: {
|
||||
organizationId: config.parameters.organizationId,
|
||||
siteId: config.parameters.siteId,
|
||||
id
|
||||
}
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
return reshapeProduct(product);
|
||||
}
|
||||
|
||||
async function searchProducts(options: { query?: string; categoryId?: string; sortKey?: string }) {
|
||||
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 || '',
|
||||
q: query || "",
|
||||
refine: categoryId ? [`cgid=${categoryId}`] : [],
|
||||
sort: sortKey,
|
||||
limit: 100
|
||||
}
|
||||
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 });
|
||||
})
|
||||
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
|
||||
@ -409,15 +456,17 @@ async function getCartItems(createdBasket: ShopperBaskets.Basket) {
|
||||
);
|
||||
|
||||
// Reshape the sfcc items and push them onto the cartItems
|
||||
createdBasket.productItems.map((productItem: ShopperBaskets.ProductItem) => {
|
||||
cartItems.push(
|
||||
reshapeProductItem(
|
||||
productItem,
|
||||
createdBasket.currency || 'USD',
|
||||
productsInCart.find((p) => p.id === productItem.productId)!
|
||||
)
|
||||
);
|
||||
});
|
||||
createdBasket.productItems.map(
|
||||
(productItem: ShopperBaskets.ProductItem) => {
|
||||
cartItems.push(
|
||||
reshapeProductItem(
|
||||
productItem,
|
||||
createdBasket.currency || "USD",
|
||||
productsInCart.find((p) => p.id === productItem.productId)!
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return cartItems;
|
||||
@ -432,18 +481,20 @@ function reshapeCategory(
|
||||
|
||||
return {
|
||||
handle: category.id,
|
||||
title: category.name || '',
|
||||
description: category.description || '',
|
||||
title: category.name || "",
|
||||
description: category.description || "",
|
||||
seo: {
|
||||
title: category.pageTitle || '',
|
||||
description: category.description || ''
|
||||
title: category.pageTitle || "",
|
||||
description: category.description || "",
|
||||
},
|
||||
updatedAt: '',
|
||||
path: `/search/${category.id}`
|
||||
updatedAt: "",
|
||||
path: `/search/${category.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function reshapeCategories(categories: SalesforceProduct.ShopperProducts.Category[]) {
|
||||
function reshapeCategories(
|
||||
categories: SalesforceProduct.ShopperProducts.Category[]
|
||||
) {
|
||||
const reshapedCategories = [];
|
||||
for (const category of categories) {
|
||||
if (category) {
|
||||
@ -458,13 +509,13 @@ function reshapeCategories(categories: SalesforceProduct.ShopperProducts.Categor
|
||||
|
||||
function reshapeProduct(product: SalesforceProduct.ShopperProducts.Product) {
|
||||
if (!product.name) {
|
||||
throw new Error('Product name is not set');
|
||||
throw new Error("Product name is not set");
|
||||
}
|
||||
|
||||
const images = reshapeImages(product.imageGroups);
|
||||
|
||||
if (!images[0]) {
|
||||
throw new Error('Product image is not set');
|
||||
throw new Error("Product image is not set");
|
||||
}
|
||||
|
||||
const flattenedPrices =
|
||||
@ -477,22 +528,22 @@ function reshapeProduct(product: SalesforceProduct.ShopperProducts.Product) {
|
||||
id: product.id,
|
||||
handle: product.id,
|
||||
title: product.name,
|
||||
description: product.shortDescription || '',
|
||||
descriptionHtml: product.longDescription || '',
|
||||
tags: product['c_product-tags'] || [],
|
||||
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'
|
||||
amount: flattenedPrices[flattenedPrices.length - 1]?.toString() || "0",
|
||||
currencyCode: product.currency || "USD",
|
||||
},
|
||||
minVariantPrice: {
|
||||
amount: flattenedPrices[0]?.toString() || '0',
|
||||
currencyCode: product.currency || 'USD'
|
||||
}
|
||||
amount: flattenedPrices[0]?.toString() || "0",
|
||||
currencyCode: product.currency || "USD",
|
||||
},
|
||||
},
|
||||
images: images,
|
||||
options:
|
||||
@ -501,19 +552,24 @@ function reshapeProduct(product: SalesforceProduct.ShopperProducts.Product) {
|
||||
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!) || []
|
||||
values:
|
||||
attribute.values
|
||||
?.filter((v) => v.value !== undefined)
|
||||
?.map((v) => v.name!) || [],
|
||||
};
|
||||
}) || [],
|
||||
seo: {
|
||||
title: product.pageTitle || '',
|
||||
description: product.pageDescription || ''
|
||||
title: product.pageTitle || "",
|
||||
description: product.pageDescription || "",
|
||||
},
|
||||
variants: reshapeVariants(product.variants || [], product),
|
||||
updatedAt: product['c_updated-date']
|
||||
updatedAt: product["c_updated-date"],
|
||||
};
|
||||
}
|
||||
|
||||
function reshapeProducts(products: SalesforceProduct.ShopperProducts.Product[]) {
|
||||
function reshapeProducts(
|
||||
products: SalesforceProduct.ShopperProducts.Product[]
|
||||
) {
|
||||
const reshapedProducts = [];
|
||||
for (const product of products) {
|
||||
if (product) {
|
||||
@ -531,7 +587,7 @@ function reshapeImages(
|
||||
): Image[] {
|
||||
if (!imageGroups) return [];
|
||||
|
||||
const largeGroup = imageGroups.filter((g) => g.viewType === 'large');
|
||||
const largeGroup = imageGroups.filter((g) => g.viewType === "large");
|
||||
|
||||
const images = [...largeGroup].map((group) => group.images).flat();
|
||||
|
||||
@ -541,7 +597,7 @@ function reshapeImages(
|
||||
url: image.link,
|
||||
// TODO: add field for size
|
||||
width: image.width || 800,
|
||||
height: image.height || 800
|
||||
height: image.height || 800,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -559,22 +615,24 @@ function reshapeVariant(
|
||||
) {
|
||||
return {
|
||||
id: variant.productId,
|
||||
title: product.name || '',
|
||||
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,
|
||||
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 || ''
|
||||
?.values?.find((v) => v.value === value)?.name || "",
|
||||
})) || [],
|
||||
price: {
|
||||
amount: variant.price?.toString() || '0',
|
||||
currencyCode: product.currency || 'USD'
|
||||
}
|
||||
amount: variant.price?.toString() || "0",
|
||||
currencyCode: product.currency || "USD",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -584,48 +642,52 @@ function reshapeProductItem(
|
||||
matchingProduct: Product
|
||||
): CartItem {
|
||||
return {
|
||||
id: item.itemId || '',
|
||||
id: item.itemId || "",
|
||||
quantity: item.quantity || 0,
|
||||
cost: {
|
||||
totalAmount: {
|
||||
amount: item.price?.toString() || '0',
|
||||
currencyCode: currency
|
||||
}
|
||||
amount: item.price?.toString() || "0",
|
||||
currencyCode: currency,
|
||||
},
|
||||
},
|
||||
merchandise: {
|
||||
id: item.productId || '',
|
||||
title: item.productName || '',
|
||||
id: item.productId || "",
|
||||
title: item.productName || "",
|
||||
selectedOptions:
|
||||
item.optionItems?.map((o) => {
|
||||
return {
|
||||
name: o.optionId!,
|
||||
value: o.optionValueId!
|
||||
value: o.optionValueId!,
|
||||
};
|
||||
}) || [],
|
||||
product: matchingProduct
|
||||
}
|
||||
product: matchingProduct,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function reshapeBasket(basket: ShopperBaskets.Basket, cartItems: CartItem[]): Cart {
|
||||
function reshapeBasket(
|
||||
basket: ShopperBaskets.Basket,
|
||||
cartItems: CartItem[]
|
||||
): Cart {
|
||||
return {
|
||||
id: basket.basketId!,
|
||||
checkoutUrl: '/checkout',
|
||||
checkoutUrl: "/checkout",
|
||||
cost: {
|
||||
subtotalAmount: {
|
||||
amount: basket.productSubTotal?.toString() || '0',
|
||||
currencyCode: basket.currency || 'USD'
|
||||
amount: basket.productSubTotal?.toString() || "0",
|
||||
currencyCode: basket.currency || "USD",
|
||||
},
|
||||
totalAmount: {
|
||||
amount: `${(basket.productSubTotal ?? 0) + (basket.merchandizeTotalTax ?? 0)}`,
|
||||
currencyCode: basket.currency || 'USD'
|
||||
currencyCode: basket.currency || "USD",
|
||||
},
|
||||
totalTaxAmount: {
|
||||
amount: basket.merchandizeTotalTax?.toString() || '0',
|
||||
currencyCode: basket.currency || 'USD'
|
||||
}
|
||||
amount: basket.merchandizeTotalTax?.toString() || "0",
|
||||
currencyCode: basket.currency || "USD",
|
||||
},
|
||||
},
|
||||
totalQuantity: cartItems?.reduce((acc, item) => acc + (item?.quantity ?? 0), 0) ?? 0,
|
||||
lines: cartItems
|
||||
totalQuantity:
|
||||
cartItems?.reduce((acc, item) => acc + (item?.quantity ?? 0), 0) ?? 0,
|
||||
lines: cartItems,
|
||||
};
|
||||
}
|
||||
|
17
lib/utils.ts
17
lib/utils.ts
@ -1,11 +1,20 @@
|
||||
import { ReadonlyURLSearchParams } from 'next/navigation';
|
||||
import { ReadonlyURLSearchParams } from "next/navigation";
|
||||
|
||||
export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => {
|
||||
export const baseUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL
|
||||
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
|
||||
: "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}`;
|
||||
};
|
||||
|
||||
export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
|
||||
stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
|
||||
stringToCheck.startsWith(startsWith)
|
||||
? stringToCheck
|
||||
: `${startsWith}${stringToCheck}`;
|
||||
|
@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2023 Vercel, Inc.
|
||||
Copyright (c) 2025 Vercel, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
@ -1,17 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.shopify.com',
|
||||
pathname: '/s/files/**'
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'zylq-002.dx.commercecloud.salesforce.com',
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
18
next.config.ts
Normal file
18
next.config.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export default {
|
||||
experimental: {
|
||||
ppr: true,
|
||||
inlineCss: true,
|
||||
useCache: true,
|
||||
reactOwnerStack: true,
|
||||
newDevOverlay: true,
|
||||
},
|
||||
images: {
|
||||
formats: ["image/avif", "image/webp"],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "zylq-002.dx.commercecloud.salesforce.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
38
package.json
38
package.json
@ -1,11 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
"pnpm": ">=9"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"prettier": "prettier --write --ignore-unknown .",
|
||||
@ -13,27 +9,27 @@
|
||||
"test": "pnpm prettier:check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@heroicons/react": "^2.1.5",
|
||||
"@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.0.0-canary.113",
|
||||
"react": "19.0.0-rc-3208e73e-20240730",
|
||||
"react-dom": "19.0.0-rc-3208e73e-20240730",
|
||||
"sonner": "^1.5.0"
|
||||
"next": "15.2.0-canary.67",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"sonner": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@types/node": "20.14.12",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "5.5.4"
|
||||
"@tailwindcss/postcss": "^4.0.8",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/node": "22.13.4",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "3.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.0.8",
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
}
|
||||
|
3678
pnpm-lock.yaml
generated
3678
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
6
postcss.config.mjs
Normal file
6
postcss.config.mjs
Normal file
@ -0,0 +1,6 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
}
|
||||
};
|
@ -1,9 +0,0 @@
|
||||
/** @type {import('prettier').Config} */
|
||||
module.exports = {
|
||||
singleQuote: true,
|
||||
arrowParens: 'always',
|
||||
trailingComma: 'none',
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
plugins: ['prettier-plugin-tailwindcss']
|
||||
};
|
@ -1,54 +0,0 @@
|
||||
const plugin = require('tailwindcss/plugin');
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['var(--font-geist-sans)']
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 1 }
|
||||
},
|
||||
marquee: {
|
||||
'0%': { transform: 'translateX(0%)' },
|
||||
'100%': { transform: 'translateX(-100%)' }
|
||||
},
|
||||
blink: {
|
||||
'0%': { opacity: 0.2 },
|
||||
'20%': { opacity: 1 },
|
||||
'100% ': { opacity: 0.2 }
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
fadeIn: 'fadeIn .3s ease-in-out',
|
||||
carousel: 'marquee 60s linear infinite',
|
||||
blink: 'blink 1.4s both infinite'
|
||||
}
|
||||
}
|
||||
},
|
||||
future: {
|
||||
hoverOnlyWhenSupported: true
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/container-queries'),
|
||||
require('@tailwindcss/typography'),
|
||||
plugin(({ matchUtilities, theme }) => {
|
||||
matchUtilities(
|
||||
{
|
||||
'animation-delay': (value) => {
|
||||
return {
|
||||
'animation-delay': value
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
values: theme('transitionDelay')
|
||||
}
|
||||
);
|
||||
})
|
||||
]
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user