4 Commits

Author SHA1 Message Date
Zack Tanner
f29a16de5c add layout keying 2024-08-01 10:24:50 -07:00
Zack Tanner
6bfd0b1bd9 remove full prefetch on search page 2024-08-01 10:14:47 -07:00
Janka Uryga
6e85b80158 update next to https://github.com/vercel/next.js/pull/68340/ 2024-07-31 16:57:21 +02:00
Lee Robinson
8c5f8d8d2a next/form 2024-07-30 17:19:38 -05:00
22 changed files with 543 additions and 576 deletions

View File

@@ -2,7 +2,7 @@
# Next.js Commerce # Next.js Commerce
A high-performance, server-rendered Next.js App Router ecommerce application. A high-perfomance, server-rendered Next.js App Router ecommerce application.
This template uses React Server Components, Server Actions, `Suspense`, `useOptimistic`, and more. This template uses React Server Components, Server Actions, `Suspense`, `useOptimistic`, and more.
@@ -33,13 +33,9 @@ Vercel is happy to partner and work with any commerce provider to help them get
Integrations enable upgraded or additional functionality for Next.js Commerce Integrations enable upgraded or additional functionality for Next.js Commerce
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/)) - [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. - 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. - 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 ## 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. 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.

View File

@@ -4,10 +4,11 @@ import Prose from 'components/prose';
import { getPage } from 'lib/shopify'; import { getPage } from 'lib/shopify';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
export async function generateMetadata(props: { export async function generateMetadata({
params: Promise<{ page: string }>; params
}: {
params: { page: string };
}): Promise<Metadata> { }): Promise<Metadata> {
const params = await props.params;
const page = await getPage(params.page); const page = await getPage(params.page);
if (!page) return notFound(); if (!page) return notFound();
@@ -23,8 +24,7 @@ export async function generateMetadata(props: {
}; };
} }
export default async function Page(props: { params: Promise<{ page: string }> }) { export default async function Page({ params }: { params: { page: string } }) {
const params = await props.params;
const page = await getPage(params.page); const page = await getPage(params.page);
if (!page) return notFound(); if (!page) return notFound();
@@ -32,7 +32,7 @@ export default async function Page(props: { params: Promise<{ page: string }> })
return ( return (
<> <>
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1> <h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
<Prose className="mb-8" html={page.body} /> <Prose className="mb-8" html={page.body as string} />
<p className="text-sm italic"> <p className="text-sm italic">
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, { {`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
year: 'numeric', year: 'numeric',

View File

@@ -37,7 +37,7 @@ export const metadata = {
}; };
export default async function RootLayout({ children }: { children: ReactNode }) { export default async function RootLayout({ children }: { children: ReactNode }) {
const cartId = (await cookies()).get('cartId')?.value; const cartId = cookies().get('cartId')?.value;
// Don't await the fetch, pass the Promise to the context provider // Don't await the fetch, pass the Promise to the context provider
const cart = getCart(cartId); const cart = getCart(cartId);

View File

@@ -12,10 +12,11 @@ import { Image } from 'lib/shopify/types';
import Link from 'next/link'; import Link from 'next/link';
import { Suspense } from 'react'; import { Suspense } from 'react';
export async function generateMetadata(props: { export async function generateMetadata({
params: Promise<{ handle: string }>; params
}: {
params: { handle: string };
}): Promise<Metadata> { }): Promise<Metadata> {
const params = await props.params;
const product = await getProduct(params.handle); const product = await getProduct(params.handle);
if (!product) return notFound(); if (!product) return notFound();
@@ -49,8 +50,7 @@ export async function generateMetadata(props: {
}; };
} }
export default async function ProductPage(props: { params: Promise<{ handle: string }> }) { export default async function ProductPage({ params }: { params: { handle: string } }) {
const params = await props.params;
const product = await getProduct(params.handle); const product = await getProduct(params.handle);
if (!product) return notFound(); if (!product) return notFound();

View File

@@ -6,10 +6,11 @@ import Grid from 'components/grid';
import ProductGridItems from 'components/layout/product-grid-items'; import ProductGridItems from 'components/layout/product-grid-items';
import { defaultSort, sorting } from 'lib/constants'; import { defaultSort, sorting } from 'lib/constants';
export async function generateMetadata(props: { export async function generateMetadata({
params: Promise<{ collection: string }>; params
}: {
params: { collection: string };
}): Promise<Metadata> { }): Promise<Metadata> {
const params = await props.params;
const collection = await getCollection(params.collection); const collection = await getCollection(params.collection);
if (!collection) return notFound(); if (!collection) return notFound();
@@ -21,12 +22,13 @@ export async function generateMetadata(props: {
}; };
} }
export default async function CategoryPage(props: { export default async function CategoryPage({
params: Promise<{ collection: string }>; params,
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; searchParams
}: {
params: { collection: string };
searchParams?: { [key: string]: string | string[] | undefined };
}) { }) {
const searchParams = await props.searchParams;
const params = await props.params;
const { sort } = searchParams as { [key: string]: string }; const { sort } = searchParams as { [key: string]: string };
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort; const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse }); const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });

View File

@@ -1,9 +1,7 @@
'use client'; 'use client';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { Fragment } from 'react'; import { Fragment } from 'react';
// Ensure children are re-rendered when the search query changes
export default function ChildrenWrapper({ children }: { children: React.ReactNode }) { export default function ChildrenWrapper({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
return <Fragment key={searchParams.get('q')}>{children}</Fragment>; return <Fragment key={searchParams.get('q')}>{children}</Fragment>;

View File

@@ -2,17 +2,14 @@ import Grid from 'components/grid';
export default function Loading() { export default function Loading() {
return ( return (
<> <Grid className="grid-cols-2 lg:grid-cols-3">
<div className="mb-4 h-6" /> {Array(12)
<Grid className="grid-cols-2 lg:grid-cols-3"> .fill(0)
{Array(12) .map((_, index) => {
.fill(0) return (
.map((_, index) => { <Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-800" />
return ( );
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-800" /> })}
); </Grid>
})}
</Grid>
</>
); );
} }

View File

@@ -8,10 +8,11 @@ export const metadata = {
description: 'Search for products in the store.' description: 'Search for products in the store.'
}; };
export default async function SearchPage(props: { export default async function SearchPage({
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; searchParams
}: {
searchParams?: { [key: string]: string | string[] | undefined };
}) { }) {
const searchParams = await props.searchParams;
const { sort, q: searchValue } = searchParams as { [key: string]: string }; const { sort, q: searchValue } = searchParams as { [key: string]: string };
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort; const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;

View File

@@ -7,7 +7,7 @@ import { cookies } from 'next/headers';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
export async function addItem(prevState: any, selectedVariantId: string | undefined) { export async function addItem(prevState: any, selectedVariantId: string | undefined) {
let cartId = (await cookies()).get('cartId')?.value; let cartId = cookies().get('cartId')?.value;
if (!cartId || !selectedVariantId) { if (!cartId || !selectedVariantId) {
return 'Error adding item to cart'; return 'Error adding item to cart';
@@ -22,7 +22,7 @@ export async function addItem(prevState: any, selectedVariantId: string | undefi
} }
export async function removeItem(prevState: any, merchandiseId: string) { export async function removeItem(prevState: any, merchandiseId: string) {
let cartId = (await cookies()).get('cartId')?.value; let cartId = cookies().get('cartId')?.value;
if (!cartId) { if (!cartId) {
return 'Missing cart ID'; return 'Missing cart ID';
@@ -55,7 +55,7 @@ export async function updateItemQuantity(
quantity: number; quantity: number;
} }
) { ) {
let cartId = (await cookies()).get('cartId')?.value; let cartId = cookies().get('cartId')?.value;
if (!cartId) { if (!cartId) {
return 'Missing cart ID'; return 'Missing cart ID';
@@ -97,7 +97,7 @@ export async function updateItemQuantity(
} }
export async function redirectToCheckout() { export async function redirectToCheckout() {
let cartId = (await cookies()).get('cartId')?.value; let cartId = cookies().get('cartId')?.value;
if (!cartId) { if (!cartId) {
return 'Missing cart ID'; return 'Missing cart ID';
@@ -114,5 +114,5 @@ export async function redirectToCheckout() {
export async function createCartAndSetCookie() { export async function createCartAndSetCookie() {
let cart = await createCart(); let cart = await createCart();
(await cookies()).set('cartId', cart.id!); cookies().set('cartId', cart.id!);
} }

View File

@@ -5,7 +5,7 @@ import clsx from 'clsx';
import { addItem } from 'components/cart/actions'; import { addItem } from 'components/cart/actions';
import { useProduct } from 'components/product/product-context'; import { useProduct } from 'components/product/product-context';
import { Product, ProductVariant } from 'lib/shopify/types'; import { Product, ProductVariant } from 'lib/shopify/types';
import { useActionState } from 'react'; import { useFormState } from 'react-dom';
import { useCart } from './cart-context'; import { useCart } from './cart-context';
function SubmitButton({ function SubmitButton({
@@ -62,7 +62,7 @@ export function AddToCart({ product }: { product: Product }) {
const { variants, availableForSale } = product; const { variants, availableForSale } = product;
const { addCartItem } = useCart(); const { addCartItem } = useCart();
const { state } = useProduct(); const { state } = useProduct();
const [message, formAction] = useActionState(addItem, null); const [message, formAction] = useFormState(addItem, null);
const variant = variants.find((variant: ProductVariant) => 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()])

View File

@@ -3,7 +3,7 @@
import { XMarkIcon } from '@heroicons/react/24/outline'; import { XMarkIcon } from '@heroicons/react/24/outline';
import { removeItem } from 'components/cart/actions'; import { removeItem } from 'components/cart/actions';
import type { CartItem } from 'lib/shopify/types'; import type { CartItem } from 'lib/shopify/types';
import { useActionState } from 'react'; import { useFormState } from 'react-dom';
export function DeleteItemButton({ export function DeleteItemButton({
item, item,
@@ -12,7 +12,7 @@ export function DeleteItemButton({
item: CartItem; item: CartItem;
optimisticUpdate: any; optimisticUpdate: any;
}) { }) {
const [message, formAction] = useActionState(removeItem, null); const [message, formAction] = useFormState(removeItem, null);
const merchandiseId = item.merchandise.id; const merchandiseId = item.merchandise.id;
const actionWithVariant = formAction.bind(null, merchandiseId); const actionWithVariant = formAction.bind(null, merchandiseId);

View File

@@ -4,7 +4,7 @@ import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { updateItemQuantity } from 'components/cart/actions'; import { updateItemQuantity } from 'components/cart/actions';
import type { CartItem } from 'lib/shopify/types'; import type { CartItem } from 'lib/shopify/types';
import { useActionState } from 'react'; import { useFormState } from 'react-dom';
function SubmitButton({ type }: { type: 'plus' | 'minus' }) { function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
return ( return (
@@ -36,7 +36,7 @@ export function EditItemQuantityButton({
type: 'plus' | 'minus'; type: 'plus' | 'minus';
optimisticUpdate: any; optimisticUpdate: any;
}) { }) {
const [message, formAction] = useActionState(updateItemQuantity, null); const [message, formAction] = useFormState(updateItemQuantity, null);
const payload = { const payload = {
merchandiseId: item.merchandise.id, merchandiseId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1 quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1

View File

@@ -37,7 +37,7 @@ export async function Navbar() {
<li key={item.title}> <li key={item.title}>
<Link <Link
href={item.path} href={item.path}
prefetch={true} prefetch={item.path === '/search' ? undefined : true}
className="text-neutral-500 underline-offset-4 hover:text-black hover:underline dark:text-neutral-400 dark:hover:text-neutral-300" className="text-neutral-500 underline-offset-4 hover:text-black hover:underline dark:text-neutral-400 dark:hover:text-neutral-300"
> >
{item.title} {item.title}

View File

@@ -1,13 +1,19 @@
import clsx from 'clsx'; import clsx from 'clsx';
import type { FunctionComponent } from 'react';
const Prose = ({ html, className }: { html: string; className?: string }) => { interface TextProps {
html: string;
className?: string;
}
const Prose: FunctionComponent<TextProps> = ({ html, className }) => {
return ( return (
<div <div
className={clsx( 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 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',
className className
)} )}
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html as string }}
/> />
); );
}; };

View File

@@ -122,7 +122,7 @@ const reshapeCart = (cart: ShopifyCart): Cart => {
if (!cart.cost?.totalTaxAmount) { if (!cart.cost?.totalTaxAmount) {
cart.cost.totalTaxAmount = { cart.cost.totalTaxAmount = {
amount: '0.0', amount: '0.0',
currencyCode: cart.cost.totalAmount.currencyCode currencyCode: 'USD'
}; };
} }
@@ -428,14 +428,14 @@ export async function revalidate(req: NextRequest): Promise<NextResponse> {
// otherwise it will continue to retry the request. // otherwise it will continue to retry the request.
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update']; const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
const productWebhooks = ['products/create', 'products/delete', 'products/update']; const productWebhooks = ['products/create', 'products/delete', 'products/update'];
const topic = (await headers()).get('x-shopify-topic') || 'unknown'; const topic = headers().get('x-shopify-topic') || 'unknown';
const secret = req.nextUrl.searchParams.get('secret'); const secret = req.nextUrl.searchParams.get('secret');
const isCollectionUpdate = collectionWebhooks.includes(topic); const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic); const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) { if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
console.error('Invalid revalidation secret.'); console.error('Invalid revalidation secret.');
return NextResponse.json({ status: 401 }); return NextResponse.json({ status: 200 });
} }
if (!isCollectionUpdate && !isProductUpdate) { if (!isCollectionUpdate && !isProductUpdate) {

View File

@@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2024 Vercel, Inc. Copyright (c) 2023 Vercel, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,8 +1,5 @@
export default { /** @type {import('next').NextConfig} */
experimental: { module.exports = {
ppr: true,
inlineCss: true
},
images: { images: {
formats: ['image/avif', 'image/webp'], formats: ['image/avif', 'image/webp'],
remotePatterns: [ remotePatterns: [

View File

@@ -13,32 +13,26 @@
"test": "pnpm prettier:check" "test": "pnpm prettier:check"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.1.2",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.1.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"geist": "^1.3.1", "geist": "^1.3.1",
"next": "15.0.4-canary.22", "next": "https://vercel-packages.vercel.app/next/prs/68340/next",
"react": "19.0.0-rc-cd22717c-20241013", "react": "19.0.0-rc-3208e73e-20240730",
"react-dom": "19.0.0-rc-cd22717c-20241013", "react-dom": "19.0.0-rc-3208e73e-20240730",
"sonner": "^1.7.0" "sonner": "^1.5.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.13",
"@types/node": "22.9.1", "@types/node": "20.14.12",
"@types/react": "npm:types-react@19.0.0-rc.1", "@types/react": "18.3.3",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1", "@types/react-dom": "18.3.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.19",
"postcss": "^8.4.49", "postcss": "^8.4.39",
"prettier": "3.3.3", "prettier": "3.3.3",
"prettier-plugin-tailwindcss": "^0.6.9", "prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.15", "tailwindcss": "^3.4.6",
"typescript": "5.6.3" "typescript": "5.5.4"
},
"pnpm": {
"overrides": {
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1"
}
} }
} }

925
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -1,7 +0,0 @@
/** @type {import('postcss-load-config').Config} */
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -1,7 +1,7 @@
import type { Config } from 'tailwindcss'; const plugin = require('tailwindcss/plugin');
import plugin from 'tailwindcss/plugin';
const config: Config = { /** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'], content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
theme: { theme: {
extend: { extend: {
@@ -10,17 +10,17 @@ const config: Config = {
}, },
keyframes: { keyframes: {
fadeIn: { fadeIn: {
from: { opacity: '0' }, from: { opacity: 0 },
to: { opacity: '1' } to: { opacity: 1 }
}, },
marquee: { marquee: {
'0%': { transform: 'translateX(0%)' }, '0%': { transform: 'translateX(0%)' },
'100%': { transform: 'translateX(-100%)' } '100%': { transform: 'translateX(-100%)' }
}, },
blink: { blink: {
'0%': { opacity: '0.2' }, '0%': { opacity: 0.2 },
'20%': { opacity: '1' }, '20%': { opacity: 1 },
'100%': { opacity: '0.2' } '100% ': { opacity: 0.2 }
} }
}, },
animation: { animation: {
@@ -52,5 +52,3 @@ const config: Config = {
}) })
] ]
}; };
export default config;