3 Commits

Author SHA1 Message Date
Michael Novotny
eb9b7732b5 Lowercase 2023-08-14 16:14:39 -05:00
Michael Novotny
a8bb9e7915 Fixes url 2023-08-14 16:11:52 -05:00
Michael Novotny
3c3e44a157 Replaces README content with Vercel and Shopify integration guide 2023-08-14 16:11:34 -05:00
25 changed files with 1083 additions and 1679 deletions

View File

@@ -1,18 +1,15 @@
name: test
on: pull_request
# Cancel in progress workflows on pull_requests.
# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
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@v4
uses: actions/checkout@v3
- name: Set node version
uses: actions/setup-node@v3
with:
@@ -30,6 +27,6 @@ jobs:
key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
run: pnpm install --no-frozen-lockfile
run: pnpm install
- name: Run tests
run: pnpm test

1
.gitignore vendored
View File

@@ -36,4 +36,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.env*.local

View File

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

View File

@@ -2,7 +2,7 @@
# Next.js Commerce
A Next.js 14 and App Router-ready ecommerce template featuring:
A Next.js 13 and App Router-ready ecommerce template featuring:
- Next.js App Router
- Optimized for SEO using Next.js's Metadata
@@ -23,7 +23,7 @@ A Next.js 14 and App Router-ready ecommerce template featuring:
Vercel will only be actively maintaining a Shopify version [as outlined in our vision and strategy for Next.js Commerce](https://github.com/vercel/commerce/pull/966).
Vercel is happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged.
Vercel is more than happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged.
- Shopify (this repository)
- [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
@@ -31,18 +31,6 @@ Vercel is happy to partner and work with any commerce provider to help them get
- [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
@@ -68,9 +56,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 `pnpm dev` to ensure everything is working correctly.
1. Run `pmpm dev` to ensure everything is working correctly.
</details>
## Vercel, Next.js Commerce, and Shopify Integration Guide
## 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.

View File

@@ -1,6 +1,6 @@
import Navbar from 'components/layout/navbar';
import { GeistSans } from 'geist/font';
import { ensureStartsWith } from 'lib/utils';
import { Inter } from 'next/font/google';
import { ReactNode, Suspense } from 'react';
import './globals.css';
@@ -31,13 +31,17 @@ export const metadata = {
})
};
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter'
});
export default async function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={GeistSans.variable}>
<html lang="en" className={inter.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">
<Suspense>
<Navbar />
</Suspense>
<Navbar />
<Suspense>
<main>{children}</main>
</Suspense>

View File

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

View File

@@ -1,11 +1,9 @@
'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';
export async function addItem(prevState: any, selectedVariantId: string | undefined) {
export const addItem = async (variantId: string | undefined): Promise<String | undefined> => {
let cartId = cookies().get('cartId')?.value;
let cart;
@@ -19,56 +17,45 @@ export async function addItem(prevState: any, selectedVariantId: string | undefi
cookies().set('cartId', cartId);
}
if (!selectedVariantId) {
if (!variantId) {
return 'Missing product variant ID';
}
try {
await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]);
revalidateTag(TAGS.cart);
await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
} catch (e) {
return 'Error adding item to cart';
}
}
};
export async function removeItem(prevState: any, lineId: string) {
export const removeItem = async (lineId: string): Promise<String | undefined> => {
const cartId = cookies().get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
}
try {
await removeFromCart(cartId, [lineId]);
revalidateTag(TAGS.cart);
} catch (e) {
return 'Error removing item from cart';
}
}
};
export async function updateItemQuantity(
prevState: any,
payload: {
lineId: string;
variantId: string;
quantity: number;
}
) {
export const updateItemQuantity = async ({
lineId,
variantId,
quantity
}: {
lineId: string;
variantId: string;
quantity: number;
}): Promise<String | undefined> => {
const cartId = cookies().get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
}
const { lineId, variantId, quantity } = payload;
try {
if (quantity === 0) {
await removeFromCart(cartId, [lineId]);
revalidateTag(TAGS.cart);
return;
}
await updateCart(cartId, [
{
id: lineId,
@@ -76,8 +63,7 @@ export async function updateItemQuantity(
quantity
}
]);
revalidateTag(TAGS.cart);
} catch (e) {
return 'Error updating item quantity';
}
}
};

