Next.js Commerce refresh. (#966)

We're making some updates to Next.js Commerce. Everything prior to this commit marks what we're calling [`v1`](https://github.com/vercel/commerce/releases/tag/v1) as a point in time to be able to reference and still use going into the future. The current architecture of Commerce is a multi-vendor, interoperable solution, including:

- [Shopify](https://shopify.vercel.store/)
- [Swell](https://swell.vercel.store/)
- [BigCommerce](https://bigcommerce.vercel.store/)
- [Vendure](https://vendure.vercel.store/)
- [Saleor](https://saleor.vercel.store/)
- [Ordercloud](https://ordercloud.vercel.store/)
- [Spree](https://spree.vercel.store/)
- [Kibo Commerce](https://kibocommerce.vercel.store/)
- [Commerce.js](https://commercejs.vercel.store/)
- [SalesForce Cloud Commerce](https://salesforce-cloud-commerce.vercel.store/)

All features can be toggled on or off, and it's easy to change between commerce providers. To support this, we needed to create a ["commerce metaframework"](d1d9e8c434/packages/commerce/new-provider.md) where providers could confirm to an API spec to add support for Next.js Commerce. While this worked and was successful for `v1`, we have different design goals and ambitions for `v2`.

**What You Need To Know**

- `v1` will not be updated moving forward. If you need to reference `v1`, you will still be able to clone and deploy the version tagged at this release.
- `v2` will be shifting to be a single provider vs. provider agnostic. Other providers are welcome to fork this repository and swap out the underlying `lib/` implementation that connects to the selected commerce provider (Shopify). This architecture was chosen to reduce the surface area of the codebase, remove the intermediate metaframework layer for provider-interoperability, and enable usage with the latest Next.js and React features.
- We will be sharing more about `v2` in the future as we continue to iterate before the marked release.
This commit is contained in:
Lee Robinson
2023-04-17 23:00:47 -04:00
committed by GitHub
parent d1d9e8c434
commit fd9450aecb
1288 changed files with 4997 additions and 148456 deletions

View File

@@ -0,0 +1,65 @@
import Link from 'next/link';
import GitHubIcon from 'components/icons/github';
import LogoIcon from 'components/icons/logo';
import VercelIcon from 'components/icons/vercel';
import { SITE_NAME } from 'lib/constants';
import { getMenu } from 'lib/shopify';
import { Menu } from 'lib/shopify/types';
export default async function Footer() {
const menu = await getMenu('next-js-frontend-footer-menu');
return (
<footer className="border-t border-gray-700 bg-white text-black dark:bg-black dark:text-white">
<div className="mx-auto w-full max-w-7xl px-6">
<div className="grid grid-cols-1 gap-8 border-b border-gray-700 py-12 transition-colors duration-150 lg:grid-cols-12">
<div className="col-span-1 lg:col-span-3">
<a className="flex flex-initial items-center font-bold md:mr-24" href="/">
<span className="mr-2">
<LogoIcon className="h-8" />
</span>
<span>{SITE_NAME}</span>
</a>
</div>
{menu.length ? (
<nav className="col-span-1 lg:col-span-7">
<ul className="grid md:grid-flow-col md:grid-cols-3 md:grid-rows-4">
{menu.map((item: Menu) => (
<li key={item.title} className="py-3 md:py-0 md:pb-4">
<Link
href={item.path}
className="text-gray-800 transition duration-150 ease-in-out hover:text-gray-300 dark:text-gray-100"
>
{item.title}
</Link>
</li>
))}
</ul>
</nav>
) : null}
<div className="col-span-1 text-black dark:text-white lg:col-span-2">
<a aria-label="Github Repository" href="https://github.com/vercel/commerce">
<GitHubIcon className="h-6" />
</a>
</div>
</div>
<div className="flex flex-col items-center justify-between space-y-4 pt-6 pb-10 text-sm md:flex-row">
<p>&copy; 2023 {SITE_NAME}. All rights reserved.</p>
<div className="flex items-center text-sm text-white dark:text-black">
<span className="text-black dark:text-white">Created by</span>
<a
rel="noopener noreferrer"
href="https://vercel.com"
aria-label="Vercel.com Link"
target="_blank"
className="text-black dark:text-white"
>
<VercelIcon className="ml-3 inline-block h-6" />
</a>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,53 @@
import Link from 'next/link';
import { Suspense } from 'react';
import Cart from 'components/cart';
import CartIcon from 'components/icons/cart';
import LogoIcon from 'components/icons/logo';
import { getMenu } from 'lib/shopify';
import { Menu } from 'lib/shopify/types';
import MobileMenu from './mobile-menu';
import Search from './search';
export default async function Navbar() {
const menu = await getMenu('next-js-frontend-header-menu');
return (
<nav className="relative flex items-center justify-between bg-white p-4 dark:bg-black lg:px-6">
<div className="block w-1/3 md:hidden">
<MobileMenu menu={menu} />
</div>
<div className="flex justify-self-center md:w-1/3 md:justify-self-start">
<div className="md:mr-4">
<Link href="/" aria-label="Go back home">
<LogoIcon className="h-8 transition-transform hover:scale-110" />
</Link>
</div>
{menu.length ? (
<ul className="hidden md:flex">
{menu.map((item: Menu) => (
<li key={item.title}>
<Link
href={item.path}
className="rounded-lg px-2 py-1 text-gray-800 hover:text-gray-500 dark:text-gray-200 dark:hover:text-gray-400"
>
{item.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
<div className="hidden w-1/3 md:block">
<Search />
</div>
<div className="flex w-1/3 justify-end">
<Suspense fallback={<CartIcon className="h-6" />}>
{/* @ts-expect-error Server Component */}
<Cart />
</Suspense>
</div>
</nav>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import { Dialog } from '@headlessui/react';
import { motion } from 'framer-motion';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import CloseIcon from 'components/icons/close';
import MenuIcon from 'components/icons/menu';
import { Menu } from 'lib/shopify/types';
import Search from './search';
export default function MobileMenu({ menu }: { menu: Menu[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [mobileMenuIsOpen, setMobileMenuIsOpen] = useState(false);
useEffect(() => {
const handleResize = () => {
if (window.innerWidth > 768) {
setMobileMenuIsOpen(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [mobileMenuIsOpen]);
useEffect(() => {
setMobileMenuIsOpen(false);
}, [pathname, searchParams]);
return (
<>
<button
onClick={() => {
setMobileMenuIsOpen(!mobileMenuIsOpen);
}}
aria-label="Open mobile menu"
className="md:hidden"
data-testid="open-mobile-menu"
>
<MenuIcon className="h-6" />
</button>
<Dialog
open={mobileMenuIsOpen}
onClose={() => {
setMobileMenuIsOpen(false);
}}
className="relative z-50"
>
<div className="fixed inset-0 flex justify-end" data-testid="mobile-menu">
<Dialog.Panel
as={motion.div}
variants={{
open: { opacity: 1 }
}}
className="flex w-full flex-col bg-white pb-6 dark:bg-black"
>
<div className="p-4">
<button
className="mb-4"
onClick={() => {
setMobileMenuIsOpen(false);
}}
aria-label="Close mobile menu"
data-testid="close-mobile-menu"
>
<CloseIcon className="h-6" />
</button>
<div className="mb-4 w-full">
<Search />
</div>
{menu.length ? (
<ul className="flex flex-col">
{menu.map((item: Menu) => (
<li key={item.title}>
<Link
href={item.path}
className="rounded-lg py-1 text-xl text-black transition-colors hover:text-gray-500 dark:text-white"
onClick={() => {
setMobileMenuIsOpen(false);
}}
>
{item.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
</Dialog.Panel>
</div>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import SearchIcon from 'components/icons/search';
export default function Search() {
const router = useRouter();
const searchParams = useSearchParams();
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const val = e.target as HTMLFormElement;
const search = val.search as HTMLInputElement;
if (search.value) {
router.push(`/search?q=${search.value}`);
} else {
router.push(`/search`);
}
}
return (
<form
onSubmit={onSubmit}
className="relative m-0 flex w-full items-center border border-gray-200 bg-transparent p-0 dark:border-gray-500"
>
<input
type="text"
name="search"
placeholder="Search for products..."
autoComplete="off"
defaultValue={searchParams?.get('q') || ''}
className="w-full py-2 px-4 text-black dark:bg-black dark:text-gray-100"
/>
<div className="absolute top-0 right-0 mr-3 flex h-full items-center">
<SearchIcon className="h-5" />
</div>
</form>
);
}

View File

@@ -0,0 +1,38 @@
import clsx from 'clsx';
import { Suspense } from 'react';
import { getCollections } from 'lib/shopify';
import FilterList from './filter';
async function CollectionList() {
const collections = await getCollections();
return <FilterList list={collections} title="Collections" />;
}
const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded';
const activeAndTitles = 'bg-gray-800 dark:bg-gray-300';
const items = 'bg-gray-400 dark:bg-gray-700';
export default function Collections() {
return (
<Suspense
fallback={
<div className="col-span-2 hidden h-[400px] w-full flex-none py-4 pl-10 lg:block">
<div className={clsx(skeleton, activeAndTitles)} />
<div className={clsx(skeleton, activeAndTitles)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
</div>
}
>
{/* @ts-expect-error Server Component */}
<CollectionList />
</Suspense>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import Caret from 'components/icons/caret-right';
import type { ListItem } from '.';
import { FilterItem } from './item';
export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [active, setActive] = useState('');
const [openSelect, setOpenSelect] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpenSelect(false);
}
};
window.addEventListener('click', handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside);
}, []);
useEffect(() => {
list.forEach((listItem: ListItem) => {
if (
('path' in listItem && pathname === listItem.path) ||
('slug' in listItem && searchParams.get('sort') === listItem.slug)
) {
setActive(listItem.title);
}
});
}, [pathname, list, searchParams]);
return (
<div className="relative" ref={ref}>
<div
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"
>
<div>{active}</div>
<Caret className="h-4 rotate-90" />
</div>
{openSelect && (
<div
onClick={() => {
setOpenSelect(false);
}}
className="absolute z-40 w-full rounded-b-md bg-white p-4 shadow-md dark:bg-black"
>
{list.map((item: ListItem, i) => (
<FilterItem key={i} item={item} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { SortFilterItem } from 'lib/constants';
import FilterItemDropdown from './dropdown';
import { FilterItem } from './item';
export type ListItem = SortFilterItem | PathFilterItem;
export type PathFilterItem = { title: string; path: string };
function FilterItemList({ list }: { list: ListItem[] }) {
return (
<div className="hidden md:block">
{list.map((item: ListItem, i) => (
<FilterItem key={i} item={item} />
))}
</div>
);
}
export default function FilterList({ list, title }: { list: ListItem[]; title?: string }) {
return (
<>
<nav className="col-span-2 w-full flex-none px-6 py-2 md:py-4 md:pl-10">
{title ? (
<h3 className="hidden font-semibold text-black dark:text-white md:block">{title}</h3>
) : null}
<ul className="hidden md:block">
<FilterItemList list={list} />
</ul>
<ul className="md:hidden">
<FilterItemDropdown list={list} />
</ul>
</nav>
</>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import clsx from 'clsx';
import { SortFilterItem } from 'lib/constants';
import { createUrl } from 'lib/utils';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import type { ListItem, PathFilterItem } from '.';
function PathFilterItem({ item }: { item: PathFilterItem }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [active, setActive] = useState(pathname === item.path);
useEffect(() => {
setActive(pathname === item.path);
}, [pathname, item.path]);
return (
<li className="mt-2 flex text-sm text-gray-400" key={item.title}>
<Link
href={createUrl(item.path, searchParams)}
className={clsx('w-full hover:text-gray-800 dark:hover:text-gray-100', {
'text-gray-600 dark:text-gray-400': !active,
'font-semibold text-black dark:text-white': active
})}
>
{item.title}
</Link>
</li>
);
}
function SortFilterItem({ item }: { item: SortFilterItem }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [active, setActive] = useState(searchParams.get('sort') === item.slug);
useEffect(() => {
setActive(searchParams.get('sort') === item.slug);
}, [searchParams, item.slug]);
const href =
item.slug && item.slug.length
? createUrl(pathname, new URLSearchParams({ sort: item.slug }))
: pathname;
return (
<li className="mt-2 flex text-sm text-gray-400" key={item.title}>
<Link
prefetch={false}
href={href}
className={clsx('w-full hover:text-gray-800 dark:hover:text-gray-100', {
'text-gray-600 dark:text-gray-400': !active,
'font-semibold text-black dark:text-white': active
})}
>
{item.title}
</Link>
</li>
);
}
export function FilterItem({ item }: { item: ListItem }) {
return 'path' in item ? <PathFilterItem item={item} /> : <SortFilterItem item={item} />;
}

View File

@@ -0,0 +1,33 @@
import Grid from 'components/grid';
import { GridTileImage } from 'components/grid/tile';
import { Product } from 'lib/shopify/types';
import Link from 'next/link';
export default async function SearchResults({ products }: { products: Product[] }) {
return (
<>
{products.length ? (
<Grid className="grid-cols-2 lg:grid-cols-3">
{products.map((product) => (
<Grid.Item key={product.handle} className="animate-fadeIn">
<Link className="h-full w-full" href={`/product/${product.handle}`}>
<GridTileImage
alt={product.title}
labels={{
isSmall: true,
title: product.title,
amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode
}}
src={product.featuredImage.url}
width={600}
height={600}
/>
</Link>
</Grid.Item>
))}
</Grid>
) : null}
</>
);
}