31 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
Lee Robinson
84224f8d7e Fix bug with disabled state 2024-07-29 14:22:06 -05:00
Lee Robinson
94b85fca6f Update README 2024-07-28 23:28:35 -05:00
Lee Robinson
37cb5e38da Small cleanup. 2024-07-28 23:26:03 -05:00
Lee Robinson
9a4c995bb6 Make image, variant, and cart updates faster with useOptimistic (#1365) 2024-07-28 22:58:59 -05:00
Lee Robinson
dd7449f975 Make deleting optimistic too. 2024-07-25 17:23:22 -05:00
Lee Robinson
cea56f608b Fix bugs with optimistic. 2024-07-25 17:15:50 -05:00
Lee Robinson
0ebf071826 Optimistic cart (#1364) 2024-07-25 13:56:53 -05:00
Vincent Voyer
d7a4f3dc46 feat(design): Show carousel above the fold on desktop (#1363)
* feat(design): Show carousel above the fold on desktop

Before this commit, we would not see the carousel without scrolling. The top
images are so big that take most of the space. This made the website looked a
bit weird, thus I am proposing this change.

* uneeded
2024-07-25 11:24:49 -05:00
Lee Robinson
ec21369389 Update dependencies. (#1361) 2024-07-24 14:05:34 -05:00
Harry Buisman
7c1b34abdb Remove any type and make removeEdgesAndNodes generic (#1353) 2024-07-14 12:20:15 -05:00
JustinApt
7fd9ad8a8c Update README.md (#1339)
The current URL to the Shopify Integration Guide is http://vercel.com/docs/integrations/shopify, which redirects to https://vercel.com/docs/integrations/cms/shopify and shows a 404. The correct URL that holds the same content now seems to be on https://vercel.com/docs/integrations/ecommerce/shopify, which is updated in this commit.
2024-05-26 17:17:37 -05:00
Ilnur Basyrov
42d5d8efcf Adds Ecwid by Lightspeed to providers section (#1304)
Co-authored-by: Michael Novotny <manovotny@gmail.com>
2024-05-07 09:31:40 -04:00
Elbert Corniell
a5de9173e8 fix: disabled button classes when selectedVariantId is defined are not being set (#1333) 2024-05-04 14:27:59 -05:00
Lee Robinson
887d437795 Prepare for using PPR (#1236) 2024-04-17 21:54:09 -05:00
Lee Robinson
610b0e8692 Remove stray revalidate 2024-03-31 08:50:39 -05:00
Lee Robinson
25ddc5e643 Update dependencies. (#1314) 2024-03-26 16:15:01 -05:00
Alberto Benatti
3a18f9a098 Update next.js version to 14 inside README.md (#1243) 2023-11-15 08:48:11 -06:00
Benedikt Jónsson
2fe1527bea Use GitHub actions concurrency to cancel in progress workflows on PRs (#1232) 2023-11-10 13:46:08 -06:00
Lee Robinson
2448f5201c Upgrade to Next.js 14 (#1224) 2023-10-27 14:11:18 -05:00
Michele Riva
80e48001d9 docs: adds Orama integration into the readme file (#1221) 2023-10-26 21:17:58 -05:00
Lee Robinson
1f47796529 Improves form submissions and updates dependencies (#1209) 2023-10-10 21:45:55 -05:00
dependabot[bot]
ece49c4265 Bump styfle/cancel-workflow-action from 0.11.0 to 0.12.0 (#1217) 2023-10-10 21:31:00 -05:00
Tucker Massad
2035fa0431 Fixes pnpm typo in README (#1215) 2023-10-06 09:28:14 -05:00
Michael Novotny
5cb1245432 Adds environment variable validation (#1198)
* Adds environment variable validation

* Adds bracket checking in SHOPIFY_STORE_DOMAIN

* Prettier

* Adds link

---------

Co-authored-by: Lee Robinson <lrobinson2011@gmail.com>
2023-10-02 10:18:56 -05:00
Michael Novotny
d9f875b539 Removes unnecessary search state (#1201) 2023-09-18 15:25:03 -05:00
Netanel Gilad
b0f6e94fba Adds Wix provider (#1195) 2023-09-18 09:56:16 -05:00
dependabot[bot]
d8703e8140 Bump actions/checkout from 3 to 4 (#1188) 2023-09-04 11:41:34 -05:00
57 changed files with 2293 additions and 3658 deletions

View File

@@ -1,23 +0,0 @@
module.exports = {
extends: ['next', 'prettier'],
plugins: ['unicorn'],
rules: {
'no-unused-vars': [
'error',
{
args: 'after-used',
caughtErrors: 'none',
ignoreRestSiblings: true,
vars: 'all'
}
],
'prefer-const': 'error',
'react-hooks/exhaustive-deps': 'error',
'unicorn/filename-case': [
'error',
{
case: 'kebabCase'
}
]
}
};

View File

@@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'weekly'

View File

@@ -1,32 +0,0 @@
name: test
on: pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Cancel running workflows
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Checkout repo
uses: actions/checkout@v3
- name: Set node version
uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
- name: Set pnpm version
uses: pnpm/action-setup@v2
with:
run_install: false
version: 7
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
run: pnpm install
- name: Run tests
run: pnpm test

1
.nvmrc
View File

@@ -1 +0,0 @@
18

View File

@@ -1,3 +0,0 @@
.vercel
.next
pnpm-lock.yaml

View File

@@ -2,8 +2,8 @@
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.codeActionsOnSave": {
"source.fixAll": true,
"source.organizeImports": true,
"source.sortMembers": true
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

View File

@@ -2,18 +2,9 @@
# Next.js Commerce
A Next.js 13 and App Router-ready ecommerce template featuring:
A high-perfomance, server-rendered Next.js App Router ecommerce application.
- Next.js App Router
- Optimized for SEO using Next.js's Metadata
- React Server Components (RSCs) and Suspense
- Server Actions for mutations
- Edge Runtime
- New fetching and caching paradigms
- Dynamic OG images
- Styling with Tailwind CSS
- Checkout and payments with Shopify
- Automatic light/dark mode based on system settings
This template uses React Server Components, Server Actions, `Suspense`, `useOptimistic`, and more.
<h3 id="v1-note"></h3>
@@ -27,14 +18,24 @@ Vercel is happy to partner and work with any commerce provider to help them get
- Shopify (this repository)
- [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/))
- [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
- [Shopware](https://github.com/shopwareLabs/vercel-commerce) ([Demo](https://shopware-vercel-commerce-react.vercel.app/))
- [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
- [Umbraco](https://github.com/umbraco/Umbraco.VercelCommerce.Demo) ([Demo](https://vercel-commerce-demo.umbraco.com/))
- [Wix](https://github.com/wix/nextjs-commerce) ([Demo](https://wix-nextjs-commerce.vercel.app/))
> Note: Providers, if you are looking to use similar products for your demo, you can [download these assets](https://drive.google.com/file/d/1q_bKerjrwZgHwCw0ovfUMW6He9VtepO_/view?usp=sharing).
## Integrations
Integrations enable upgraded or additional functionality for Next.js Commerce
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
- Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
- Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
## 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.
@@ -59,9 +60,9 @@ Your app should now be running on [localhost:3000](http://localhost:3000/).
1. Select the `Vercel Solutions` scope.
1. Connect to the existing `commerce-shopify` project.
1. Run `vc env pull` to get environment variables.
1. Run `pmpm dev` to ensure everything is working correctly.
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](http://vercel.com/docs/integrations/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.
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.

View File

@@ -1,15 +1,12 @@
import Footer from 'components/layout/footer';
import { Suspense } from 'react';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<Suspense>
<>
<div className="w-full">
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">
<Suspense>{children}</Suspense>
</div>
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">{children}</div>
</div>
<Footer />
</Suspense>
</>
);
}

View File

@@ -4,10 +4,6 @@ import Prose from 'components/prose';
import { getPage } from 'lib/shopify';
import { notFound } from 'next/navigation';
export const runtime = 'edge';
export const revalidate = 43200; // 12 hours in seconds
export async function generateMetadata({
params
}: {

View File

@@ -1,8 +1,6 @@
import { revalidate } from 'lib/shopify';
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'edge';
export async function POST(req: NextRequest): Promise<NextResponse> {
return revalidate(req);
}

View File

@@ -2,7 +2,7 @@
export default function Error({ reset }: { reset: () => void }) {
return (
<div className="mx-auto my-4 flex max-w-xl flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12">
<div className="mx-auto my-4 flex max-w-xl flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 dark:border-neutral-800 dark:bg-black">
<h2 className="text-xl font-bold">Oh no!</h2>
<p className="my-2">
There was an issue with our storefront. This could be a temporary issue, please try your

View File

@@ -1,7 +1,12 @@
import Navbar from 'components/layout/navbar';
import { CartProvider } from 'components/cart/cart-context';
import { Navbar } from 'components/layout/navbar';
import { WelcomeToast } from 'components/welcome-toast';
import { GeistSans } from 'geist/font/sans';
import { getCart } from 'lib/shopify';
import { ensureStartsWith } from 'lib/utils';
import { Inter } from 'next/font/google';
import { ReactNode, Suspense } from 'react';
import { cookies } from 'next/headers';
import { ReactNode } from 'react';
import { Toaster } from 'sonner';
import './globals.css';
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
@@ -31,20 +36,22 @@ export const metadata = {
})
};
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter'
});
export default async function RootLayout({ children }: { children: ReactNode }) {
const cartId = cookies().get('cartId')?.value;
// Don't await the fetch, pass the Promise to the context provider
const cart = getCart(cartId);
return (
<html lang="en" className={inter.variable}>
<html lang="en" className={GeistSans.variable}>
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
<Navbar />
<Suspense>
<main>{children}</main>
</Suspense>
<CartProvider cartPromise={cart}>
<Navbar />
<main>
{children}
<Toaster closeButton />
<WelcomeToast />
</main>
</CartProvider>
</body>
</html>
);

View File

@@ -1,7 +1,6 @@
import { Carousel } from 'components/carousel';
import { ThreeItemGrid } from 'components/grid/three-items';
import Footer from 'components/layout/footer';
import { Suspense } from 'react';
export const metadata = {
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
@@ -10,16 +9,12 @@ export const metadata = {
}
};
export default async function HomePage() {
export default function HomePage() {
return (
<>
<ThreeItemGrid />
<Suspense>
<Carousel />
<Suspense>
<Footer />
</Suspense>
</Suspense>
<Carousel />
<Footer />
</>
);
}

View File

@@ -1,17 +1,16 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { Suspense } from 'react';
import { GridTileImage } from 'components/grid/tile';
import Footer from 'components/layout/footer';
import { Gallery } from 'components/product/gallery';
import { ProductProvider } from 'components/product/product-context';
import { ProductDescription } from 'components/product/product-description';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct, getProductRecommendations } from 'lib/shopify';
import { Image } from 'lib/shopify/types';
import Link from 'next/link';
export const runtime = 'edge';
import { Suspense } from 'react';
export async function generateMetadata({
params
@@ -74,7 +73,7 @@ export default async function ProductPage({ params }: { params: { handle: string
};
return (
<>
<ProductProvider>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
@@ -82,28 +81,32 @@ export default async function ProductPage({ params }: { params: { handle: string
}}
/>
<div className="mx-auto max-w-screen-2xl px-4">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row lg:gap-8">
<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">
<Gallery
images={product.images.map((image: Image) => ({
src: image.url,
altText: image.altText
}))}
/>
<Suspense
fallback={
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" />
}
>
<Gallery
images={product.images.slice(0, 5).map((image: Image) => ({
src: image.url,
altText: image.altText
}))}
/>
</Suspense>
</div>
<div className="basis-full lg:basis-2/6">
<ProductDescription product={product} />
<Suspense fallback={null}>
<ProductDescription product={product} />
</Suspense>
</div>
</div>
<Suspense>
<RelatedProducts id={product.id} />
</Suspense>
<RelatedProducts id={product.id} />
</div>
<Suspense>
<Footer />
</Suspense>
</>
<Footer />
</ProductProvider>
);
}
@@ -121,7 +124,11 @@ async function RelatedProducts({ id }: { id: string }) {
key={product.handle}
className="aspect-square w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
>
<Link className="relative h-full w-full" href={`/product/${product.handle}`}>
<Link
className="relative h-full w-full"
href={`/product/${product.handle}`}
prefetch={true}
>
<GridTileImage
alt={product.title}
label={{

View File

@@ -6,8 +6,6 @@ import Grid from 'components/grid';
import ProductGridItems from 'components/layout/product-grid-items';
import { defaultSort, sorting } from 'lib/constants';
export const runtime = 'edge';
export async function generateMetadata({
params
}: {

View File

@@ -0,0 +1,8 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { Fragment } from 'react';
export default function ChildrenWrapper({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams();
return <Fragment key={searchParams.get('q')}>{children}</Fragment>;
}

View File

@@ -2,21 +2,23 @@ import Footer from 'components/layout/footer';
import Collections from 'components/layout/search/collections';
import FilterList from 'components/layout/search/filter';
import { sorting } from 'lib/constants';
import { Suspense } from 'react';
import ChildrenWrapper from './children-wrapper';
export default function SearchLayout({ children }: { children: React.ReactNode }) {
return (
<Suspense>
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black dark:text-white md:flex-row">
<>
<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="order-first w-full flex-none md:max-w-[125px]">
<Collections />
</div>
<div className="order-last min-h-screen w-full md:order-none">{children}</div>
<div className="order-last min-h-screen w-full md:order-none">
<ChildrenWrapper>{children}</ChildrenWrapper>
</div>
<div className="order-none flex-none md:order-last md:w-[125px]">
<FilterList list={sorting} title="Sort by" />
</div>
</div>
<Footer />
</Suspense>
</>
);
}

View File

@@ -7,7 +7,7 @@ export default function Loading() {
.fill(0)
.map((_, index) => {
return (
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-900" />
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-800" />
);
})}
</Grid>

View File

@@ -3,8 +3,6 @@ import ProductGridItems from 'components/layout/product-grid-items';
import { defaultSort, sorting } from 'lib/constants';
import { getProducts } from 'lib/shopify';
export const runtime = 'edge';
export const metadata = {
title: 'Search',
description: 'Search for products in the store.'

View File

@@ -1,4 +1,5 @@
import { getCollections, getPages, getProducts } from 'lib/shopify';
import { validateEnvironmentVariables } from 'lib/utils';
import { MetadataRoute } from 'next';
type Route = {
@@ -10,7 +11,11 @@ const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000';
export const dynamic = 'force-dynamic';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
validateEnvironmentVariables();
const routesMap = [''].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date().toISOString()

View File

@@ -12,7 +12,7 @@ export async function Carousel() {
const carouselProducts = [...products, ...products, ...products];
return (
<div className=" w-full overflow-x-auto pb-6 pt-1">
<div className="w-full overflow-x-auto pb-6 pt-1">
<ul className="flex animate-carousel gap-4">
{carouselProducts.map((product, i) => (
<li

View File

@@ -1,69 +1,118 @@
'use server';
import { TAGS } from 'lib/constants';
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export const addItem = async (variantId: string | undefined): Promise<String | undefined> => {
export async function addItem(prevState: any, selectedVariantId: string | undefined) {
let cartId = cookies().get('cartId')?.value;
let cart;
if (cartId) {
cart = await getCart(cartId);
}
if (!cartId || !cart) {
cart = await createCart();
cartId = cart.id;
cookies().set('cartId', cartId);
}
if (!variantId) {
return 'Missing product variant ID';
if (!cartId || !selectedVariantId) {
return 'Error adding item to cart';
}
try {
await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]);
revalidateTag(TAGS.cart);
} catch (e) {
return 'Error adding item to cart';
}
};
}
export const removeItem = async (lineId: string): Promise<String | undefined> => {
const cartId = cookies().get('cartId')?.value;
export async function removeItem(prevState: any, merchandiseId: string) {
let cartId = cookies().get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
}
try {
await removeFromCart(cartId, [lineId]);
const cart = await getCart(cartId);
if (!cart) {
return 'Error fetching cart';
}
const lineItem = cart.lines.find((line) => line.merchandise.id === merchandiseId);
if (lineItem && lineItem.id) {
await removeFromCart(cartId, [lineItem.id]);
revalidateTag(TAGS.cart);
} else {
return 'Item not found in cart';
}
} catch (e) {
return 'Error removing item from cart';
}
};
}
export const updateItemQuantity = async ({
lineId,
variantId,
quantity
}: {
lineId: string;
variantId: string;
quantity: number;
}): Promise<String | undefined> => {
const cartId = cookies().get('cartId')?.value;
export async function updateItemQuantity(
prevState: any,
payload: {
merchandiseId: string;
quantity: number;
}
) {
let cartId = cookies().get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
}
const { merchandiseId, quantity } = payload;
try {
await updateCart(cartId, [
{
id: lineId,
merchandiseId: variantId,
quantity
const cart = await getCart(cartId);
if (!cart) {
return 'Error fetching cart';
}
const lineItem = cart.lines.find((line) => line.merchandise.id === merchandiseId);
if (lineItem && lineItem.id) {
if (quantity === 0) {
await removeFromCart(cartId, [lineItem.id]);
} else {
await updateCart(cartId, [
{
id: lineItem.id,
merchandiseId,
quantity
}
]);
}
]);
} else if (quantity > 0) {
// If the item doesn't exist in the cart and quantity > 0, add it
await addToCart(cartId, [{ merchandiseId, quantity }]);
}
revalidateTag(TAGS.cart);
} catch (e) {
console.error(e);
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);
}
export async function createCartAndSetCookie() {
let cart = await createCart();
cookies().set('cartId', cart.id!);
}

View File

@@ -3,66 +3,86 @@
import { PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { addItem } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import { ProductVariant } from 'lib/shopify/types';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
import { useProduct } from 'components/product/product-context';
import { Product, ProductVariant } from 'lib/shopify/types';
import { useFormState } from 'react-dom';
import { useCart } from './cart-context';
export function AddToCart({
variants,
availableForSale
function SubmitButton({
availableForSale,
selectedVariantId
}: {
variants: ProductVariant[];
availableForSale: boolean;
selectedVariantId: string | undefined;
}) {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
const variant = variants.find((variant: ProductVariant) =>
variant.selectedOptions.every(
(option) => option.value === searchParams.get(option.name.toLowerCase())
)
);
const selectedVariantId = variant?.id || defaultVariantId;
const title = !availableForSale
? 'Out of stock'
: !selectedVariantId
? 'Please select options'
: 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';
if (!availableForSale) {
return (
<button disabled className={clsx(buttonClasses, disabledClasses)}>
Out Of Stock
</button>
);
}
console.log(selectedVariantId);
if (!selectedVariantId) {
return (
<button
aria-label="Please select an option"
disabled
className={clsx(buttonClasses, disabledClasses)}
>
<div className="absolute left-0 ml-4">
<PlusIcon className="h-5" />
</div>
Add To Cart
</button>
);
}
return (
<button
aria-label="Add item to cart"
disabled={isPending || !availableForSale || !selectedVariantId}
title={title}
onClick={() => {
// Safeguard in case someone messes with `disabled` in devtools.
if (!availableForSale || !selectedVariantId) return;
startTransition(async () => {
const error = await addItem(selectedVariantId);
if (error) {
// Trigger the error boundary in the root error.js
throw new Error(error.toString());
}
router.refresh();
});
}}
className={clsx(
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90',
{
'cursor-not-allowed opacity-60 hover:opacity-60': !availableForSale || !selectedVariantId,
'cursor-not-allowed': isPending
}
)}
aria-label="Add to cart"
className={clsx(buttonClasses, {
'hover:opacity-90': true
})}
>
<div className="absolute left-0 ml-4">
{!isPending ? <PlusIcon className="h-5" /> : <LoadingDots className="mb-3 bg-white" />}
<PlusIcon className="h-5" />
</div>
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
Add To Cart
</button>
);
}
export function AddToCart({ product }: { product: Product }) {
const { variants, availableForSale } = product;
const { addCartItem } = useCart();
const { state } = useProduct();
const [message, formAction] = useFormState(addItem, null);
const variant = variants.find((variant: ProductVariant) =>
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)!;
return (
<form
action={async () => {
addCartItem(finalVariant, product);
await actionWithVariant();
}}
>
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import type { Cart, CartItem, Product, ProductVariant } from 'lib/shopify/types';
import React, { createContext, use, useContext, useMemo, useOptimistic } from 'react';
type UpdateType = 'plus' | 'minus' | 'delete';
type CartAction =
| { 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;
};
const CartContext = createContext<CartContextType | undefined>(undefined);
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;
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());
return {
...item,
quantity: newQuantity,
cost: {
...item.cost,
totalAmount: {
...item.cost.totalAmount,
amount: newTotalAmount
}
}
};
}
function createOrUpdateCartItem(
existingItem: CartItem | undefined,
variant: ProductVariant,
product: Product
): CartItem {
const quantity = existingItem ? existingItem.quantity + 1 : 1;
const totalAmount = calculateItemCost(quantity, variant.price.amount);
return {
id: existingItem?.id,
quantity,
cost: {
totalAmount: {
amount: totalAmount,
currencyCode: variant.price.currencyCode
}
},
merchandise: {
id: variant.id,
title: variant.title,
selectedOptions: variant.selectedOptions,
product: {
id: product.id,
handle: product.handle,
title: product.title,
featuredImage: product.featuredImage
}
}
};
}
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';
return {
totalQuantity,
cost: {
subtotalAmount: { amount: totalAmount.toString(), currencyCode },
totalAmount: { amount: totalAmount.toString(), currencyCode },
totalTaxAmount: { amount: '0', currencyCode }
}
};
}
function createEmptyCart(): Cart {
return {
id: undefined,
checkoutUrl: '',
totalQuantity: 0,
lines: [],
cost: {
subtotalAmount: { amount: '0', currencyCode: 'USD' },
totalAmount: { amount: '0', currencyCode: 'USD' },
totalTaxAmount: { amount: '0', currencyCode: 'USD' }
}
};
}
function cartReducer(state: Cart | undefined, action: CartAction): Cart {
const currentCart = state || createEmptyCart();
switch (action.type) {
case 'UPDATE_ITEM': {
const { merchandiseId, updateType } = action.payload;
const updatedLines = currentCart.lines
.map((item) =>
item.merchandise.id === merchandiseId ? updateCartItem(item, updateType) : item
)
.filter(Boolean) as CartItem[];
if (updatedLines.length === 0) {
return {
...currentCart,
lines: [],
totalQuantity: 0,
cost: {
...currentCart.cost,
totalAmount: { ...currentCart.cost.totalAmount, amount: '0' }
}
};
}
return { ...currentCart, ...updateCartTotals(updatedLines), lines: updatedLines };
}
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 updatedLines = existingItem
? currentCart.lines.map((item) => (item.merchandise.id === variant.id ? updatedItem : item))
: [...currentCart.lines, updatedItem];
return { ...currentCart, ...updateCartTotals(updatedLines), lines: updatedLines };
}
default:
return currentCart;
}
}
export function CartProvider({
children,
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={value}>{children}</CartContext.Provider>;
}
export function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}

View File

@@ -4,7 +4,7 @@ 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)} />
<XMarkIcon className={clsx('h-6 transition-all ease-in-out hover:scale-110', className)} />
</div>
);
}

View File

@@ -1,44 +1,38 @@
import { XMarkIcon } from '@heroicons/react/24/outline';
import LoadingDots from 'components/loading-dots';
import { useRouter } from 'next/navigation';
'use client';
import clsx from 'clsx';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { removeItem } from 'components/cart/actions';
import type { CartItem } from 'lib/shopify/types';
import { useTransition } from 'react';
import { useFormState } from 'react-dom';
export default function DeleteItemButton({ item }: { item: CartItem }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
export function DeleteItemButton({
item,
optimisticUpdate
}: {
item: CartItem;
optimisticUpdate: any;
}) {
const [message, formAction] = useFormState(removeItem, null);
const merchandiseId = item.merchandise.id;
const actionWithVariant = formAction.bind(null, merchandiseId);
return (
<button
aria-label="Remove cart item"
onClick={() => {
startTransition(async () => {
const error = await removeItem(item.id);
if (error) {
// Trigger the error boundary in the root error.js
throw new Error(error.toString());
}
router.refresh();
});
<form
action={async () => {
optimisticUpdate(merchandiseId, 'delete');
await actionWithVariant();
}}
disabled={isPending}
className={clsx(
'ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-neutral-500 transition-all duration-200',
{
'cursor-not-allowed px-0': isPending
}
)}
>
{isPending ? (
<LoadingDots className="bg-white" />
) : (
<XMarkIcon className="hover:text-accent-3 mx-[1px] h-4 w-4 text-white dark:text-black" />
)}
</button>
<button
type="submit"
aria-label="Remove cart item"
className="flex h-[24px] w-[24px] items-center justify-center rounded-full bg-neutral-500"
>
<XMarkIcon className="mx-[1px] h-4 w-4 text-white dark:text-black" />
</button>
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}

View File

@@ -1,56 +1,24 @@
import { useRouter } from 'next/navigation';
import { useTransition } from 'react';
'use client';
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { removeItem, updateItemQuantity } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import { updateItemQuantity } from 'components/cart/actions';
import type { CartItem } from 'lib/shopify/types';
import { useFormState } from 'react-dom';
export default function EditItemQuantityButton({
item,
type
}: {
item: CartItem;
type: 'plus' | 'minus';
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
return (
<button
type="submit"
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
onClick={() => {
startTransition(async () => {
const error =
type === 'minus' && item.quantity - 1 === 0
? await removeItem(item.id)
: await updateItemQuantity({
lineId: item.id,
variantId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
});
if (error) {
// Trigger the error boundary in the root error.js
throw new Error(error.toString());
}
router.refresh();
});
}}
disabled={isPending}
className={clsx(
'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full px-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',
{
'cursor-not-allowed': isPending,
'ml-auto': type === 'minus'
}
)}
>
{isPending ? (
<LoadingDots className="bg-black dark:bg-white" />
) : type === 'plus' ? (
{type === 'plus' ? (
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
) : (
<MinusIcon className="h-4 w-4 dark:text-neutral-500" />
@@ -58,3 +26,34 @@ export default function EditItemQuantityButton({
</button>
);
}
export function EditItemQuantityButton({
item,
type,
optimisticUpdate
}: {
item: CartItem;
type: 'plus' | 'minus';
optimisticUpdate: any;
}) {
const [message, formAction] = useFormState(updateItemQuantity, null);
const payload = {
merchandiseId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
};
const actionWithVariant = formAction.bind(null, payload);
return (
<form
action={async () => {
optimisticUpdate(payload.merchandiseId, type);
await actionWithVariant();
}}
>
<SubmitButton type={type} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}

View File

@@ -1,14 +0,0 @@
import { getCart } from 'lib/shopify';
import { cookies } from 'next/headers';
import CartModal from './modal';
export default async function Cart() {
const cartId = cookies().get('cartId')?.value;
let cart;
if (cartId) {
cart = await getCart(cartId);
}
return <CartModal cart={cart} />;
}

View File

@@ -2,37 +2,47 @@
import { Dialog, Transition } from '@headlessui/react';
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
import LoadingDots from 'components/loading-dots';
import Price from 'components/price';
import { DEFAULT_OPTION } from 'lib/constants';
import type { Cart } from 'lib/shopify/types';
import { createUrl } from 'lib/utils';
import Image from 'next/image';
import Link from 'next/link';
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 { DeleteItemButton } from './delete-item-button';
import { EditItemQuantityButton } from './edit-item-quantity-button';
import OpenCart from './open-cart';
type MerchandiseSearchParams = {
[key: string]: string;
};
export default function CartModal({ cart }: { cart: Cart | undefined }) {
export default function CartModal() {
const { cart, updateCartItem } = useCart();
const [isOpen, setIsOpen] = useState(false);
const quantityRef = useRef(cart?.totalQuantity);
const openCart = () => setIsOpen(true);
const closeCart = () => setIsOpen(false);
useEffect(() => {
// Open cart modal when quantity changes.
if (cart?.totalQuantity !== quantityRef.current) {
// But only if it's not already open (quantity also changes when editing items in cart).
if (!cart) {
createCartAndSetCookie();
}
}, [cart]);
useEffect(() => {
if (
cart?.totalQuantity &&
cart?.totalQuantity !== quantityRef.current &&
cart?.totalQuantity > 0
) {
if (!isOpen) {
setIsOpen(true);
}
// Always update the quantity reference
quantityRef.current = cart?.totalQuantity;
}
}, [isOpen, cart?.totalQuantity, quantityRef]);
@@ -64,10 +74,9 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black backdrop-blur-xl dark:border-neutral-700 dark:bg-black/80 dark:text-white md:w-[390px]">
<Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black backdrop-blur-xl md:w-[390px] dark:border-neutral-700 dark:bg-black/80 dark:text-white">
<div className="flex items-center justify-between">
<p className="text-lg font-semibold">My Cart</p>
<button aria-label="Close cart" onClick={closeCart}>
<CloseCart />
</button>
@@ -81,76 +90,89 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
) : (
<div className="flex h-full flex-col justify-between overflow-hidden p-1">
<ul className="flex-grow overflow-auto py-4">
{cart.lines.map((item, i) => {
const merchandiseSearchParams = {} as MerchandiseSearchParams;
{cart.lines
.sort((a, b) =>
a.merchandise.product.title.localeCompare(b.merchandise.product.title)
)
.map((item, i) => {
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}`,
new URLSearchParams(merchandiseSearchParams)
);
const merchandiseUrl = createUrl(
`/product/${item.merchandise.product.handle}`,
new URLSearchParams(merchandiseSearchParams)
);
return (
<li
key={i}
className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
>
<div className="relative flex w-full flex-row justify-between px-1 py-4">
<div className="absolute z-40 -mt-2 ml-[55px]">
<DeleteItemButton item={item} />
</div>
<Link
href={merchandiseUrl}
onClick={closeCart}
className="z-30 flex flex-row space-x-4"
>
<div className="relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
<Image
className="h-full w-full object-cover"
width={64}
height={64}
alt={
item.merchandise.product.featuredImage.altText ||
item.merchandise.product.title
}
src={item.merchandise.product.featuredImage.url}
return (
<li
key={i}
className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
>
<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} />
</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">
<Image
className="h-full w-full object-cover"
width={64}
height={64}
alt={
item.merchandise.product.featuredImage.altText ||
item.merchandise.product.title
}
src={item.merchandise.product.featuredImage.url}
/>
</div>
<Link
href={merchandiseUrl}
onClick={closeCart}
className="z-30 ml-2 flex flex-row space-x-4"
>
<div className="flex flex-1 flex-col text-base">
<span className="leading-tight">
{item.merchandise.product.title}
</span>
{item.merchandise.title !== DEFAULT_OPTION ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{item.merchandise.title}
</p>
) : null}
</div>
</Link>
</div>
<div className="flex h-16 flex-col justify-between">
<Price
className="flex justify-end space-y-2 text-right text-sm"
amount={item.cost.totalAmount.amount}
currencyCode={item.cost.totalAmount.currencyCode}
/>
</div>
<div className="flex flex-1 flex-col text-base">
<span className="leading-tight">
{item.merchandise.product.title}
</span>
{item.merchandise.title !== DEFAULT_OPTION ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{item.merchandise.title}
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
<EditItemQuantityButton
item={item}
type="minus"
optimisticUpdate={updateCartItem}
/>
<p className="w-6 text-center">
<span className="w-full text-sm">{item.quantity}</span>
</p>
) : null}
</div>
</Link>
<div className="flex h-16 flex-col justify-between">
<Price
className="flex justify-end space-y-2 text-right text-sm"
amount={item.cost.totalAmount.amount}
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 item={item} type="minus" />
<p className="w-6 text-center">
<span className="w-full text-sm">{item.quantity}</span>
</p>
<EditItemQuantityButton item={item} type="plus" />
<EditItemQuantityButton
item={item}
type="plus"
optimisticUpdate={updateCartItem}
/>
</div>
</div>
</div>
</div>
</li>
);
})}
</li>
);
})}
</ul>
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
@@ -174,12 +196,9 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
/>
</div>
</div>
<a
href={cart.checkoutUrl}
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
>
Proceed to Checkout
</a>
<form action={redirectToCheckout}>
<CheckoutButton />
</form>
</div>
)}
</Dialog.Panel>
@@ -189,3 +208,17 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
</>
);
}
function CheckoutButton() {
const { pending } = useFormStatus();
return (
<button
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
type="submit"
disabled={pending}
>
{pending ? <LoadingDots className="bg-white" /> : 'Proceed to Checkout'}
</button>
);
}

View File

@@ -11,7 +11,7 @@ export default function OpenCart({
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">
<ShoppingCartIcon
className={clsx('h-4 transition-all ease-in-out hover:scale-110 ', className)}
className={clsx('h-4 transition-all ease-in-out hover:scale-110', className)}
/>
{quantity ? (

View File

@@ -1,20 +0,0 @@
import { Suspense } from 'react';
//import { dynamic } from "next/render-mode"
import "server-only";
function D({ children }: { children: React.ReactNode }) {
//dynamic();
return children;
}
export function Dynamic({fallback, error, children}: {
fallback: React.ReactNode,
error: string,
children: React.ReactNode
}) {
return (
<Suspense fallback={fallback}>
<D>{children}</D>
</Suspense>
);
}

View File

@@ -16,7 +16,11 @@ function ThreeItemGridItem({
<div
className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
>
<Link className="relative block aspect-square h-full w-full" href={`/product/${item.handle}`}>
<Link
className="relative block aspect-square h-full w-full"
href={`/product/${item.handle}`}
prefetch={true}
>
<GridTileImage
src={item.featuredImage.url}
fill
@@ -48,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">
<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)]">
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
<ThreeItemGridItem size="half" item={thirdProduct} />

View File

@@ -29,7 +29,6 @@ export function GridTileImage({
)}
>
{props.src ? (
// eslint-disable-next-line jsx-a11y/alt-text -- `alt` is inherited from `props`, which is being enforced with TypeScript
<Image
className={clsx('relative h-full w-full object-contain', {
'transition duration-300 ease-in-out group-hover:scale-105': isInteractive

View File

@@ -6,7 +6,7 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
const FooterMenuItem = ({ item }: { item: Menu }) => {
export function FooterMenuItem({ item }: { item: Menu }) {
const pathname = usePathname();
const [active, setActive] = useState(pathname === item.path);
@@ -19,7 +19,7 @@ const FooterMenuItem = ({ item }: { item: Menu }) => {
<Link
href={item.path}
className={clsx(
'block p-2 text-lg underline-offset-4 hover:text-black hover:underline dark:hover:text-neutral-300 md:inline-block md:text-sm',
'block p-2 text-lg underline-offset-4 hover:text-black hover:underline md:inline-block md:text-sm dark:hover:text-neutral-300',
{
'text-black dark:text-neutral-300': active
}
@@ -29,7 +29,7 @@ const FooterMenuItem = ({ item }: { item: Menu }) => {
</Link>
</li>
);
};
}
export default function FooterMenu({ menu }: { menu: Menu[] }) {
if (!menu.length) return null;

View File

@@ -16,9 +16,9 @@ export default async function Footer() {
return (
<footer className="text-sm text-neutral-500 dark:text-neutral-400">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm dark:border-neutral-700 md:flex-row md:gap-12 md:px-4 min-[1320px]:px-0">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm md:flex-row md:gap-12 md:px-4 min-[1320px]:px-0 dark:border-neutral-700">
<div>
<Link className="flex items-center gap-2 text-black dark:text-white md:pt-1" href="/">
<Link className="flex items-center gap-2 text-black md:pt-1 dark:text-white" href="/">
<LogoSquare size="sm" />
<span className="uppercase">{SITE_NAME}</span>
</Link>
@@ -56,11 +56,12 @@ export default async function Footer() {
{copyrightName.length && !copyrightName.endsWith('.') ? '.' : ''} All rights reserved.
</p>
<hr className="mx-4 hidden h-4 w-[1px] border-l border-neutral-400 md:inline-block" />
<p>Designed in California</p>
<p>
<a href="https://github.com/vercel/commerce">View the source</a>
</p>
<p className="md:ml-auto">
Crafted by{' '}
<a href="https://vercel.com" className="text-black dark:text-white">
Vercel
Created by Vercel
</a>
</p>
</div>

View File

@@ -1,25 +1,31 @@
import Cart from 'components/cart';
import OpenCart from 'components/cart/open-cart';
import { Dynamic } from 'components/dynamic';
import CartModal from 'components/cart/modal';
import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/shopify';
import { Menu } from 'lib/shopify/types';
import Link from 'next/link';
import { Suspense } from 'react';
import MobileMenu from './mobile-menu';
import Search from './search';
import Search, { SearchSkeleton } from './search';
const { SITE_NAME } = process.env;
export default async function Navbar() {
export async function Navbar() {
const menu = await getMenu('next-js-frontend-header-menu');
return (
<nav className="relative flex items-center justify-between p-4 lg:px-6">
<div className="block flex-none md:hidden">
<MobileMenu menu={menu} />
<Suspense fallback={null}>
<MobileMenu menu={menu} />
</Suspense>
</div>
<div className="flex w-full items-center">
<div className="flex w-full md:w-1/3">
<Link href="/" className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6">
<Link
href="/"
prefetch={true}
className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6"
>
<LogoSquare />
<div className="ml-2 flex-none text-sm font-medium uppercase md:hidden lg:block">
{SITE_NAME}
@@ -31,6 +37,7 @@ export default async function Navbar() {
<li key={item.title}>
<Link
href={item.path}
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"
>
{item.title}
@@ -41,12 +48,12 @@ export default async function Navbar() {
) : null}
</div>
<div className="hidden justify-center md:flex md:w-1/3">
<Search />
<Suspense fallback={<SearchSkeleton />}>
<Search />
</Suspense>
</div>
<div className="flex justify-end md:w-1/3">
<Dynamic fallback={<OpenCart />} error="There was an error opening the cart.">
<Cart />
</Dynamic>
<CartModal />
</div>
</div>
</nav>

View File

@@ -3,11 +3,11 @@
import { Dialog, Transition } from '@headlessui/react';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import { Fragment, useEffect, useState } from 'react';
import { Fragment, Suspense, useEffect, useState } from 'react';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import { Menu } from 'lib/shopify/types';
import Search from './search';
import Search, { SearchSkeleton } from './search';
export default function MobileMenu({ menu }: { menu: Menu[] }) {
const pathname = usePathname();
@@ -35,7 +35,7 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
<button
onClick={openMobileMenu}
aria-label="Open mobile menu"
className="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 md:hidden"
className="flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors md:hidden dark:border-neutral-700 dark:text-white"
>
<Bars3Icon className="h-4" />
</button>
@@ -72,7 +72,9 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
</button>
<div className="mb-4 w-full">
<Search />
<Suspense fallback={<SearchSkeleton />}>
<Search />
</Suspense>
</div>
{menu.length ? (
<ul className="flex w-full flex-col">
@@ -81,7 +83,7 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
className="py-2 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white"
key={item.title}
>
<Link href={item.path} onClick={closeMobileMenu}>
<Link href={item.path} prefetch={true} onClick={closeMobileMenu}>
{item.title}
</Link>
</li>

View File

@@ -1,45 +1,35 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { createUrl } from 'lib/utils';
import Form from 'next/form';
import { useSearchParams } from 'next/navigation';
export default function Search() {
const router = useRouter();
const searchParams = useSearchParams();
const [searchValue, setSearchValue] = useState('');
useEffect(() => {
setSearchValue(searchParams?.get('q') || '');
}, [searchParams, setSearchValue]);
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const val = e.target as HTMLFormElement;
const search = val.search as HTMLInputElement;
const newParams = new URLSearchParams(searchParams.toString());
if (search.value) {
newParams.set('q', search.value);
} else {
newParams.delete('q');
}
router.push(createUrl('/search', newParams));
}
return (
<form onSubmit={onSubmit} className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
<Form action="/search" className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
<input
key={searchParams?.get('q')}
type="text"
name="search"
name="q"
placeholder="Search for products..."
autoComplete="off"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
defaultValue={searchParams?.get('q') || ''}
className="text-md w-full rounded-lg border bg-white px-4 py-2 text-black placeholder:text-neutral-500 md:text-sm dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
/>
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
<MagnifyingGlassIcon className="h-4" />
</div>
</Form>
);
}
export function SearchSkeleton() {
return (
<form className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
<input
placeholder="Search for products..."
className="w-full rounded-lg border bg-white px-4 py-2 text-sm text-black placeholder:text-neutral-500 dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
/>
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">

View File

@@ -8,7 +8,11 @@ export default function ProductGridItems({ products }: { products: Product[] })
<>
{products.map((product) => (
<Grid.Item key={product.handle} className="animate-fadeIn">
<Link className="relative inline-block h-full w-full" href={`/product/${product.handle}`}>
<Link
className="relative inline-block h-full w-full"
href={`/product/${product.handle}`}
prefetch={true}
>
<GridTileImage
alt={product.title}
label={{

View File

@@ -1,4 +1,5 @@
import { SortFilterItem } from 'lib/constants';
import { Suspense } from 'react';
import FilterItemDropdown from './dropdown';
import { FilterItem } from './item';
@@ -19,12 +20,20 @@ export default function FilterList({ list, title }: { list: ListItem[]; title?:
return (
<>
<nav>
{title ? <h3 className="hidden text-xs text-neutral-500 md:block">{title}</h3> : null}
{title ? (
<h3 className="hidden text-xs text-neutral-500 md:block dark:text-neutral-400">
{title}
</h3>
) : null}
<ul className="hidden md:block">
<FilterItemList list={list} />
<Suspense fallback={null}>
<FilterItemList list={list} />
</Suspense>
</ul>
<ul className="md:hidden">
<FilterItemDropdown list={list} />
<Suspense fallback={null}>
<FilterItemDropdown list={list} />
</Suspense>
</ul>
</nav>
</>

View File

@@ -1,7 +1,7 @@
'use client';
import clsx from 'clsx';
import { SortFilterItem } from 'lib/constants';
import type { SortFilterItem } from 'lib/constants';
import { createUrl } from 'lib/utils';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';

View File

@@ -1,4 +1,4 @@
import { ImageResponse } from 'next/server';
import { ImageResponse } from 'next/og';
import LogoIcon from './icons/logo';
export type Props = {

View File

@@ -2,32 +2,22 @@
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import { GridTileImage } from 'components/grid/tile';
import { createUrl } from 'lib/utils';
import { useProduct, useUpdateURL } from 'components/product/product-context';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const imageSearchParam = searchParams.get('image');
const imageIndex = imageSearchParam ? parseInt(imageSearchParam) : 0;
const { state, updateImage } = useProduct();
const updateURL = useUpdateURL();
const imageIndex = state.image ? parseInt(state.image) : 0;
const nextSearchParams = new URLSearchParams(searchParams.toString());
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
nextSearchParams.set('image', nextImageIndex.toString());
const nextUrl = createUrl(pathname, nextSearchParams);
const previousSearchParams = new URLSearchParams(searchParams.toString());
const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
previousSearchParams.set('image', previousImageIndex.toString());
const previousUrl = createUrl(pathname, previousSearchParams);
const buttonClassName =
'h-full px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white flex items-center justify-center';
return (
<>
<form>
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden">
{images[imageIndex] && (
<Image
@@ -43,23 +33,27 @@ 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">
<Link
<button
formAction={() => {
const newState = updateImage(previousImageIndex.toString());
updateURL(newState);
}}
aria-label="Previous product image"
href={previousUrl}
className={buttonClassName}
scroll={false}
>
<ArrowLeftIcon className="h-5" />
</Link>
</button>
<div className="mx-1 h-6 w-px bg-neutral-500"></div>
<Link
<button
formAction={() => {
const newState = updateImage(nextImageIndex.toString());
updateURL(newState);
}}
aria-label="Next product image"
href={nextUrl}
className={buttonClassName}
scroll={false}
>
<ArrowRightIcon className="h-5" />
</Link>
</button>
</div>
</div>
) : null}
@@ -69,16 +63,15 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
<ul className="my-12 flex items-center justify-center gap-2 overflow-auto py-1 lg:mb-0">
{images.map((image, index) => {
const isActive = index === imageIndex;
const imageSearchParams = new URLSearchParams(searchParams.toString());
imageSearchParams.set('image', index.toString());
return (
<li key={image.src} className="h-20 w-20">
<Link
aria-label="Enlarge product image"
href={createUrl(pathname, imageSearchParams)}
scroll={false}
<button
formAction={() => {
const newState = updateImage(index.toString());
updateURL(newState);
}}
aria-label="Select product image"
className="h-full w-full"
>
<GridTileImage
@@ -88,12 +81,12 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
height={80}
active={isActive}
/>
</Link>
</button>
</li>
);
})}
</ul>
) : null}
</>
</form>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { createContext, useContext, useMemo, useOptimistic } from 'react';
type ProductState = {
[key: string]: string;
} & {
image?: string;
};
type ProductContextType = {
state: ProductState;
updateOption: (name: string, value: string) => ProductState;
updateImage: (index: string) => ProductState;
};
const ProductContext = createContext<ProductContextType | undefined>(undefined);
export function ProductProvider({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams();
const getInitialState = () => {
const params: ProductState = {};
for (const [key, value] of searchParams.entries()) {
params[key] = value;
}
return params;
};
const [state, setOptimisticState] = useOptimistic(
getInitialState(),
(prevState: ProductState, update: ProductState) => ({
...prevState,
...update
})
);
const updateOption = (name: string, value: string) => {
const newState = { [name]: value };
setOptimisticState(newState);
return { ...state, ...newState };
};
const updateImage = (index: string) => {
const newState = { image: index };
setOptimisticState(newState);
return { ...state, ...newState };
};
const value = useMemo(
() => ({
state,
updateOption,
updateImage
}),
[state]
);
return <ProductContext.Provider value={value}>{children}</ProductContext.Provider>;
}
export function useProduct() {
const context = useContext(ProductContext);
if (context === undefined) {
throw new Error('useProduct must be used within a ProductProvider');
}
return context;
}
export function useUpdateURL() {
const router = useRouter();
return (state: ProductState) => {
const newParams = new URLSearchParams(window.location.search);
Object.entries(state).forEach(([key, value]) => {
newParams.set(key, value);
});
router.push(`?${newParams.toString()}`, { scroll: false });
};
}

View File

@@ -17,15 +17,13 @@ export function ProductDescription({ product }: { product: Product }) {
</div>
</div>
<VariantSelector options={product.options} variants={product.variants} />
{product.descriptionHtml ? (
<Prose
className="mb-6 text-sm leading-tight dark:text-white/[60%]"
html={product.descriptionHtml}
/>
) : null}
<AddToCart variants={product.variants} availableForSale={product.availableForSale} />
<AddToCart product={product} />
</>
);
}

View File

@@ -1,14 +1,13 @@
'use client';
import clsx from 'clsx';
import { useProduct, useUpdateURL } from 'components/product/product-context';
import { ProductOption, ProductVariant } from 'lib/shopify/types';
import { createUrl } from 'lib/utils';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
type Combination = {
id: string;
availableForSale: boolean;
[key: string]: string | boolean; // ie. { color: 'Red', size: 'Large', ... }
[key: string]: string | boolean;
};
export function VariantSelector({
@@ -18,9 +17,8 @@ export function VariantSelector({
options: ProductOption[];
variants: ProductVariant[];
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { state, updateOption } = useProduct();
const updateURL = useUpdateURL();
const hasNoOptionsOrJustOneOption =
!options.length || (options.length === 1 && options[0]?.values.length === 1);
@@ -31,7 +29,6 @@ export function VariantSelector({
const combinations: Combination[] = variants.map((variant) => ({
id: variant.id,
availableForSale: variant.availableForSale,
// Adds key / value pairs for each variant (ie. "color": "Black" and "size": 'M").
...variant.selectedOptions.reduce(
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
{}
@@ -39,68 +36,58 @@ export function VariantSelector({
}));
return options.map((option) => (
<dl className="mb-8" key={option.id}>
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
<dd className="flex flex-wrap gap-3">
{option.values.map((value) => {
const optionNameLowerCase = option.name.toLowerCase();
<form key={option.id}>
<dl className="mb-8">
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
<dd className="flex flex-wrap gap-3">
{option.values.map((value) => {
const optionNameLowerCase = option.name.toLowerCase();
// Base option params on current params so we can preserve any other param state in the url.
const optionSearchParams = new URLSearchParams(searchParams.toString());
// Base option params on current selectedOptions so we can preserve any other param state.
const optionParams = { ...state, [optionNameLowerCase]: value };
// Update the option params using the current option to reflect how the url *would* change,
// if the option was clicked.
optionSearchParams.set(optionNameLowerCase, value);
const optionUrl = createUrl(pathname, optionSearchParams);
// Filter out invalid options and check if the option combination is available for sale.
const filtered = Object.entries(optionParams).filter(([key, value]) =>
options.find(
(option) => option.name.toLowerCase() === key && option.values.includes(value)
)
);
const isAvailableForSale = combinations.find((combination) =>
filtered.every(
([key, value]) => combination[key] === value && combination.availableForSale
)
);
// In order to determine if an option is available for sale, we need to:
//
// 1. Filter out all other param state
// 2. Filter out invalid options
// 3. Check if the option combination is available for sale
//
// This is the "magic" that will cross check possible variant combinations and preemptively
// disable combinations that are not available. For example, if the color gray is only available in size medium,
// then all other sizes should be disabled.
const filtered = Array.from(optionSearchParams.entries()).filter(([key, value]) =>
options.find(
(option) => option.name.toLowerCase() === key && option.values.includes(value)
)
);
const isAvailableForSale = combinations.find((combination) =>
filtered.every(
([key, value]) => combination[key] === value && combination.availableForSale
)
);
// The option is active if it's in the selected options.
const isActive = state[optionNameLowerCase] === value;
// The option is active if it's in the url params.
const isActive = searchParams.get(optionNameLowerCase) === value;
return (
<button
key={value}
aria-disabled={!isAvailableForSale}
disabled={!isAvailableForSale}
onClick={() => {
router.replace(optionUrl, { scroll: false });
}}
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
className={clsx(
'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900',
{
'cursor-default ring-2 ring-blue-600': isActive,
'ring-1 ring-transparent transition duration-300 ease-in-out hover:scale-110 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':
!isAvailableForSale
}
)}
>
{value}
</button>
);
})}
</dd>
</dl>
return (
<button
formAction={() => {
const newState = updateOption(optionNameLowerCase, value);
updateURL(newState);
}}
key={value}
aria-disabled={!isAvailableForSale}
disabled={!isAvailableForSale}
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
className={clsx(
'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900',
{
'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':
!isAvailableForSale
}
)}
>
{value}
</button>
);
})}
</dd>
</dl>
</form>
));
}

View File

@@ -0,0 +1,35 @@
'use client';
import { useEffect } from 'react';
import { toast } from 'sonner';
export function WelcomeToast() {
useEffect(() => {
// ignore if screen height is too small
if (window.innerHeight < 650) return;
if (!document.cookie.includes('welcome-toast=2')) {
toast('🛍️ Welcome to Next.js Commerce!', {
id: 'welcome-toast',
duration: Infinity,
onDismiss: () => {
document.cookie = 'welcome-toast=2; max-age=31536000; path=/';
},
description: (
<>
This is a high-performance, SSR storefront powered by Shopify, Next.js, and Vercel.{' '}
<a
href="https://vercel.com/templates/next.js/nextjs-commerce"
className="text-blue-600 hover:underline"
target="_blank"
>
Deploy your own
</a>
.
</>
)
});
}
}, []);
return null;
}

View File

@@ -22,7 +22,8 @@ export const sorting: SortFilterItem[] = [
export const TAGS = {
collections: 'collections',
products: 'products'
products: 'products',
cart: 'cart'
};
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';

View File

@@ -114,7 +114,7 @@ export async function shopifyFetch<T>({
}
}
const removeEdgesAndNodes = (array: Connection<any>) => {
const removeEdgesAndNodes = <T>(array: Connection<T>): T[] => {
return array.edges.map((edge) => edge?.node);
};
@@ -163,7 +163,7 @@ const reshapeImages = (images: Connection<Image>, productTitle: string) => {
const flattened = removeEdgesAndNodes(images);
return flattened.map((image) => {
const filename = image.url.match(/.*\/(.*)\..*/)[1];
const filename = image.url.match(/.*\/(.*)\..*/)?.[1];
return {
...image,
altText: image.altText || `${productTitle} - ${filename}`
@@ -254,11 +254,15 @@ export async function updateCart(
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
export async function getCart(cartId: string): Promise<Cart | undefined> {
export async function getCart(cartId: string | undefined): Promise<Cart | undefined> {
if (!cartId) {
return undefined;
}
const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery,
variables: { cartId },
cache: 'no-store'
tags: [TAGS.cart]
});
// Old carts becomes `null` when you checkout.
@@ -356,6 +360,7 @@ export async function getMenu(handle: string): Promise<Menu[]> {
export async function getPage(handle: string): Promise<Page> {
const res = await shopifyFetch<ShopifyPageOperation>({
query: getPageQuery,
cache: 'no-store',
variables: { handle }
});
@@ -364,7 +369,8 @@ export async function getPage(handle: string): Promise<Page> {
export async function getPages(): Promise<Page[]> {
const res = await shopifyFetch<ShopifyPagesOperation>({
query: getPagesQuery
query: getPagesQuery,
cache: 'no-store'
});
return removeEdgesAndNodes(res.body.data.pages);

View File

@@ -12,8 +12,15 @@ export type Cart = Omit<ShopifyCart, 'lines'> & {
lines: CartItem[];
};
export type CartItem = {
export type CartProduct = {
id: string;
handle: string;
title: string;
featuredImage: Image;
};
export type CartItem = {
id: string | undefined;
quantity: number;
cost: {
totalAmount: Money;
@@ -25,7 +32,7 @@ export type CartItem = {
name: string;
value: string;
}[];
product: Product;
product: CartProduct;
};
};
@@ -89,7 +96,7 @@ export type SEO = {
};
export type ShopifyCart = {
id: string;
id: string | undefined;
checkoutUrl: string;
cost: {
subtotalAmount: Money;

View File

@@ -9,3 +9,31 @@ export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyUR
export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
export const validateEnvironmentVariables = () => {
const requiredEnvironmentVariables = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_STOREFRONT_ACCESS_TOKEN'];
const missingEnvironmentVariables = [] as string[];
requiredEnvironmentVariables.forEach((envVar) => {
if (!process.env[envVar]) {
missingEnvironmentVariables.push(envVar);
}
});
if (missingEnvironmentVariables.length) {
throw new Error(
`The following environment variables are missing. Your site will not work without them. Read more: https://vercel.com/docs/integrations/shopify#configure-environment-variables\n\n${missingEnvironmentVariables.join(
'\n'
)}\n`
);
}
if (
process.env.SHOPIFY_STORE_DOMAIN?.includes('[') ||
process.env.SHOPIFY_STORE_DOMAIN?.includes(']')
) {
throw new Error(
'Your `SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.'
);
}
};

View File

@@ -1,12 +1,5 @@
/** @type {import('next').NextConfig} */
module.exports = {
eslint: {
// Disabling on production builds because we're running checks on PRs via GitHub Actions.
ignoreDuringBuilds: true
},
experimental: {
serverActions: true
},
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
@@ -16,14 +9,5 @@ module.exports = {
pathname: '/s/files/**'
}
]
},
async redirects() {
return [
{
source: '/password',
destination: '/',
permanent: true
}
];
}
};

View File

@@ -1,52 +1,38 @@
{
"private": true,
"packageManager": "pnpm@8.2.0",
"engines": {
"node": ">=18",
"pnpm": ">=7"
"node": ">=20",
"pnpm": ">=9"
},
"scripts": {
"dev": "next dev",
"dev": "next dev --turbo",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint-staged": "lint-staged",
"prettier": "prettier --write --ignore-unknown .",
"prettier:check": "prettier --check --ignore-unknown .",
"test": "pnpm lint && pnpm prettier:check"
},
"git": {
"pre-commit": "lint-staged"
},
"lint-staged": {
"*": "prettier --write --ignore-unknown"
"test": "pnpm prettier:check"
},
"dependencies": {
"@headlessui/react": "^1.7.15",
"@heroicons/react": "^2.0.18",
"clsx": "^2.0.0",
"next": "13.4.13-canary.15",
"react": "18.2.0",
"react-dom": "18.2.0",
"server-only": "^0.0.1"
"@headlessui/react": "^2.1.2",
"@heroicons/react": "^2.1.5",
"clsx": "^2.1.1",
"geist": "^1.3.1",
"next": "https://vercel-packages.vercel.app/next/prs/68340/next",
"react": "19.0.0-rc-3208e73e-20240730",
"react-dom": "19.0.0-rc-3208e73e-20240730",
"sonner": "^1.5.0"
},
"devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.9",
"@types/node": "20.4.4",
"@types/react": "18.2.16",
"@types/react-dom": "18.2.7",
"@vercel/git-hooks": "^1.0.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.45.0",
"eslint-config-next": "^13.4.12",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-unicorn": "^48.0.0",
"lint-staged": "^13.2.3",
"postcss": "^8.4.27",
"prettier": "3.0.1",
"prettier-plugin-tailwindcss": "^0.4.1",
"tailwindcss": "^3.3.3",
"typescript": "5.1.6"
"@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"
}
}

4331
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,9 @@
/** @type {import('prettier').Config} */
module.exports = {
singleQuote: true,
arrowParens: 'always',
trailingComma: 'none',
printWidth: 100,
tabWidth: 2,
// pnpm doesn't support plugin autoloading
// https://github.com/tailwindlabs/prettier-plugin-tailwindcss#installation
plugins: [require('prettier-plugin-tailwindcss')]
plugins: ['prettier-plugin-tailwindcss']
};

View File

@@ -6,7 +6,7 @@ module.exports = {
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)']
sans: ['var(--font-geist-sans)']
},
keyframes: {
fadeIn: {

View File

@@ -1,7 +1,8 @@
{
"compilerOptions": {
"target": "es5",
"target": "es2015",
"lib": ["dom", "dom.iterable", "esnext"],
"downlevelIteration": true,
"allowJs": true,
"skipLibCheck": true,
"strict": true,