View File

@@ -5,63 +5,8 @@ import clsx from 'clsx';
import { addItem } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import { ProductVariant } from 'lib/shopify/types';
import { useSearchParams } from 'next/navigation';
import { useFormState, useFormStatus } from 'react-dom';
function SubmitButton({
availableForSale,
selectedVariantId
}: {
availableForSale: boolean;
selectedVariantId: string | undefined;
}) {
const { pending } = useFormStatus();
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 aria-disabled className={clsx(buttonClasses, disabledClasses)}>
Out Of Stock
</button>
);
}
if (!selectedVariantId) {
return (
<button
aria-label="Please select an option"
aria-disabled
className={clsx(buttonClasses, disabledClasses)}
>
<div className="absolute left-0 ml-4">
<PlusIcon className="h-5" />
</div>
Add To Cart
</button>
);
}
return (
<button
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
aria-label="Add to cart"
aria-disabled={pending}
className={clsx(buttonClasses, {
'hover:opacity-90': true,
disabledClasses: pending
})}
>
<div className="absolute left-0 ml-4">
{pending ? <LoadingDots className="mb-3 bg-white" /> : <PlusIcon className="h-5" />}
</div>
Add To Cart
</button>
);
}
import { useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
export function AddToCart({
variants,
@@ -70,8 +15,9 @@ export function AddToCart({
variants: ProductVariant[];
availableForSale: boolean;
}) {
const [message, formAction] = useFormState(addItem, null);
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(
@@ -79,14 +25,44 @@ export function AddToCart({
)
);
const selectedVariantId = variant?.id || defaultVariantId;
const actionWithVariant = formAction.bind(null, selectedVariantId);
const title = !availableForSale
? 'Out of stock'
: !selectedVariantId
? 'Please select options'
: undefined;
return (
<form action={actionWithVariant}>
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
<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
}
)}
>
<div className="absolute left-0 ml-4">
{!isPending ? <PlusIcon className="h-5" /> : <LoadingDots className="mb-3 bg-white" />}
</div>
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
</button>
);
}

View File

@@ -1,31 +1,40 @@
'use client';
import { XMarkIcon } from '@heroicons/react/24/outline';
import LoadingDots from 'components/loading-dots';
import { useRouter } from 'next/navigation';
import clsx from 'clsx';
import { removeItem } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import type { CartItem } from 'lib/shopify/types';
import { useFormState, useFormStatus } from 'react-dom';
import { useTransition } from 'react';
function SubmitButton() {
const { pending } = useFormStatus();
export default function DeleteItemButton({ item }: { item: CartItem }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
return (
<button
type="submit"
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
aria-label="Remove cart item"
aria-disabled={pending}
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();
});
}}
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': pending
'cursor-not-allowed px-0': isPending
}
)}
>
{pending ? (
{isPending ? (
<LoadingDots className="bg-white" />
) : (
<XMarkIcon className="hover:text-accent-3 mx-[1px] h-4 w-4 text-white dark:text-black" />
@@ -33,18 +42,3 @@ function SubmitButton() {
</button>
);
}
export function DeleteItemButton({ item }: { item: CartItem }) {
const [message, formAction] = useFormState(removeItem, null);
const itemId = item.id;
const actionWithVariant = formAction.bind(null, itemId);
return (
<form action={actionWithVariant}>
<SubmitButton />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}

View File

@@ -1,32 +1,54 @@
'use client';
import { useRouter } from 'next/navigation';
import { useTransition } from 'react';
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { updateItemQuantity } from 'components/cart/actions';
import { removeItem, updateItemQuantity } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import type { CartItem } from 'lib/shopify/types';
import { useFormState, useFormStatus } from 'react-dom';
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
const { pending } = useFormStatus();
export default function EditItemQuantityButton({
item,
type
}: {
item: CartItem;
type: 'plus' | 'minus';
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
return (
<button
type="submit"
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
aria-disabled={pending}
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',
{
'cursor-not-allowed': pending,
'cursor-not-allowed': isPending,
'ml-auto': type === 'minus'
}
)}
>
{pending ? (
{isPending ? (
<LoadingDots className="bg-black dark:bg-white" />
) : type === 'plus' ? (
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
@@ -36,22 +58,3 @@ function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
</button>
);
}
export function EditItemQuantityButton({ item, type }: { item: CartItem; type: 'plus' | 'minus' }) {
const [message, formAction] = useFormState(updateItemQuantity, null);
const payload = {
lineId: item.id,
variantId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
};
const actionWithVariant = formAction.bind(null, payload);
return (
<form action={actionWithVariant}>
<SubmitButton type={type} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}

View File

@@ -10,8 +10,8 @@ import Image from 'next/image';
import Link from 'next/link';
import { Fragment, useEffect, useRef, useState } from 'react';
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 = {

View File

@@ -16,7 +16,7 @@ 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 dark:border-neutral-700 md:flex-row md:gap-12 md:px-4 xl:px-0">
<div>
<Link className="flex items-center gap-2 text-black dark:text-white md:pt-1" href="/">
<LogoSquare size="sm" />
@@ -50,7 +50,7 @@ export default async function Footer() {
</div>
</div>
<div className="border-t border-neutral-200 py-6 text-sm dark:border-neutral-700">
<div className="mx-auto flex w-full max-w-7xl flex-col items-center gap-1 px-4 md:flex-row md:gap-0 md:px-4 min-[1320px]:px-0">
<div className="mx-auto flex w-full max-w-7xl flex-col items-center gap-1 px-4 md:flex-row md:gap-0 md:px-4 xl:px-0">
<p>
&copy; {copyrightDate} {copyrightName}
{copyrightName.length && !copyrightName.endsWith('.') ? '.' : ''} All rights reserved.
@@ -58,8 +58,9 @@ export default async function Footer() {
<hr className="mx-4 hidden h-4 w-[1px] border-l border-neutral-400 md:inline-block" />
<p>Designed in California</p>
<p className="md:ml-auto">
Crafted by{' '}
<a href="https://vercel.com" className="text-black dark:text-white">
Crafted by Vercel
Vercel
</a>
</p>
</div>

View File

@@ -1,12 +1,19 @@
'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 { useRouter, 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();
@@ -27,12 +34,12 @@ export default function Search() {
return (
<form onSubmit={onSubmit} className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
<input
key={searchParams?.get('q')}
type="text"
name="search"
placeholder="Search for products..."
autoComplete="off"
defaultValue={searchParams?.get('q') || ''}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
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

@@ -19,11 +19,7 @@ export default function FilterList({ list, title }: { list: ListItem[]; title?:
return (
<>
<nav>
{title ? (
<h3 className="hidden text-xs text-neutral-500 dark:text-neutral-400 md:block">
{title}
</h3>
) : null}
{title ? <h3 className="hidden text-xs text-neutral-500 md:block">{title}</h3> : null}
<ul className="hidden md:block">
<FilterItemList list={list} />
</ul>

View File

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

View File

@@ -1,7 +1,6 @@
import { AddToCart } from 'components/cart/add-to-cart';
import Price from 'components/price';
import Prose from 'components/prose';
import { FitToCart } from 'lib/ai/fit-to-cart';
import { Product } from 'lib/shopify/types';
import { VariantSelector } from './variant-selector';
@@ -25,8 +24,8 @@ export function ProductDescription({ product }: { product: Product }) {
html={product.descriptionHtml}
/>
) : null}
<AddToCart variants={product.variants} availableForSale={product.availableForSale} />
<FitToCart currentProduct={product} />
</>
);
}

View File

@@ -1,84 +0,0 @@
import { OpenAIStream, StreamingTextResponse } from 'ai';
import { getCart } from 'lib/shopify';
import { Product } from 'lib/shopify/types';
import { unstable_cache } from 'next/cache';
import { cookies } from 'next/headers';
import OpenAI from 'openai';
import { Suspense } from 'react';
async function getCartFromCookies() {
const cartId = cookies().get('cartId')?.value;
if (cartId) {
return getCart(cartId);
}
return null;
}
export async function FitToCart({ currentProduct }: { currentProduct: Product }) {
return (
<Suspense fallback={null}>
<FitToCartInternal currentProduct={currentProduct} />
</Suspense>
);
}
async function FitToCartInternal({ currentProduct }: { currentProduct: Product }) {
const pitch = await getPitch({ currentProduct });
if (!pitch) return null;
return <div className="mt-6 text-sm leading-tight dark:text-white/[60%]">{pitch}</div>;
}
const fireworks = new OpenAI({
baseURL: 'https://api.fireworks.ai/inference/v1',
apiKey: process.env.FIREWORKS_API_KEY!
});
function buildPrompt(prompt: string) {
return prompt.split('\n').map((message) => ({
role: 'user' as const,
content: message
}));
}
export async function getPitch({ currentProduct }: { currentProduct: Product }) {
const cart = await getCartFromCookies();
if (!cart) return null;
const products = cart.lines
.filter((line) => line.merchandise.product.id !== currentProduct.id)
.map((line) => `"${line.merchandise.product.title}"`);
if (!products.length) return null;
const prompt = `Write a 30 word pitch for why a person who has ${products.join(
' and '
)} in their shopping cart should also purchase the "${currentProduct.title}"`;
const query = {
model: 'accounts/fireworks/models/mistral-7b-instruct-4k',
stream: true,
messages: buildPrompt(prompt),
max_tokens: 1000,
temperature: 0.75,
top_p: 1,
frequency_penalty: 1
} as const;
return unstable_cache(async () => {
// Request the Fireworks API for the response based on the prompt
const response = await fireworks.chat.completions.create(query);
// Convert the response into a friendly text-stream
const stream = OpenAIStream(response);
// Respond with the stream
const streamingResponse = new StreamingTextResponse(stream);
let text = await streamingResponse.text();
// Remove the quotes from the response tht the LLM sometimes adds.
text = text.trim().replace(/^"/, '').replace(/"$/, '');
return text;
}, [
JSON.stringify(query),
'1.0',
process.env.VERCEL_BRANCH_URL || '',
process.env.NODE_ENV || ''
])();
}

View File

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

View File

@@ -210,10 +210,6 @@ export async function createCart(): Promise<Cart> {
return reshapeCart(res.body.data.cartCreate.cart);
}
function revalidateCart(cartId: string) {
revalidateTag(`cart-${cartId}`);
}
export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
@@ -226,7 +222,6 @@ export async function addToCart(
},
cache: 'no-store'
});
revalidateCart(cartId);
return reshapeCart(res.body.data.cartLinesAdd.cart);
}
@@ -239,7 +234,6 @@ export async function removeFromCart(cartId: string, lineIds: string[]): Promise
},
cache: 'no-store'
});
revalidateCart(cartId);
return reshapeCart(res.body.data.cartLinesRemove.cart);
}
@@ -256,7 +250,6 @@ export async function updateCart(
},
cache: 'no-store'
});
revalidateCart(cartId);
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
@@ -265,7 +258,7 @@ export async function getCart(cartId: string): Promise<Cart | undefined> {
const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery,
variables: { cartId },
tags: [TAGS.cart, `cart-${cartId}`]
cache: 'no-store'
});
// Old carts becomes `null` when you checkout.

View File

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

View File

@@ -4,6 +4,9 @@ module.exports = {
// 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: [

View File

@@ -22,33 +22,30 @@
"*": "prettier --write --ignore-unknown"
},
"dependencies": {
"@headlessui/react": "^1.7.17",
"@headlessui/react": "^1.7.15",
"@heroicons/react": "^2.0.18",
"ai": "^2.2.33",
"clsx": "^2.0.0",
"geist": "^1.0.0",
"next": "14.1.1-canary.27",
"openai": "^4.26.0",
"next": "13.4.13-canary.15",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.10",
"@types/node": "20.8.9",
"@types/react": "18.2.33",
"@types/react-dom": "18.2.14",
"@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.16",
"eslint": "^8.52.0",
"eslint-config-next": "^14.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-unicorn": "^48.0.1",
"lint-staged": "^15.0.2",
"postcss": "^8.4.31",
"prettier": "3.0.3",
"prettier-plugin-tailwindcss": "^0.5.6",
"tailwindcss": "^3.3.5",
"typescript": "5.2.2"
"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"
}
}

2230
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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