4
0
forked from crowetic/commerce

Compare commits

..

32 Commits

Author SHA1 Message Date
Netanel Gilad
28f9a645bd
Update Wix fork repository (#1439) 2025-02-24 12:30:29 -06:00
Lee Robinson
9f4fdbb600 Merge branch 'main' of github.com:vercel/commerce 2025-02-21 12:39:39 -06:00
Lee Robinson
63725d82d9 Update deps 2025-02-21 12:39:31 -06:00
John Stringer
6946bf713a
Fix production base url (#1429) 2025-02-09 22:08:56 -06:00
Lee Robinson
7f8f9ff1a3 use cache 2025-02-09 11:38:22 -06:00
polykoi
675942141b
Adds Prodigy Commerce as a commerce provider. (#1415) 2025-01-21 10:06:30 -06:00
Lee Robinson
88762ba1bc Update deps 2024-12-06 08:23:35 -06:00
Kristian Arvidsson
386392be02
feat: added geins as a commerce provider (#1414) 2024-11-28 07:55:03 -06:00
Jieren Chen
3a26bae429
Add Fourthwall as a commerce provider (#1394) 2024-11-21 13:46:31 -06:00
Dharmveer
cf413a51fc
Update gallery.tsx (#1403)
🔧💡 Fix: Enhance product gallery layout in product view page

- 🖼️ Implemented `flex-wrap` for the sub-gallery images.
- 🛠️ Ensured images wrap automatically when they exceed 5 or 6, preventing overflow and maintaining responsive design.
-  Confirmed that the layout remains unaffected for galleries with 3 or fewer images.

This improvement enhances user experience by making sure large image sets are displayed without affecting screen layout.
2024-11-21 13:45:24 -06:00
Newton Lomar
8d4cc9a9a7
fixing response status code for no secret or wrong secret (#1397) 2024-10-27 15:21:33 -05:00
Omkar Kulkarni
ce004c05fa
Update tailwind.config.ts (#1388) 2024-10-18 13:13:08 -05:00
조계진
b7e9e1c7e3
Refactor <Prose> component (#1352) 2024-10-15 22:28:58 -05:00
Matthew Petrie
cb99695b72
Correct default cart tax currency (#1260) 2024-10-15 22:28:35 -05:00
Igor Shevchenko
815bea2c1a
chore: update readme (#1381) 2024-10-15 22:24:13 -05:00
Lee Robinson
64ca2ac790 Update to 15 RC 2 2024-10-15 22:07:55 -05:00
Lee Robinson
694c5c17ba
Move to next/form (#1369) 2024-08-13 13:33:05 -05:00
Matteo Frana
556aa77649
README: added React Bricks integration (#1367) 2024-08-07 17:38:07 -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
68 changed files with 2113 additions and 4220 deletions

View File

@ -1,6 +1,4 @@
COMPANY_NAME="Vercel Inc."
TWITTER_CREATOR="@vercel"
TWITTER_SITE="https://nextjs.org/commerce"
SITE_NAME="Next.js Commerce"
SHOPIFY_REVALIDATION_SECRET=""
SHOPIFY_STOREFRONT_ACCESS_TOKEN=""

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,35 +0,0 @@
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: Checkout repo
uses: actions/checkout@v4
- 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 --no-frozen-lockfile
- name: Run tests
run: pnpm test

1
.gitignore vendored
View File

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

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

@ -1,19 +1,10 @@
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=COMPANY_NAME,SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STORE_DOMAIN,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SITE_NAME,TWITTER_CREATOR,TWITTER_SITE)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=COMPANY_NAME,SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STORE_DOMAIN,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SITE_NAME)
# Next.js Commerce
A Next.js 14 and App Router-ready ecommerce template featuring:
A high-performance, 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,12 +18,16 @@ 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/))
- [Geins](https://github.com/geins-io/vercel-nextjs-commerce) ([Demo](https://geins-nextjs-commerce-starter.vercel.app/))
- [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
- [Prodigy Commerce](https://github.com/prodigycommerce/nextjs-commerce) ([Demo](https://prodigy-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/))
- [Wix](https://github.com/wix/headless-templates/tree/main/nextjs/commerce) ([Demo](https://wix-nextjs-commerce.vercel.app/))
- [Fourthwall](https://github.com/FourthwallHQ/vercel-commerce) ([Demo](https://vercel-storefront.fourthwall.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).
@ -41,9 +36,13 @@ Vercel is happy to partner and work with any commerce provider to help them get
Integrations enable upgraded or additional functionality for Next.js Commerce
- [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.
- [React Bricks](https://github.com/ReactBricks/nextjs-commerce-rb) ([Demo](https://nextjs-commerce.reactbricks.com/))
- Edit pages, product details, and footer content visually using [React Bricks](https://www.reactbricks.com) visual headless CMS.
## Running locally
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.
@ -73,4 +72,4 @@ Your app should now be running on [localhost:3000](http://localhost:3000/).
## 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

@ -1,8 +1,6 @@
import OpengraphImage from 'components/opengraph-image';
import { getPage } from 'lib/shopify';
export const runtime = 'edge';
export default async function Image({ params }: { params: { page: string } }) {
const page = await getPage(params.page);
const title = page.seo?.title || page.title;

View File

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

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

@ -1,6 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
@plugin "@tailwindcss/container-queries";
@plugin "@tailwindcss/typography";
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@media (prefers-color-scheme: dark) {
html {
@ -17,5 +28,5 @@
a,
input,
button {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900;
@apply focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900;
}

View File

@ -1,15 +1,14 @@
import Navbar from 'components/layout/navbar';
import { GeistSans } from 'geist/font';
import { ensureStartsWith } from 'lib/utils';
import { ReactNode, Suspense } from 'react';
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 { ReactNode } from 'react';
import { Toaster } from 'sonner';
import './globals.css';
import { baseUrl } from 'lib/utils';
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000';
const twitterCreator = TWITTER_CREATOR ? ensureStartsWith(TWITTER_CREATOR, '@') : undefined;
const twitterSite = TWITTER_SITE ? ensureStartsWith(TWITTER_SITE, 'https://') : undefined;
const { SITE_NAME } = process.env;
export const metadata = {
metadataBase: new URL(baseUrl),
@ -20,25 +19,28 @@ export const metadata = {
robots: {
follow: true,
index: true
},
...(twitterCreator &&
twitterSite && {
twitter: {
card: 'summary_large_image',
creator: twitterCreator,
site: twitterSite
}
})
};
export default async function RootLayout({ children }: { children: ReactNode }) {
export default async function RootLayout({
children
}: {
children: ReactNode;
}) {
// Don't await the fetch, pass the Promise to the context provider
const cart = getCart();
return (
<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">
<CartProvider cartPromise={cart}>
<Navbar />
<Suspense>
<main>{children}</main>
</Suspense>
<main>
{children}
<Toaster closeButton />
<WelcomeToast />
</main>
</CartProvider>
</body>
</html>
);

View File

@ -1,7 +1,5 @@
import OpengraphImage from 'components/opengraph-image';
export const runtime = 'edge';
export default async function Image() {
return await OpengraphImage();
}

View File

@ -1,27 +1,21 @@
import { Carousel } from 'components/carousel';
import { ThreeItemGrid } from 'components/grid/three-items';
import Footer from 'components/layout/footer';
import { Suspense } from 'react';
export const runtime = 'edge';
export const metadata = {
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
description:
'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
openGraph: {
type: 'website'
}
};
export default async function HomePage() {
export default function HomePage() {
return (
<>
<ThreeItemGrid />
<Suspense>
<Carousel />
<Suspense>
<Footer />
</Suspense>
</Suspense>
</>
);
}

View File

@ -1,23 +1,21 @@
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';
import { Suspense } from 'react';
export const runtime = 'edge';
export async function generateMetadata({
params
}: {
params: { handle: string };
export async function generateMetadata(props: {
params: Promise<{ handle: string }>;
}): Promise<Metadata> {
const params = await props.params;
const product = await getProduct(params.handle);
if (!product) return notFound();
@ -51,7 +49,8 @@ export async function generateMetadata({
};
}
export default async function ProductPage({ params }: { params: { handle: string } }) {
export default async function ProductPage(props: { params: Promise<{ handle: string }> }) {
const params = await props.params;
const product = await getProduct(params.handle);
if (!product) return notFound();
@ -74,14 +73,14 @@ export default async function ProductPage({ params }: { params: { handle: string
};
return (
<>
<ProductProvider>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(productJsonLd)
}}
/>
<div className="mx-auto max-w-screen-2xl px-4">
<div className="mx-auto max-w-(--breakpoint-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="h-full w-full basis-full lg:basis-4/6">
<Suspense
@ -90,7 +89,7 @@ export default async function ProductPage({ params }: { params: { handle: string
}
>
<Gallery
images={product.images.map((image: Image) => ({
images={product.images.slice(0, 5).map((image: Image) => ({
src: image.url,
altText: image.altText
}))}
@ -99,17 +98,15 @@ export default async function ProductPage({ params }: { params: { handle: string
</div>
<div className="basis-full lg:basis-2/6">
<Suspense fallback={null}>
<ProductDescription product={product} />
</Suspense>
</div>
</div>
<Suspense>
<RelatedProducts id={product.id} />
</Suspense>
</div>
<Suspense>
<Footer />
</Suspense>
</>
</ProductProvider>
);
}
@ -127,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

@ -1,6 +1,4 @@
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000';
import { baseUrl } from 'lib/utils';
export default function robots() {
return {

View File

@ -1,9 +1,11 @@
import OpengraphImage from 'components/opengraph-image';
import { getCollection } from 'lib/shopify';
export const runtime = 'edge';
export default async function Image({ params }: { params: { collection: string } }) {
export default async function Image({
params
}: {
params: { collection: string };
}) {
const collection = await getCollection(params.collection);
const title = collection?.seo?.title || collection?.title;

View File

@ -6,13 +6,10 @@ 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
}: {
params: { collection: string };
export async function generateMetadata(props: {
params: Promise<{ collection: string }>;
}): Promise<Metadata> {
const params = await props.params;
const collection = await getCollection(params.collection);
if (!collection) return notFound();
@ -24,13 +21,12 @@ export async function generateMetadata({
};
}
export default async function CategoryPage({
params,
searchParams
}: {
params: { collection: string };
searchParams?: { [key: string]: string | string[] | undefined };
export default async function CategoryPage(props: {
params: Promise<{ collection: string }>;
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParams = await props.searchParams;
const params = await props.params;
const { sort } = searchParams as { [key: string]: string };
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });

View File

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

View File

@ -2,21 +2,30 @@ 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 ChildrenWrapper from './children-wrapper';
import { Suspense } from 'react';
export default function SearchLayout({ children }: { children: React.ReactNode }) {
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 md:flex-row dark:text-white">
<>
<div className="mx-auto flex max-w-(--breakpoint-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">
<Suspense fallback={null}>
<ChildrenWrapper>{children}</ChildrenWrapper>
</Suspense>
</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

@ -2,14 +2,17 @@ import Grid from 'components/grid';
export default function Loading() {
return (
<>
<div className="mb-4 h-6" />
<Grid className="grid-cols-2 lg:grid-cols-3">
{Array(12)
.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,18 +3,15 @@ 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.'
};
export default async function SearchPage({
searchParams
}: {
searchParams?: { [key: string]: string | string[] | undefined };
export default async function SearchPage(props: {
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParams = await props.searchParams;
const { sort, q: searchValue } = searchParams as { [key: string]: string };
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;

View File

@ -1,5 +1,5 @@
import { getCollections, getPages, getProducts } from 'lib/shopify';
import { validateEnvironmentVariables } from 'lib/utils';
import { baseUrl, validateEnvironmentVariables } from 'lib/utils';
import { MetadataRoute } from 'next';
type Route = {
@ -7,9 +7,7 @@ type Route = {
lastModified: string;
};
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();
@ -43,7 +41,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
let fetchedRoutes: Route[] = [];
try {
fetchedRoutes = (await Promise.all([collectionsPromise, productsPromise, pagesPromise])).flat();
fetchedRoutes = (
await Promise.all([collectionsPromise, productsPromise, pagesPromise])
).flat();
} catch (error) {
throw JSON.stringify(error, null, 2);
}

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,46 +1,51 @@
'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 { redirect } from 'next/navigation';
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);
}
export async function addItem(
prevState: any,
selectedVariantId: string | undefined
) {
if (!selectedVariantId) {
return 'Missing product variant ID';
return 'Error adding item to cart';
}
try {
await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]);
await addToCart([{ merchandiseId: selectedVariantId, quantity: 1 }]);
revalidateTag(TAGS.cart);
} catch (e) {
return 'Error adding item to cart';
}
}
export async function removeItem(prevState: any, lineId: string) {
const cartId = cookies().get('cartId')?.value;
export async function removeItem(prevState: any, merchandiseId: string) {
try {
const cart = await getCart();
if (!cartId) {
return 'Missing cart ID';
if (!cart) {
return 'Error fetching cart';
}
try {
await removeFromCart(cartId, [lineId]);
const lineItem = cart.lines.find(
(line) => line.merchandise.id === merchandiseId
);
if (lineItem && lineItem.id) {
await removeFromCart([lineItem.id]);
revalidateTag(TAGS.cart);
} else {
return 'Item not found in cart';
}
} catch (e) {
return 'Error removing item from cart';
}
@ -49,35 +54,53 @@ export async function removeItem(prevState: any, lineId: string) {
export async function updateItemQuantity(
prevState: any,
payload: {
lineId: string;
variantId: string;
merchandiseId: string;
quantity: number;
}
) {
const cartId = cookies().get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
}
const { lineId, variantId, quantity } = payload;
const { merchandiseId, quantity } = payload;
try {
if (quantity === 0) {
await removeFromCart(cartId, [lineId]);
revalidateTag(TAGS.cart);
return;
const cart = await getCart();
if (!cart) {
return 'Error fetching cart';
}
await updateCart(cartId, [
const lineItem = cart.lines.find(
(line) => line.merchandise.id === merchandiseId
);
if (lineItem && lineItem.id) {
if (quantity === 0) {
await removeFromCart([lineItem.id]);
} else {
await updateCart([
{
id: lineId,
merchandiseId: variantId,
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([{ merchandiseId, quantity }]);
}
revalidateTag(TAGS.cart);
} catch (e) {
console.error(e);
return 'Error updating item quantity';
}
}
export async function redirectToCheckout() {
let cart = await getCart();
redirect(cart!.checkoutUrl);
}
export async function createCartAndSetCookie() {
let cart = await createCart();
(await cookies()).set('cartId', cart.id!);
}

View File

@ -3,10 +3,10 @@
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 { useSearchParams } from 'next/navigation';
import { useFormState, useFormStatus } from 'react-dom';
import { useProduct } from 'components/product/product-context';
import { Product, ProductVariant } from 'lib/shopify/types';
import { useActionState } from 'react';
import { useCart } from './cart-context';
function SubmitButton({
availableForSale,
@ -15,14 +15,13 @@ function SubmitButton({
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)}>
<button disabled className={clsx(buttonClasses, disabledClasses)}>
Out Of Stock
</button>
);
@ -32,7 +31,7 @@ function SubmitButton({
return (
<button
aria-label="Please select an option"
aria-disabled
disabled
className={clsx(buttonClasses, disabledClasses)}
>
<div className="absolute left-0 ml-4">
@ -45,45 +44,48 @@ function SubmitButton({
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
'hover:opacity-90': true
})}
>
<div className="absolute left-0 ml-4">
{pending ? <LoadingDots className="mb-3 bg-white" /> : <PlusIcon className="h-5" />}
<PlusIcon className="h-5" />
</div>
Add To Cart
</button>
);
}
export function AddToCart({
variants,
availableForSale
}: {
variants: ProductVariant[];
availableForSale: boolean;
}) {
const [message, formAction] = useFormState(addItem, null);
const searchParams = useSearchParams();
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
export function AddToCart({ product }: { product: Product }) {
const { variants, availableForSale } = product;
const { addCartItem } = useCart();
const { state } = useProduct();
const [message, formAction] = useActionState(addItem, null);
const variant = variants.find((variant: ProductVariant) =>
variant.selectedOptions.every(
(option) => option.value === searchParams.get(option.name.toLowerCase())
(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 addItemAction = formAction.bind(null, selectedVariantId);
const finalVariant = variants.find(
(variant) => variant.id === selectedVariantId
)!;
return (
<form action={actionWithVariant}>
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} />
<form
action={async () => {
addCartItem(finalVariant, product);
addItemAction();
}}
>
<SubmitButton
availableForSale={availableForSale}
selectedVariantId={selectedVariantId}
/>
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>

View File

@ -0,0 +1,238 @@
'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 = {
cartPromise: Promise<Cart | undefined>;
};
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>;
}) {
return (
<CartContext.Provider value={{ cartPromise }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart must be used within a CartProvider');
}
const initialCart = use(context.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 } });
};
return useMemo(
() => ({
cart: optimisticCart,
updateCartItem,
addCartItem
}),
[optimisticCart]
);
}

View File

@ -1,10 +0,0 @@
import { XMarkIcon } from '@heroicons/react/24/outline';
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)} />
</div>
);
}

View File

@ -1,47 +1,35 @@
'use client';
import { XMarkIcon } from '@heroicons/react/24/outline';
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 { useActionState } from 'react';
function SubmitButton() {
const { pending } = useFormStatus();
export function DeleteItemButton({
item,
optimisticUpdate
}: {
item: CartItem;
optimisticUpdate: any;
}) {
const [message, formAction] = useActionState(removeItem, null);
const merchandiseId = item.merchandise.id;
const removeItemAction = formAction.bind(null, merchandiseId);
return (
<form
action={async () => {
optimisticUpdate(merchandiseId, 'delete');
removeItemAction();
}}
>
<button
type="submit"
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
aria-label="Remove cart item"
aria-disabled={pending}
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
}
)}
className="flex h-[24px] w-[24px] items-center justify-center rounded-full bg-neutral-500"
>
{pending ? (
<LoadingDots className="bg-white" />
) : (
<XMarkIcon className="hover:text-accent-3 mx-[1px] h-4 w-4 text-white dark:text-black" />
)}
<XMarkIcon className="mx-[1px] h-4 w-4 text-white dark:text-black" />
</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>

View File

@ -3,94 +3,59 @@
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { updateItemQuantity } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import type { CartItem } from 'lib/shopify/types';
import { useOptimistic, useState } from 'react';
import { useFormState, useFormStatus } from 'react-dom';
import { useActionState } from 'react';
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
const { pending } = useFormStatus();
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}
aria-label={
type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'
}
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': pending,
'ml-auto': type === 'minus'
}
)}
>
{pending ? (
<LoadingDots className="bg-black dark:bg-white" />
) : type === 'plus' ? (
<PlusIcon className="w-4 h-4 dark:text-neutral-500" />
{type === 'plus' ? (
<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>
);
}
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);
// };
export function EditItemQuantityButton({
item,
type,
optimisticUpdate
}: {
item: CartItem;
type: 'plus' | 'minus';
optimisticUpdate: any;
}) {
const [message, formAction] = useActionState(updateItemQuantity, null);
const payload = {
merchandiseId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
};
const updateItemQuantityAction = formAction.bind(null, payload);
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} />
<form
action={async () => {
optimisticUpdate(payload.merchandiseId, type);
updateItemQuantityAction();
}}
>
<SubmitButton type={type} />
<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

@ -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

@ -1,38 +1,48 @@
'use client';
import clsx from 'clsx';
import { Dialog, Transition } from '@headlessui/react';
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
import { ShoppingCartIcon, XMarkIcon } 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 CloseCart from './close-cart';
import { useFormStatus } from 'react-dom';
import { createCartAndSetCookie, redirectToCheckout } from './actions';
import { useCart } from './cart-context';
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';
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]);
@ -67,28 +77,39 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
<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>
</div>
{!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" />
<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 className="flex flex-col justify-between h-full p-1 overflow-hidden">
<ul className="flex-grow py-4 overflow-auto">
{cart.lines.map((item, i) => {
const merchandiseSearchParams = {} as MerchandiseSearchParams;
<div className="flex h-full flex-col justify-between overflow-hidden p-1">
<ul className="grow overflow-auto py-4">
{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 }) => {
item.merchandise.selectedOptions.forEach(
({ name, value }) => {
if (value !== DEFAULT_OPTION) {
merchandiseSearchParams[name.toLowerCase()] = value;
merchandiseSearchParams[name.toLowerCase()] =
value;
}
});
}
);
const merchandiseUrl = createUrl(
`/product/${item.merchandise.product.handle}`,
@ -98,48 +119,74 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
return (
<li
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="absolute z-40 -mt-2 ml-[55px]">
<DeleteItemButton item={item} />
<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 flex flex-row space-x-4"
className="z-30 ml-2 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">
<Image
className="object-cover w-full h-full"
width={64}
height={64}
alt={
item.merchandise.product.featuredImage.altText ||
item.merchandise.product.title
}
src={item.merchandise.product.featuredImage.url}
/>
</div>
<div className="flex flex-col flex-1 text-base">
<div className="flex flex-1 flex-col text-base">
<span className="leading-tight">
{item.merchandise.product.title}
</span>
{item.merchandise.title !== DEFAULT_OPTION ? (
{item.merchandise.title !==
DEFAULT_OPTION ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{item.merchandise.title}
</p>
) : null}
</div>
</Link>
<div className="flex flex-col justify-between h-16">
</div>
<div className="flex h-16 flex-col justify-between">
<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}
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"
optimisticUpdate={updateCartItem}
/>
<p className="w-6 text-center">
<span className="w-full text-sm">
{item.quantity}
</span>
</p>
<EditItemQuantityButton
item={item}
type="plus"
optimisticUpdate={updateCartItem}
/>
</div>
</div>
</div>
</li>
@ -147,33 +194,30 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
})}
</ul>
<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>
<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}
currencyCode={cart.cost.totalTaxAmount.currencyCode}
/>
</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 className="text-right">Calculated at checkout</p>
</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>
<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}
currencyCode={cart.cost.totalAmount.currencyCode}
/>
</div>
</div>
<a
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"
>
Proceed to Checkout
</a>
<form action={redirectToCheckout}>
<CheckoutButton />
</form>
</div>
)}
</Dialog.Panel>
@ -183,3 +227,30 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
</>
);
}
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
)}
/>
</div>
);
}
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,11 +11,11 @@ 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 ? (
<div className="absolute right-0 top-0 -mr-2 -mt-2 h-4 w-4 rounded bg-blue-600 text-[11px] font-medium text-white">
<div className="absolute right-0 top-0 -mr-2 -mt-2 h-4 w-4 rounded-sm bg-blue-600 text-[11px] font-medium text-white">
{quantity}
</div>
) : null}

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-(--breakpoint-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

@ -19,7 +19,7 @@ const Label = ({
})}
>
<div className="flex items-center rounded-full border bg-white/70 p-1 text-xs font-semibold text-black backdrop-blur-md dark:border-neutral-800 dark:bg-black/70 dark:text-white">
<h3 className="mr-4 line-clamp-2 flex-grow pl-2 leading-none tracking-tight">{title}</h3>
<h3 className="mr-4 line-clamp-2 grow pl-2 leading-none tracking-tight">{title}</h3>
<Price
className="flex-none rounded-full bg-blue-600 p-2 text-white"
amount={amount}

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);
@ -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

@ -10,7 +10,7 @@ const { COMPANY_NAME, SITE_NAME } = process.env;
export default async function Footer() {
const currentYear = new Date().getFullYear();
const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
const skeleton = 'w-full h-6 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700';
const skeleton = 'w-full h-6 animate-pulse rounded-sm bg-neutral-200 dark:bg-neutral-700';
const menu = await getMenu('next-js-frontend-footer-menu');
const copyrightName = COMPANY_NAME || SITE_NAME || '';
@ -56,10 +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">
<a href="https://vercel.com" className="text-black dark:text-white">
Crafted by Vercel
Created by Vercel
</a>
</p>
</div>

View File

@ -1,5 +1,4 @@
import Cart from 'components/cart';
import OpenCart from 'components/cart/open-cart';
import CartModal from 'components/cart/modal';
import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/shopify';
import { Menu } from 'lib/shopify/types';
@ -7,9 +6,10 @@ import Link from 'next/link';
import { Suspense } from 'react';
import MobileMenu from './mobile-menu';
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 (
@ -21,7 +21,11 @@ export default async function Navbar() {
</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}
@ -33,6 +37,7 @@ export default async function Navbar() {
<li key={item.title}>
<Link
href={item.path}
prefetch={true}
className="text-neutral-500 underline-offset-4 hover:text-black hover:underline dark:text-neutral-400 dark:hover:text-neutral-300"
>
{item.title}
@ -48,9 +53,7 @@ export default async function Navbar() {
</Suspense>
</div>
<div className="flex justify-end md:w-1/3">
<Suspense fallback={<OpenCart />}>
<Cart />
</Suspense>
<CartModal />
</div>
</div>
</nav>

View File

@ -83,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,44 +1,27 @@
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { createUrl } from 'lib/utils';
import { useRouter, useSearchParams } from 'next/navigation';
import Form from 'next/form';
import { useSearchParams } from 'next/navigation';
export default function Search() {
const router = useRouter();
const searchParams = useSearchParams();
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const val = e.target as HTMLFormElement;
const search = val.search as HTMLInputElement;
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"
defaultValue={searchParams?.get('q') || ''}
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="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>
</Form>
);
}

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

@ -9,7 +9,7 @@ async function CollectionList() {
return <FilterList list={collections} title="Collections" />;
}
const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded';
const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded-sm';
const activeAndTitles = 'bg-neutral-800 dark:bg-neutral-300';
const items = 'bg-neutral-400 dark:bg-neutral-700';

View File

@ -42,7 +42,7 @@ export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
onClick={() => {
setOpenSelect(!openSelect);
}}
className="flex w-full items-center justify-between rounded border border-black/30 px-4 py-2 text-sm dark:border-white/30"
className="flex w-full items-center justify-between rounded-sm border border-black/30 px-4 py-2 text-sm dark:border-white/30"
>
<div>{active}</div>
<ChevronDownIcon className="h-4" />

View File

@ -28,12 +28,12 @@ export default function FilterList({ list, title }: { list: ListItem[]; title?:
<ul className="hidden md:block">
<Suspense fallback={null}>
<FilterItemList list={list} />
</Suspense>{' '}
</Suspense>
</ul>
<ul className="md:hidden">
<Suspense fallback={null}>
<FilterItemDropdown list={list} />
</Suspense>{' '}
</Suspense>
</ul>
</nav>
</>

View File

@ -1,11 +1,15 @@
import { ImageResponse } from 'next/og';
import LogoIcon from './icons/logo';
import { join } from 'path';
import { readFile } from 'fs/promises';
export type Props = {
title?: string;
};
export default async function OpengraphImage(props?: Props): Promise<ImageResponse> {
export default async function OpengraphImage(
props?: Props
): Promise<ImageResponse> {
const { title } = {
...{
title: process.env.SITE_NAME
@ -13,6 +17,9 @@ export default async function OpengraphImage(props?: Props): Promise<ImageRespon
...props
};
const file = await readFile(join(process.cwd(), './fonts/Inter-Bold.ttf'));
const font = Uint8Array.from(file).buffer;
return new ImageResponse(
(
<div tw="flex h-full w-full flex-col items-center justify-center bg-black">
@ -28,9 +35,7 @@ export default async function OpengraphImage(props?: Props): Promise<ImageRespon
fonts: [
{
name: 'Inter',
data: await fetch(new URL('../fonts/Inter-Bold.ttf', import.meta.url)).then((res) =>
res.arrayBuffer()
),
data: font,
style: 'normal',
weight: 700
}

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
@ -42,43 +32,46 @@ 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
<div className="mx-auto flex h-11 items-center rounded-full border border-white bg-neutral-50/80 text-neutral-500 backdrop-blur-sm dark:border-black dark:bg-neutral-900/80">
<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}
</div>
{images.length > 1 ? (
<ul className="my-12 flex items-center justify-center gap-2 overflow-auto py-1 lg:mb-0">
<ul className="my-12 flex items-center flex-wrap 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

@ -2,7 +2,6 @@ import { AddToCart } from 'components/cart/add-to-cart';
import Price from 'components/price';
import Prose from 'components/prose';
import { Product } from 'lib/shopify/types';
import { Suspense } from 'react';
import { VariantSelector } from './variant-selector';
export function ProductDescription({ product }: { product: Product }) {
@ -17,20 +16,14 @@ export function ProductDescription({ product }: { product: Product }) {
/>
</div>
</div>
<Suspense fallback={null}>
<VariantSelector options={product.options} variants={product.variants} />
</Suspense>
{product.descriptionHtml ? (
<Prose
className="mb-6 text-sm leading-tight dark:text-white/[60%]"
html={product.descriptionHtml}
/>
) : null}
<Suspense fallback={null}>
<AddToCart variants={product.variants} availableForSale={product.availableForSale} />
</Suspense>
<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,30 +36,18 @@ export function VariantSelector({
}));
return options.map((option) => (
<dl className="mb-8" key={option.id}>
<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);
// 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]) =>
// 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)
)
@ -73,25 +58,26 @@ export function VariantSelector({
)
);
// The option is active if it's in the url params.
const isActive = searchParams.get(optionNameLowerCase) === value;
// The option is active if it's in the selected options.
const isActive = state[optionNameLowerCase] === value;
return (
<button
formAction={() => {
const newState = updateOption(optionNameLowerCase, value);
updateURL(newState);
}}
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 ':
'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':
'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 dark:before:bg-neutral-700':
!isAvailableForSale
}
)}
@ -102,5 +88,6 @@ export function VariantSelector({
})}
</dd>
</dl>
</form>
));
}

View File

@ -1,19 +1,13 @@
import clsx from 'clsx';
import type { FunctionComponent } from 'react';
interface TextProps {
html: string;
className?: string;
}
const Prose: FunctionComponent<TextProps> = ({ html, className }) => {
const Prose = ({ html, className }: { html: string; className?: string }) => {
return (
<div
className={clsx(
'prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline hover:prose-a:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white',
'prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline prose-a:hover:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white',
className
)}
dangerouslySetInnerHTML={{ __html: html as string }}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
};

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

@ -1,8 +1,16 @@
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
import {
HIDDEN_PRODUCT_TAG,
SHOPIFY_GRAPHQL_API_ENDPOINT,
TAGS
} from 'lib/constants';
import { isShopifyError } from 'lib/type-guards';
import { ensureStartsWith } from 'lib/utils';
import { revalidateTag } from 'next/cache';
import { headers } from 'next/headers';
import {
revalidateTag,
unstable_cacheTag as cacheTag,
unstable_cacheLife as cacheLife
} from 'next/cache';
import { cookies, headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import {
addToCartMutation,
@ -56,19 +64,17 @@ const domain = process.env.SHOPIFY_STORE_DOMAIN
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
type ExtractVariables<T> = T extends { variables: object }
? T['variables']
: never;
export async function shopifyFetch<T>({
cache = 'force-cache',
headers,
query,
tags,
variables
}: {
cache?: RequestCache;
headers?: HeadersInit;
query: string;
tags?: string[];
variables?: ExtractVariables<T>;
}): Promise<{ status: number; body: T } | never> {
try {
@ -82,9 +88,7 @@ export async function shopifyFetch<T>({
body: JSON.stringify({
...(query && { query }),
...(variables && { variables })
}),
cache,
...(tags && { next: { tags } })
})
});
const body = await result.json();
@ -114,7 +118,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);
};
@ -122,7 +126,7 @@ const reshapeCart = (cart: ShopifyCart): Cart => {
if (!cart.cost?.totalTaxAmount) {
cart.cost.totalTaxAmount = {
amount: '0.0',
currencyCode: 'USD'
currencyCode: cart.cost.totalAmount.currencyCode
};
}
@ -132,7 +136,9 @@ const reshapeCart = (cart: ShopifyCart): Cart => {
};
};
const reshapeCollection = (collection: ShopifyCollection): Collection | undefined => {
const reshapeCollection = (
collection: ShopifyCollection
): Collection | undefined => {
if (!collection) {
return undefined;
}
@ -163,7 +169,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}`
@ -171,8 +177,14 @@ const reshapeImages = (images: Connection<Image>, productTitle: string) => {
});
};
const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean = true) => {
if (!product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))) {
const reshapeProduct = (
product: ShopifyProduct,
filterHiddenProducts: boolean = true
) => {
if (
!product ||
(filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))
) {
return undefined;
}
@ -203,63 +215,64 @@ const reshapeProducts = (products: ShopifyProduct[]) => {
export async function createCart(): Promise<Cart> {
const res = await shopifyFetch<ShopifyCreateCartOperation>({
query: createCartMutation,
cache: 'no-store'
query: createCartMutation
});
return reshapeCart(res.body.data.cartCreate.cart);
}
export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const cartId = (await cookies()).get('cartId')?.value!;
const res = await shopifyFetch<ShopifyAddToCartOperation>({
query: addToCartMutation,
variables: {
cartId,
lines
},
cache: 'no-store'
}
});
return reshapeCart(res.body.data.cartLinesAdd.cart);
}
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
export async function removeFromCart(lineIds: string[]): Promise<Cart> {
const cartId = (await cookies()).get('cartId')?.value!;
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
query: removeFromCartMutation,
variables: {
cartId,
lineIds
},
cache: 'no-store'
}
});
return reshapeCart(res.body.data.cartLinesRemove.cart);
}
export async function updateCart(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const cartId = (await cookies()).get('cartId')?.value!;
const res = await shopifyFetch<ShopifyUpdateCartOperation>({
query: editCartItemsMutation,
variables: {
cartId,
lines
},
cache: 'no-store'
}
});
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
export async function getCart(cartId: string): Promise<Cart | undefined> {
export async function getCart(): Promise<Cart | undefined> {
const cartId = (await cookies()).get('cartId')?.value;
if (!cartId) {
return undefined;
}
const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery,
variables: { cartId },
tags: [TAGS.cart],
cache: 'no-store'
variables: { cartId }
});
// Old carts becomes `null` when you checkout.
@ -270,10 +283,15 @@ export async function getCart(cartId: string): Promise<Cart | undefined> {
return reshapeCart(res.body.data.cart);
}
export async function getCollection(handle: string): Promise<Collection | undefined> {
export async function getCollection(
handle: string
): Promise<Collection | undefined> {
'use cache';
cacheTag(TAGS.collections);
cacheLife('days');
const res = await shopifyFetch<ShopifyCollectionOperation>({
query: getCollectionQuery,
tags: [TAGS.collections],
variables: {
handle
}
@ -291,9 +309,12 @@ export async function getCollectionProducts({
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
'use cache';
cacheTag(TAGS.collections, TAGS.products);
cacheLife('days');
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
query: getCollectionProductsQuery,
tags: [TAGS.collections, TAGS.products],
variables: {
handle: collection,
reverse,
@ -306,13 +327,18 @@ export async function getCollectionProducts({
return [];
}
return reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products));
return reshapeProducts(
removeEdgesAndNodes(res.body.data.collection.products)
);
}
export async function getCollections(): Promise<Collection[]> {
'use cache';
cacheTag(TAGS.collections);
cacheLife('days');
const res = await shopifyFetch<ShopifyCollectionsOperation>({
query: getCollectionsQuery,
tags: [TAGS.collections]
query: getCollectionsQuery
});
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
const collections = [
@ -338,9 +364,12 @@ export async function getCollections(): Promise<Collection[]> {
}
export async function getMenu(handle: string): Promise<Menu[]> {
'use cache';
cacheTag(TAGS.collections);
cacheLife('days');
const res = await shopifyFetch<ShopifyMenuOperation>({
query: getMenuQuery,
tags: [TAGS.collections],
variables: {
handle
}
@ -349,7 +378,10 @@ export async function getMenu(handle: string): Promise<Menu[]> {
return (
res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({
title: item.title,
path: item.url.replace(domain, '').replace('/collections', '/search').replace('/pages', '')
path: item.url
.replace(domain, '')
.replace('/collections', '/search')
.replace('/pages', '')
})) || []
);
}
@ -372,9 +404,12 @@ export async function getPages(): Promise<Page[]> {
}
export async function getProduct(handle: string): Promise<Product | undefined> {
'use cache';
cacheTag(TAGS.products);
cacheLife('days');
const res = await shopifyFetch<ShopifyProductOperation>({
query: getProductQuery,
tags: [TAGS.products],
variables: {
handle
}
@ -383,10 +418,15 @@ export async function getProduct(handle: string): Promise<Product | undefined> {
return reshapeProduct(res.body.data.product, false);
}
export async function getProductRecommendations(productId: string): Promise<Product[]> {
export async function getProductRecommendations(
productId: string
): Promise<Product[]> {
'use cache';
cacheTag(TAGS.products);
cacheLife('days');
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
query: getProductRecommendationsQuery,
tags: [TAGS.products],
variables: {
productId
}
@ -404,9 +444,12 @@ export async function getProducts({
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
'use cache';
cacheTag(TAGS.products);
cacheLife('days');
const res = await shopifyFetch<ShopifyProductsOperation>({
query: getProductsQuery,
tags: [TAGS.products],
variables: {
query,
reverse,
@ -421,16 +464,24 @@ export async function getProducts({
export async function revalidate(req: NextRequest): Promise<NextResponse> {
// We always need to respond with a 200 status code to Shopify,
// otherwise it will continue to retry the request.
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
const topic = headers().get('x-shopify-topic') || 'unknown';
const collectionWebhooks = [
'collections/create',
'collections/delete',
'collections/update'
];
const productWebhooks = [
'products/create',
'products/delete',
'products/update'
];
const topic = (await headers()).get('x-shopify-topic') || 'unknown';
const secret = req.nextUrl.searchParams.get('secret');
const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
console.error('Invalid revalidation secret.');
return NextResponse.json({ status: 200 });
return NextResponse.json({ status: 401 });
}
if (!isCollectionUpdate && !isProductUpdate) {

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

@ -1,6 +1,13 @@
import { ReadonlyURLSearchParams } from 'next/navigation';
export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => {
export const baseUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: 'http://localhost:3000';
export const createUrl = (
pathname: string,
params: URLSearchParams | ReadonlyURLSearchParams
) => {
const paramsString = params.toString();
const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;
@ -8,10 +15,15 @@ export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyUR
};
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 requiredEnvironmentVariables = [
'SHOPIFY_STORE_DOMAIN',
'SHOPIFY_STOREFRONT_ACCESS_TOKEN'
];
const missingEnvironmentVariables = [] as string[];
requiredEnvironmentVariables.forEach((envVar) => {

View File

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

View File

@ -1,26 +0,0 @@
/** @type {import('next').NextConfig} */
module.exports = {
eslint: {
// Disabling on production builds because we're running checks on PRs via GitHub Actions.
ignoreDuringBuilds: true
},
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.shopify.com',
pathname: '/s/files/**'
}
]
},
async redirects() {
return [
{
source: '/password',
destination: '/',
permanent: true
}
];
}
};

19
next.config.ts Normal file
View File

@ -0,0 +1,19 @@
export default {
experimental: {
ppr: true,
inlineCss: true,
useCache: true,
reactOwnerStack: true,
newDevOverlay: true
},
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.shopify.com',
pathname: '/s/files/**'
}
]
}
};

View File

@ -1,52 +1,34 @@
{
"private": true,
"packageManager": "pnpm@8.2.0",
"engines": {
"node": ">=18",
"pnpm": ">=7"
},
"scripts": {
"dev": "next dev --turbo",
"dev": "next dev --turbopack",
"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.18",
"@heroicons/react": "^2.1.3",
"clsx": "^2.1.0",
"geist": "^1.3.0",
"next": "14.1.4",
"react": "18.2.0",
"react-dom": "18.2.0"
"@headlessui/react": "^2.2.0",
"@heroicons/react": "^2.2.0",
"clsx": "^2.1.1",
"geist": "^1.3.1",
"next": "15.2.0-canary.67",
"react": "19.0.0",
"react-dom": "19.0.0",
"sonner": "^2.0.1"
},
"devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.11",
"@types/node": "20.11.30",
"@types/react": "18.2.72",
"@types/react-dom": "18.2.22",
"@vercel/git-hooks": "^1.0.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-next": "^14.1.4",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-unicorn": "^51.0.1",
"lint-staged": "^15.2.2",
"postcss": "^8.4.38",
"prettier": "3.2.5",
"prettier-plugin-tailwindcss": "^0.5.12",
"tailwindcss": "^3.4.1",
"typescript": "5.4.3"
"@tailwindcss/postcss": "^4.0.8",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "22.13.4",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"postcss": "^8.5.3",
"prettier": "3.5.1",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.8",
"typescript": "5.7.3"
}
}

4346
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

6
postcss.config.mjs Normal file
View File

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

View File

@ -1,9 +0,0 @@
/** @type {import('prettier').Config} */
module.exports = {
singleQuote: true,
arrowParens: 'always',
trailingComma: 'none',
printWidth: 100,
tabWidth: 2,
plugins: ['prettier-plugin-tailwindcss']
};

View File

@ -1,54 +0,0 @@
const plugin = require('tailwindcss/plugin');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-geist-sans)']
},
keyframes: {
fadeIn: {
from: { opacity: 0 },
to: { opacity: 1 }
},
marquee: {
'0%': { transform: 'translateX(0%)' },
'100%': { transform: 'translateX(-100%)' }
},
blink: {
'0%': { opacity: 0.2 },
'20%': { opacity: 1 },
'100% ': { opacity: 0.2 }
}
},
animation: {
fadeIn: 'fadeIn .3s ease-in-out',
carousel: 'marquee 60s linear infinite',
blink: 'blink 1.4s both infinite'
}
}
},
future: {
hoverOnlyWhenSupported: true
},
plugins: [
require('@tailwindcss/container-queries'),
require('@tailwindcss/typography'),
plugin(({ matchUtilities, theme }) => {
matchUtilities(
{
'animation-delay': (value) => {
return {
'animation-delay': value
};
}
},
{
values: theme('transitionDelay')
}
);
})
]
};

View File

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