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
30 changed files with 1311 additions and 1635 deletions

View File

@@ -1,18 +1,15 @@
name: test name: test
on: pull_request 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: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Cancel running workflows
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: Set node version - name: Set node version
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
@@ -30,6 +27,6 @@ jobs:
key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }} key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies - name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true' if: steps.node-modules-cache.outputs.cache-hit != 'true'
run: pnpm install --no-frozen-lockfile run: pnpm install
- name: Run tests - name: Run tests
run: pnpm test run: pnpm test

View File

@@ -2,7 +2,7 @@
# Next.js Commerce # 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 - Next.js App Router
- Optimized for SEO using Next.js's Metadata - 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 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) - Shopify (this repository)
- [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/)) - [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/)) - [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/)) - [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/)) - [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 ## 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. Select the `Vercel Solutions` scope.
1. Connect to the existing `commerce-shopify` project. 1. Connect to the existing `commerce-shopify` project.
1. Run `vc env pull` to get environment variables. 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> </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. 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

@@ -6,6 +6,8 @@ import { notFound } from 'next/navigation';
export const runtime = 'edge'; export const runtime = 'edge';
export const revalidate = 43200; // 12 hours in seconds
export async function generateMetadata({ export async function generateMetadata({
params params
}: { }: {

View File

@@ -2,7 +2,7 @@
export default function Error({ reset }: { reset: () => void }) { export default function Error({ reset }: { reset: () => void }) {
return ( return (
<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"> <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">
<h2 className="text-xl font-bold">Oh no!</h2> <h2 className="text-xl font-bold">Oh no!</h2>
<p className="my-2"> <p className="my-2">
There was an issue with our storefront. This could be a temporary issue, please try your There was an issue with our storefront. This could be a temporary issue, please try your

View File

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

View File

@@ -82,20 +82,14 @@ export default async function ProductPage({ params }: { params: { handle: string
}} }}
/> />
<div className="mx-auto max-w-screen-2xl px-4"> <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 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black"> <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="h-full w-full basis-full lg:basis-4/6"> <div className="h-full w-full basis-full lg:basis-4/6">
<Suspense <Gallery
fallback={ images={product.images.map((image: Image) => ({
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" /> src: image.url,
} altText: image.altText
> }))}
<Gallery />
images={product.images.map((image: Image) => ({
src: image.url,
altText: image.altText
}))}
/>
</Suspense>
</div> </div>
<div className="basis-full lg:basis-2/6"> <div className="basis-full lg:basis-2/6">

View File

@@ -7,7 +7,7 @@ import { Suspense } from 'react';
export default function SearchLayout({ children }: { children: React.ReactNode }) { export default function SearchLayout({ children }: { children: React.ReactNode }) {
return ( return (
<Suspense> <Suspense>
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black md:flex-row dark:text-white"> <div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black dark:text-white md:flex-row">
<div className="order-first w-full flex-none md:max-w-[125px]"> <div className="order-first w-full flex-none md:max-w-[125px]">
<Collections /> <Collections />
</div> </div>

View File

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

View File

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

View File

@@ -5,63 +5,8 @@ import clsx from 'clsx';
import { addItem } from 'components/cart/actions'; import { addItem } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots'; import LoadingDots from 'components/loading-dots';
import { ProductVariant } from 'lib/shopify/types'; import { ProductVariant } from 'lib/shopify/types';
import { useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useFormState, useFormStatus } from 'react-dom'; import { useTransition } from 'react';
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>
);
}
export function AddToCart({ export function AddToCart({
variants, variants,
@@ -70,8 +15,9 @@ export function AddToCart({
variants: ProductVariant[]; variants: ProductVariant[];
availableForSale: boolean; availableForSale: boolean;
}) { }) {
const [message, formAction] = useFormState(addItem, null); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined; const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
const variant = variants.find((variant: ProductVariant) => const variant = variants.find((variant: ProductVariant) =>
variant.selectedOptions.every( variant.selectedOptions.every(
@@ -79,14 +25,44 @@ export function AddToCart({
) )
); );
const selectedVariantId = variant?.id || defaultVariantId; const selectedVariantId = variant?.id || defaultVariantId;
const actionWithVariant = formAction.bind(null, selectedVariantId); const title = !availableForSale
? 'Out of stock'
: !selectedVariantId
? 'Please select options'
: undefined;
return ( return (
<form action={actionWithVariant}> <button
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} /> aria-label="Add item to cart"
<p aria-live="polite" className="sr-only" role="status"> disabled={isPending || !availableForSale || !selectedVariantId}
{message} title={title}
</p> onClick={() => {
</form> // 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 { XMarkIcon } from '@heroicons/react/24/outline';
import LoadingDots from 'components/loading-dots';
import { useRouter } from 'next/navigation';
import clsx from 'clsx'; import clsx from 'clsx';
import { removeItem } from 'components/cart/actions'; import { removeItem } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import type { CartItem } from 'lib/shopify/types'; import type { CartItem } from 'lib/shopify/types';
import { useFormState, useFormStatus } from 'react-dom'; import { useTransition } from 'react';
function SubmitButton() { export default function DeleteItemButton({ item }: { item: CartItem }) {
const { pending } = useFormStatus(); const router = useRouter();
const [isPending, startTransition] = useTransition();
return ( return (
<button <button
type="submit"
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
aria-label="Remove cart item" 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( className={clsx(
'ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-neutral-500 transition-all duration-200', '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" /> <LoadingDots className="bg-white" />
) : ( ) : (
<XMarkIcon className="hover:text-accent-3 mx-[1px] h-4 w-4 text-white dark:text-black" /> <XMarkIcon className="hover:text-accent-3 mx-[1px] h-4 w-4 text-white dark:text-black" />
@@ -33,18 +42,3 @@ function SubmitButton() {
</button> </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,96 +1,60 @@
'use client'; import { useRouter } from 'next/navigation';
import { useTransition } from 'react';
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline'; import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { updateItemQuantity } from 'components/cart/actions'; import { removeItem, updateItemQuantity } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots'; import LoadingDots from 'components/loading-dots';
import type { CartItem } from 'lib/shopify/types'; import type { CartItem } from 'lib/shopify/types';
import { useOptimistic, useState } from 'react';
import { useFormState, useFormStatus } from 'react-dom';
function SubmitButton({ type }: { type: 'plus' | 'minus' }) { export default function EditItemQuantityButton({
const { pending } = useFormStatus(); item,
type
}: {
item: CartItem;
type: 'plus' | 'minus';
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
return ( return (
<button <button
type="submit"
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'} 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( 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 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' 'ml-auto': type === 'minus'
} }
)} )}
> >
{pending ? ( {isPending ? (
<LoadingDots className="bg-black dark:bg-white" /> <LoadingDots className="bg-black dark:bg-white" />
) : type === 'plus' ? ( ) : type === 'plus' ? (
<PlusIcon className="w-4 h-4 dark:text-neutral-500" /> <PlusIcon className="h-4 w-4 dark:text-neutral-500" />
) : ( ) : (
<MinusIcon className="w-4 h-4 dark:text-neutral-500" /> <MinusIcon className="h-4 w-4 dark:text-neutral-500" />
)} )}
</button> </button>
); );
} }
export function EditItemQuantityButton({ item, type, onQuantityChange }: { item: CartItem; type: 'plus' | 'minus'; onQuantityChange: (quantity: number) => void }) {
const [message, formAction] = useFormState(updateItemQuantity, null);
const [optimisticQuantity, setOptimisticQuantity] = useOptimistic(item.quantity, (state: number, change: number) => state + change);
// const handleSubmit = async (event: React.FormEvent) => {
// event.preventDefault();
// const change = type === 'plus' ? 1 : -1;
// setOptimisticQuantity(change);
// onQuantityChange(optimisticQuantity + change);
// const updatedPayload = {
// lineId: item.id,
// variantId: item.merchandise.id,
// quantity: optimisticQuantity + change
// };
// await formAction(updatedPayload);
// };
return (
<form action={async () => {
const change = type === 'plus' ? 1 : -1;
setOptimisticQuantity(change);
onQuantityChange(optimisticQuantity + change);
const updatedPayload = {
lineId: item.id,
variantId: item.merchandise.id,
quantity: optimisticQuantity + change
};
const actionWithVariant = formAction.bind(null, updatedPayload);
await actionWithVariant();
}}>
<SubmitButton type={type} pending={!!message} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}
export function EditItemQuantity({ item }: { item: CartItem }) {
const [quantity, setQuantity] = useState(item.quantity);
const handleQuantityChange = (newQuantity: number) => setQuantity(newQuantity);
return (
<div className="flex flex-row items-center ml-auto border rounded-full h-9 border-neutral-200 dark:border-neutral-700">
<EditItemQuantityButton item={{ ...item, quantity }} type="minus" onQuantityChange={handleQuantityChange} />
<p className="w-6 text-center">
<span className="w-full text-sm">{quantity}</span>
</p>
<EditItemQuantityButton item={{ ...item, quantity }} type="plus" onQuantityChange={handleQuantityChange} />
</div>
);
}

View File

@@ -10,8 +10,8 @@ import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { Fragment, useEffect, useRef, useState } from 'react'; import { Fragment, useEffect, useRef, useState } from 'react';
import CloseCart from './close-cart'; import CloseCart from './close-cart';
import { DeleteItemButton } from './delete-item-button'; import DeleteItemButton from './delete-item-button';
import { EditItemQuantity } from './edit-item-quantity-button'; import EditItemQuantityButton from './edit-item-quantity-button';
import OpenCart from './open-cart'; import OpenCart from './open-cart';
type MerchandiseSearchParams = { type MerchandiseSearchParams = {
@@ -64,7 +64,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
leaveFrom="translate-x-0" leaveFrom="translate-x-0"
leaveTo="translate-x-full" 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 md:w-[390px] dark:border-neutral-700 dark:bg-black/80 dark:text-white"> <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]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-lg font-semibold">My Cart</p> <p className="text-lg font-semibold">My Cart</p>
@@ -74,13 +74,13 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
</div> </div>
{!cart || cart.lines.length === 0 ? ( {!cart || cart.lines.length === 0 ? (
<div className="flex flex-col items-center justify-center w-full mt-20 overflow-hidden"> <div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
<ShoppingCartIcon className="h-16" /> <ShoppingCartIcon className="h-16" />
<p className="mt-6 text-2xl font-bold text-center">Your cart is empty.</p> <p className="mt-6 text-center text-2xl font-bold">Your cart is empty.</p>
</div> </div>
) : ( ) : (
<div className="flex flex-col justify-between h-full p-1 overflow-hidden"> <div className="flex h-full flex-col justify-between overflow-hidden p-1">
<ul className="flex-grow py-4 overflow-auto"> <ul className="flex-grow overflow-auto py-4">
{cart.lines.map((item, i) => { {cart.lines.map((item, i) => {
const merchandiseSearchParams = {} as MerchandiseSearchParams; const merchandiseSearchParams = {} as MerchandiseSearchParams;
@@ -98,9 +98,9 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
return ( return (
<li <li
key={i} key={i}
className="flex flex-col w-full border-b border-neutral-300 dark:border-neutral-700" className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
> >
<div className="relative flex flex-row justify-between w-full px-1 py-4"> <div className="relative flex w-full flex-row justify-between px-1 py-4">
<div className="absolute z-40 -mt-2 ml-[55px]"> <div className="absolute z-40 -mt-2 ml-[55px]">
<DeleteItemButton item={item} /> <DeleteItemButton item={item} />
</div> </div>
@@ -109,9 +109,9 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
onClick={closeCart} onClick={closeCart}
className="z-30 flex flex-row space-x-4" className="z-30 flex flex-row space-x-4"
> >
<div className="relative w-16 h-16 overflow-hidden border rounded-md cursor-pointer border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800"> <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 <Image
className="object-cover w-full h-full" className="h-full w-full object-cover"
width={64} width={64}
height={64} height={64}
alt={ alt={
@@ -122,7 +122,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
/> />
</div> </div>
<div className="flex flex-col flex-1 text-base"> <div className="flex flex-1 flex-col text-base">
<span className="leading-tight"> <span className="leading-tight">
{item.merchandise.product.title} {item.merchandise.product.title}
</span> </span>
@@ -133,13 +133,19 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
) : null} ) : null}
</div> </div>
</Link> </Link>
<div className="flex flex-col justify-between h-16"> <div className="flex h-16 flex-col justify-between">
<Price <Price
className="flex justify-end space-y-2 text-sm text-right" className="flex justify-end space-y-2 text-right text-sm"
amount={item.cost.totalAmount.amount} amount={item.cost.totalAmount.amount}
currencyCode={item.cost.totalAmount.currencyCode} currencyCode={item.cost.totalAmount.currencyCode}
/> />
<EditItemQuantity item={item} /> <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" />
</div>
</div> </div>
</div> </div>
</li> </li>
@@ -147,22 +153,22 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
})} })}
</ul> </ul>
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400"> <div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
<div className="flex items-center justify-between pb-1 mb-3 border-b border-neutral-200 dark:border-neutral-700"> <div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
<p>Taxes</p> <p>Taxes</p>
<Price <Price
className="text-base text-right text-black dark:text-white" className="text-right text-base text-black dark:text-white"
amount={cart.cost.totalTaxAmount.amount} amount={cart.cost.totalTaxAmount.amount}
currencyCode={cart.cost.totalTaxAmount.currencyCode} currencyCode={cart.cost.totalTaxAmount.currencyCode}
/> />
</div> </div>
<div className="flex items-center justify-between pt-1 pb-1 mb-3 border-b border-neutral-200 dark:border-neutral-700"> <div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
<p>Shipping</p> <p>Shipping</p>
<p className="text-right">Calculated at checkout</p> <p className="text-right">Calculated at checkout</p>
</div> </div>
<div className="flex items-center justify-between pt-1 pb-1 mb-3 border-b border-neutral-200 dark:border-neutral-700"> <div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
<p>Total</p> <p>Total</p>
<Price <Price
className="text-base text-right text-black dark:text-white" className="text-right text-base text-black dark:text-white"
amount={cart.cost.totalAmount.amount} amount={cart.cost.totalAmount.amount}
currencyCode={cart.cost.totalAmount.currencyCode} currencyCode={cart.cost.totalAmount.currencyCode}
/> />
@@ -170,7 +176,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
</div> </div>
<a <a
href={cart.checkoutUrl} href={cart.checkoutUrl}
className="block w-full p-3 text-sm font-medium text-center text-white bg-blue-600 rounded-full opacity-90 hover:opacity-100" 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 Proceed to Checkout
</a> </a>

View File

@@ -19,7 +19,7 @@ const FooterMenuItem = ({ item }: { item: Menu }) => {
<Link <Link
href={item.path} href={item.path}
className={clsx( className={clsx(
'block p-2 text-lg underline-offset-4 hover:text-black hover:underline md:inline-block md:text-sm dark:hover:text-neutral-300', 'block p-2 text-lg underline-offset-4 hover:text-black hover:underline dark:hover:text-neutral-300 md:inline-block md:text-sm',
{ {
'text-black dark:text-neutral-300': active 'text-black dark:text-neutral-300': active
} }

View File

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

View File

@@ -6,7 +6,7 @@ import { Menu } from 'lib/shopify/types';
import Link from 'next/link'; import Link from 'next/link';
import { Suspense } from 'react'; import { Suspense } from 'react';
import MobileMenu from './mobile-menu'; import MobileMenu from './mobile-menu';
import Search, { SearchSkeleton } from './search'; import Search from './search';
const { SITE_NAME } = process.env; const { SITE_NAME } = process.env;
export default async function Navbar() { export default async function Navbar() {
@@ -15,9 +15,7 @@ export default async function Navbar() {
return ( return (
<nav className="relative flex items-center justify-between p-4 lg:px-6"> <nav className="relative flex items-center justify-between p-4 lg:px-6">
<div className="block flex-none md:hidden"> <div className="block flex-none md:hidden">
<Suspense fallback={null}> <MobileMenu menu={menu} />
<MobileMenu menu={menu} />
</Suspense>
</div> </div>
<div className="flex w-full items-center"> <div className="flex w-full items-center">
<div className="flex w-full md:w-1/3"> <div className="flex w-full md:w-1/3">
@@ -43,9 +41,7 @@ export default async function Navbar() {
) : null} ) : null}
</div> </div>
<div className="hidden justify-center md:flex md:w-1/3"> <div className="hidden justify-center md:flex md:w-1/3">
<Suspense fallback={<SearchSkeleton />}> <Search />
<Search />
</Suspense>
</div> </div>
<div className="flex justify-end md:w-1/3"> <div className="flex justify-end md:w-1/3">
<Suspense fallback={<OpenCart />}> <Suspense fallback={<OpenCart />}>

View File

@@ -3,11 +3,11 @@
import { Dialog, Transition } from '@headlessui/react'; import { Dialog, Transition } from '@headlessui/react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation'; import { usePathname, useSearchParams } from 'next/navigation';
import { Fragment, Suspense, useEffect, useState } from 'react'; import { Fragment, useEffect, useState } from 'react';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'; import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import { Menu } from 'lib/shopify/types'; import { Menu } from 'lib/shopify/types';
import Search, { SearchSkeleton } from './search'; import Search from './search';
export default function MobileMenu({ menu }: { menu: Menu[] }) { export default function MobileMenu({ menu }: { menu: Menu[] }) {
const pathname = usePathname(); const pathname = usePathname();
@@ -35,7 +35,7 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
<button <button
onClick={openMobileMenu} onClick={openMobileMenu}
aria-label="Open mobile menu" 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 md:hidden dark:border-neutral-700 dark:text-white" 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"
> >
<Bars3Icon className="h-4" /> <Bars3Icon className="h-4" />
</button> </button>
@@ -72,9 +72,7 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
</button> </button>
<div className="mb-4 w-full"> <div className="mb-4 w-full">
<Suspense fallback={<SearchSkeleton />}> <Search />
<Search />
</Suspense>
</div> </div>
{menu.length ? ( {menu.length ? (
<ul className="flex w-full flex-col"> <ul className="flex w-full flex-col">

View File

@@ -1,12 +1,19 @@
'use client'; 'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { createUrl } from 'lib/utils'; import { createUrl } from 'lib/utils';
import { useRouter, useSearchParams } from 'next/navigation';
export default function Search() { export default function Search() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [searchValue, setSearchValue] = useState('');
useEffect(() => {
setSearchValue(searchParams?.get('q') || '');
}, [searchParams, setSearchValue]);
function onSubmit(e: React.FormEvent<HTMLFormElement>) { function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
@@ -27,26 +34,12 @@ export default function Search() {
return ( return (
<form onSubmit={onSubmit} className="w-max-[550px] relative w-full lg:w-80 xl:w-full"> <form onSubmit={onSubmit} className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
<input <input
key={searchParams?.get('q')}
type="text" type="text"
name="search" name="search"
placeholder="Search for products..." placeholder="Search for products..."
autoComplete="off" autoComplete="off"
defaultValue={searchParams?.get('q') || ''} value={searchValue}
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" onChange={(e) => setSearchValue(e.target.value)}
/>
<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" 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"> <div className="absolute right-0 top-0 mr-3 flex h-full items-center">

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ import { AddToCart } from 'components/cart/add-to-cart';
import Price from 'components/price'; import Price from 'components/price';
import Prose from 'components/prose'; import Prose from 'components/prose';
import { Product } from 'lib/shopify/types'; import { Product } from 'lib/shopify/types';
import { Suspense } from 'react';
import { VariantSelector } from './variant-selector'; import { VariantSelector } from './variant-selector';
export function ProductDescription({ product }: { product: Product }) { export function ProductDescription({ product }: { product: Product }) {
@@ -17,9 +16,7 @@ export function ProductDescription({ product }: { product: Product }) {
/> />
</div> </div>
</div> </div>
<Suspense fallback={null}> <VariantSelector options={product.options} variants={product.variants} />
<VariantSelector options={product.options} variants={product.variants} />
</Suspense>
{product.descriptionHtml ? ( {product.descriptionHtml ? (
<Prose <Prose
@@ -28,9 +25,7 @@ export function ProductDescription({ product }: { product: Product }) {
/> />
) : null} ) : null}
<Suspense fallback={null}> <AddToCart variants={product.variants} availableForSale={product.availableForSale} />
<AddToCart variants={product.variants} availableForSale={product.availableForSale} />
</Suspense>
</> </>
); );
} }

View File

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

View File

@@ -258,7 +258,6 @@ export async function getCart(cartId: string): Promise<Cart | undefined> {
const res = await shopifyFetch<ShopifyCartOperation>({ const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery, query: getCartQuery,
variables: { cartId }, variables: { cartId },
tags: [TAGS.cart],
cache: 'no-store' cache: 'no-store'
}); });

View File

@@ -9,31 +9,3 @@ export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyUR
export const ensureStartsWith = (stringToCheck: string, startsWith: string) => export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`; 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. // Disabling on production builds because we're running checks on PRs via GitHub Actions.
ignoreDuringBuilds: true ignoreDuringBuilds: true
}, },
experimental: {
serverActions: true
},
images: { images: {
formats: ['image/avif', 'image/webp'], formats: ['image/avif', 'image/webp'],
remotePatterns: [ remotePatterns: [

View File

@@ -6,7 +6,7 @@
"pnpm": ">=7" "pnpm": ">=7"
}, },
"scripts": { "scripts": {
"dev": "next dev --turbo", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@@ -22,31 +22,30 @@
"*": "prettier --write --ignore-unknown" "*": "prettier --write --ignore-unknown"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^1.7.18", "@headlessui/react": "^1.7.15",
"@heroicons/react": "^2.1.3", "@heroicons/react": "^2.0.18",
"clsx": "^2.1.0", "clsx": "^2.0.0",
"geist": "^1.3.0", "next": "13.4.13-canary.15",
"next": "14.1.4",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0" "react-dom": "18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.11", "@tailwindcss/typography": "^0.5.9",
"@types/node": "20.11.30", "@types/node": "20.4.4",
"@types/react": "18.2.72", "@types/react": "18.2.16",
"@types/react-dom": "18.2.22", "@types/react-dom": "18.2.7",
"@vercel/git-hooks": "^1.0.0", "@vercel/git-hooks": "^1.0.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.14",
"eslint": "^8.57.0", "eslint": "^8.45.0",
"eslint-config-next": "^14.1.4", "eslint-config-next": "^13.4.12",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-unicorn": "^51.0.1", "eslint-plugin-unicorn": "^48.0.0",
"lint-staged": "^15.2.2", "lint-staged": "^13.2.3",
"postcss": "^8.4.38", "postcss": "^8.4.27",
"prettier": "3.2.5", "prettier": "3.0.1",
"prettier-plugin-tailwindcss": "^0.5.12", "prettier-plugin-tailwindcss": "^0.4.1",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.3.3",
"typescript": "5.4.3" "typescript": "5.1.6"
} }
} }

2345
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 = { module.exports = {
singleQuote: true, singleQuote: true,
arrowParens: 'always', arrowParens: 'always',
trailingComma: 'none', trailingComma: 'none',
printWidth: 100, printWidth: 100,
tabWidth: 2, 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: { theme: {
extend: { extend: {
fontFamily: { fontFamily: {
sans: ['var(--font-geist-sans)'] sans: ['var(--font-inter)']
}, },
keyframes: { keyframes: {
fadeIn: { fadeIn: {