Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5304683150 | ||
|
|
0c3283f591 | ||
|
|
47a1c3eea1 | ||
|
|
d12fb77ef9 | ||
|
|
59a00fe9ea | ||
|
|
9c3cc1f8b6 | ||
|
|
3a18f9a098 | ||
|
|
2fe1527bea | ||
|
|
2448f5201c | ||
|
|
80e48001d9 | ||
|
|
1f47796529 | ||
|
|
ece49c4265 | ||
|
|
2035fa0431 | ||
|
|
5cb1245432 | ||
|
|
d9f875b539 | ||
|
|
b0f6e94fba | ||
|
|
d8703e8140 | ||
|
|
18167d22f3 | ||
|
|
4993fca356 | ||
|
|
e9643a546e | ||
|
|
6a153b627c | ||
|
|
528ad9b8ce | ||
|
|
fc92f70c00 | ||
|
|
e8c0ee04fc | ||
|
|
ec838fd4e6 | ||
|
|
5f2348d89d | ||
|
|
74b5a25120 | ||
|
|
857a1df0f6 | ||
|
|
3f1a4f65ae | ||
|
|
c6eb7a30f9 | ||
|
|
faa7491a55 | ||
|
|
c3f3936732 | ||
|
|
a11b6ad83b | ||
|
|
36360a5fc3 | ||
|
|
469cd7bffd | ||
|
|
ef92d578cd | ||
|
|
0e13cfc3dd | ||
|
|
9044baf44e | ||
|
|
80bb15a7dc | ||
|
|
9e1388f974 | ||
|
|
9c813577e1 | ||
|
|
0f700e2d07 | ||
|
|
1d5242eef3 | ||
|
|
71c9cb96fa | ||
|
|
ee534492a0 | ||
|
|
36b28b4aab | ||
|
|
455a7327f3 | ||
|
|
10b1d4bbae | ||
|
|
45afbc548e | ||
|
|
7ae036b385 | ||
|
|
cd8f4c6b4c | ||
|
|
1449489c3c | ||
|
|
cccf6afdeb | ||
|
|
049d903a5b | ||
|
|
61b134a66c | ||
|
|
7dc7e6d6e4 | ||
|
|
586f9bfe56 | ||
|
|
6342808f94 | ||
|
|
69a68dd408 | ||
|
|
fa4c0fb8b8 |
@@ -2,6 +2,6 @@ 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=
|
||||
SHOPIFY_STORE_DOMAIN=
|
||||
SHOPIFY_REVALIDATION_SECRET=""
|
||||
SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
|
||||
SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
|
||||
|
||||
15
.github/workflows/test.yml
vendored
15
.github/workflows/test.yml
vendored
@@ -1,15 +1,18 @@
|
||||
name: test
|
||||
on: pull_request
|
||||
|
||||
# Cancel in progress workflows on pull_requests.
|
||||
# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cancel running workflows
|
||||
uses: styfle/cancel-workflow-action@0.11.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Set node version
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -27,6 +30,6 @@ jobs:
|
||||
key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
- name: Install dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm install
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,3 +36,4 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env*.local
|
||||
|
||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
241
README.md
241
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
# Next.js Commerce
|
||||
|
||||
A Next.js 13 and App Router-ready ecommerce template featuring:
|
||||
A Next.js 14 and App Router-ready ecommerce template featuring:
|
||||
|
||||
- Next.js App Router
|
||||
- Optimized for SEO using Next.js's Metadata
|
||||
@@ -15,19 +15,34 @@ A Next.js 13 and App Router-ready ecommerce template featuring:
|
||||
- Checkout and payments with Shopify
|
||||
- Automatic light/dark mode based on system settings
|
||||
|
||||
> Note: Looking for Next.js Commerce v1? View the [code](https://github.com/vercel/commerce/tree/v1), [demo](https://commerce-v1.vercel.store), and [release notes](https://github.com/vercel/commerce/releases/tag/v1)
|
||||
<h3 id="v1-note"></h3>
|
||||
|
||||
> Note: Looking for Next.js Commerce v1? View the [code](https://github.com/vercel/commerce/tree/v1), [demo](https://commerce-v1.vercel.store), and [release notes](https://github.com/vercel/commerce/releases/tag/v1).
|
||||
|
||||
## Providers
|
||||
|
||||
Vercel will only be actively maintaining a Shopify version [as outlined in our vision and strategy for Next.js Commerce](https://github.com/vercel/commerce/pull/966).
|
||||
|
||||
Vercel is more than happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged.
|
||||
Vercel is happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged.
|
||||
|
||||
- Shopify (this repository)
|
||||
- [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
|
||||
- [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
|
||||
- [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
|
||||
- [Shopware](https://github.com/shopwareLabs/vercel-commerce) ([Demo](https://shopware-vercel-commerce-react.vercel.app/))
|
||||
- [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
|
||||
- [Umbraco](https://github.com/umbraco/Umbraco.VercelCommerce.Demo) ([Demo](https://vercel-commerce-demo.umbraco.com/))
|
||||
- [Wix](https://github.com/wix/nextjs-commerce) ([Demo](https://wix-nextjs-commerce.vercel.app/))
|
||||
|
||||
> Note: Providers, if you are looking to use similar products for your demo, you can [download these assets](https://drive.google.com/file/d/1q_bKerjrwZgHwCw0ovfUMW6He9VtepO_/view?usp=sharing).
|
||||
|
||||
## Integrations
|
||||
|
||||
Integrations enable upgraded or additional functionality for Next.js Commerce
|
||||
|
||||
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
|
||||
- Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
|
||||
- Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
|
||||
|
||||
## Running locally
|
||||
|
||||
@@ -53,223 +68,9 @@ Your app should now be running on [localhost:3000](http://localhost:3000/).
|
||||
1. Select the `Vercel Solutions` scope.
|
||||
1. Connect to the existing `commerce-shopify` project.
|
||||
1. Run `vc env pull` to get environment variables.
|
||||
1. Run `pmpm dev` to ensure everything is working correctly.
|
||||
1. Run `pnpm dev` to ensure everything is working correctly.
|
||||
</details>
|
||||
|
||||
## How to configure your Shopify store for Next.js Commerce
|
||||
## Vercel, Next.js Commerce, and Shopify Integration Guide
|
||||
|
||||
Next.js Commerce requires a [paid Shopify plan](https://www.shopify.com/pricing).
|
||||
|
||||
> Note: Next.js Commerce will not work with a Shopify Starter plan as it does not allow installation of custom themes, which is required to run as a headless storefront.
|
||||
|
||||
### Add Shopify domain to an environment variable
|
||||
|
||||
Create a `SHOPIFY_STORE_DOMAIN` environment variable and use your Shopify domain as the the value (ie. `[your-shopify-store-subdomain].myshopify.com`).
|
||||
|
||||
> Note: Do not include the `https://`.
|
||||
|
||||
### Accessing the Shopify Storefront API
|
||||
|
||||
Next.js Commerce utilizes [Shopify's Storefront API](https://shopify.dev/docs/api/storefront) to create unique customer experiences. The API offers a full range of commerce options making it possible for customers to control products, collections, menus, pages, cart, checkout, and more.
|
||||
|
||||
In order to use the Shopify's Storefront API, you need to install the [Headless app](https://apps.shopify.com/headless) in your Shopify store.
|
||||
|
||||
Once installed, you'll need to create a `SHOPIFY_STOREFRONT_ACCESS_TOKEN` environment variable and use the public access token as the value.
|
||||
|
||||
> Note: Shopify does offer a Node.js Storefront API SDK. We use the Storefront API via GraphQL directly instead of the Node.js SDK so we have more control over fetching and caching.
|
||||
|
||||
<details>
|
||||
<summary>Expand to view detailed walkthrough</summary>
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/apps`.
|
||||
1. Click the green `Shopify App Store` button.
|
||||

|
||||
1. Search for `Headless` and click on the `Headless` app.
|
||||

|
||||
1. Click the black `Add app` button.
|
||||

|
||||
1. Click the green `Add sales channel` button.
|
||||

|
||||
1. Click the green `Create storefront` button.
|
||||

|
||||
1. Copy and paste the public access token and assign it to a `SHOPIFY_STOREFRONT_ACCESS_TOKEN` environment variable.
|
||||

|
||||
1. If you ever need to reference the public access token again, you can navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/headless_storefronts`.
|
||||
</details>
|
||||
|
||||
### Install a headless theme
|
||||
|
||||
When using a headless Shopify setup, you normally don't want customers to access any of the theme pages except for checkout. However, you can't totally disable the theme and a lot of links will still point to the theme (e.g. links in emails, order details, plugins, checkout, etc.).
|
||||
|
||||
To enable a seamless flow between your headless site and Shopify, you can install the [Shopify Headless Theme](https://github.com/instantcommerce/shopify-headless-theme).
|
||||
|
||||
Follow the installation instructions and configure the theme with your headless site's values.
|
||||
|
||||
<details>
|
||||
<summary>Expand to view detailed walkthrough</summary>
|
||||
|
||||
1. Download [Shopify Headless Theme](https://github.com/instantcommerce/shopify-headless-theme).
|
||||

|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/themes`.
|
||||
1. Click `Add theme`, then `Upload zip file`.
|
||||

|
||||
1. Select the downloaded zip file from above, and click the green `Upload file` button.
|
||||

|
||||
1. Click `Customize`.
|
||||

|
||||
1. Click `Theme settings` (ie. the paintbrush icon), expand the `STOREFRONT` section, enter your headless store domain, click the gray `Publish` button.
|
||||

|
||||
1. Confirm the theme change by clicking the green `Save and publish` button.
|
||||

|
||||
1. The headless theme should now be your current active theme.
|
||||

|
||||
</details>
|
||||
|
||||
### Branding & Design
|
||||
|
||||
Since you're creating a headless Shopify store, you'll be in full control of your brand and design. However, there are still a few aspects we're leaving within Shopify's control.
|
||||
|
||||
- Checkout
|
||||
- Emails
|
||||
- Order status
|
||||
- Order history
|
||||
- Favicon (for any Shopify controlled pages)
|
||||
|
||||
You can use Shopify's admin to customize these pages to match your brand and design.
|
||||
|
||||
<details>
|
||||
<summary>Expand to view detailed walkthrough</summary>
|
||||
|
||||
#### Checkout, order status, and order history
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/checkout`.
|
||||
1. Click the green `Customize` button.
|
||||

|
||||
1. Click `Branding` (ie. the paintbrush icon) and customize your brand. Please note, there are three steps / pages to the checkout flow. Use the dropdown to change pages and adjust branding as needed on each page. Click `Save` when you are done.
|
||||

|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/branding`.
|
||||
1. Customize settings to match your brand.
|
||||

|
||||
|
||||
#### Emails
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/email_settings`.
|
||||
1. Customize settings to match your brand.
|
||||

|
||||
|
||||
#### Favicon
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/themes`.
|
||||
1. Click the green `Customize` button.
|
||||

|
||||
1. Click `Theme settings` (ie. the paintbrush icon), expand the `FAVICON` section, upload favicon, then click the `Save` button.
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
### Configure webhooks for on-demand incremental static regeneration (ISR)
|
||||
|
||||
Utilizing [Shopify's webhooks](https://shopify.dev/docs/apps/webhooks), and listening for select [Shopify webhook event topics](https://shopify.dev/docs/api/admin-rest/2022-04/resources/webhook#event-topics), we can use [Next'js on-demand revalidation](https://nextjs.org/docs/app/building-your-application/data-fetching/revalidating#using-on-demand-revalidation) to keep data fetches indefinitely cached until certain events in the Shopify store occur.
|
||||
|
||||
Next.js is pre-configured to listen for the following Shopify webhook events and automatically revalidate fetches.
|
||||
|
||||
- `collections/create`
|
||||
- `collections/delete`
|
||||
- `collections/update`
|
||||
- `products/create`
|
||||
- `products/delete`
|
||||
- `products/update` (this also includes when variants are added, updated, and removed as well as when products are purchased so inventory and out of stocks can be updated)
|
||||
|
||||
<details>
|
||||
<summary>Expand to view detailed walkthrough</summary>
|
||||
|
||||
#### Setup secret for secure revalidation
|
||||
|
||||
1. Create your own secret or [generate a random UUID](https://www.uuidgenerator.net/guid).
|
||||
1. Create a [Vercel Environment Variable](https://vercel.com/docs/concepts/projects/environment-variables) named `SHOPIFY_REVALIDATION_SECRET` and use the value from above.
|
||||
|
||||
#### Configure Shopify webhooks
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/notifications`.
|
||||
1. Add webhooks for all six event topics listed above. You can add more sets for other preview urls, environments, or local development. Append `?secret=[SECRET]` to each url, where `[SECRET]` is the secret you created above.
|
||||

|
||||

|
||||
|
||||
#### Testing webhooks during local development
|
||||
|
||||
The easiest way to test webhooks while developing locally is to use [ngrok](https://ngrok.com).
|
||||
|
||||
1. [Install and configure ngrok](https://ngrok.com/download) (you will need to create an account).
|
||||
1. Run your app locally, `npm run dev`.
|
||||
1. In a separate terminal session, run `ngrok http 3000`.
|
||||
1. Use the url generated by ngrok and add or update your webhook urls in Shopify.
|
||||

|
||||

|
||||
1. You can now make changes to your store and your local app should receive updates. You can also use the `Send test notification` button to trigger a generic webhook test.
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
### Using Shopify as a CMS
|
||||
|
||||
Next.js Commerce is fully powered by Shopify in a truly headless and data driven way.
|
||||
|
||||
#### Products
|
||||
|
||||
`https://[your-shopify-store-subdomain].myshopify.com/admin/products`
|
||||
|
||||
Only `Active` products are shown. `Draft` products will not be shown until they are marked as `Active`.
|
||||
|
||||
`Active` products can still be hidden and not seen by navigating the site, by adding a `nextjs-frontend-hidden` tag on the product. This tag will also tell search engines to not index or crawl the product. The product is still directly accessible via url. This feature is great for "secret" products you only want to people you share the url with.
|
||||
|
||||
Product options and option combinations are driven from Shopify options and variants. When selecting options on the product detail page, other option and variant combinations will be visually validated and verified for availability, like Amazon does.
|
||||
|
||||
Products that are active and "out of stock" are still shown on the site, but the ability to add the product to the cart is disabled.
|
||||
|
||||
#### Collections
|
||||
|
||||
`https://[your-shopify-store-subdomain].myshopify.com/admin/collections`
|
||||
|
||||
Create whatever collections you want and configure them however you want. All available collections will show on the search page as filters on the left, with one exception...
|
||||
|
||||
Any collection names that start with the word "hidden" will not show up on the headless front end. The Next.js Commerce theme comes pre-configured to look for two hidden collections. Collections were chosen for this over tags so that order of products could be controlled (collections allow for manual ordering).
|
||||
|
||||
Create the following collections:
|
||||
|
||||
- `Hidden: Homepage Featured Items` -- Products in this collection are displayed in the three featured blocks on the homepage.
|
||||
- `Hidden: Homepage Carousel` -- Products in this collection are displayed in the auto-scrolling carousel section on the homepage.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### Pages
|
||||
|
||||
`https://[your-shopify-store-subdomain].myshopify.com/admin/pages`
|
||||
|
||||
Next.js Commerce contains a dynamic `[page]` route. It will use the value to look for a corresponding page in Shopify. If a page is found, it will display its rich content using Tailwind's prose. If a page is not found, a 404 page is displayed.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### Navigation menus
|
||||
|
||||
`https://[your-shopify-store-subdomain].myshopify.com/admin/menus`
|
||||
|
||||
Next.js Commerce's header and footer navigation is pre-configured to be controlled by Shopify navigation menus. This means you have full control over what links go here. They can be to collections, pages, external links, and more.
|
||||
|
||||
Create the following navigation menus:
|
||||
|
||||
- `Next.js Frontend Header Menu` -- Menu items to be shown in the headless frontend header.
|
||||
- `Next.js Frontend Footer Menu` -- Menu items to be shown in the headless frontend footer.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### SEO
|
||||
|
||||
Shopify's products, collections, pages, etc. allow you to create custom SEO titles and descriptions. Next.js Commerce is pre-configured to display these custom values, but also comes with sensible default fallbacks if they are not provided.
|
||||
|
||||

|
||||
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.
|
||||
|
||||
@@ -1,37 +1,8 @@
|
||||
import { TAGS } from 'lib/constants';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { headers } from 'next/headers';
|
||||
import { revalidate } from 'lib/shopify';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// We always need to respond with a 200 status code to Shopify,
|
||||
// otherwise it will continue to retry the request.
|
||||
export async function POST(req: NextRequest): Promise<Response> {
|
||||
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 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 });
|
||||
}
|
||||
|
||||
if (!isCollectionUpdate && !isProductUpdate) {
|
||||
// We don't need to revalidate anything for any other topics.
|
||||
return NextResponse.json({ status: 200 });
|
||||
}
|
||||
|
||||
if (isCollectionUpdate) {
|
||||
revalidateTag(TAGS.collections);
|
||||
}
|
||||
|
||||
if (isProductUpdate) {
|
||||
revalidateTag(TAGS.products);
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
return revalidate(req);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
|
||||
export default function Error({ reset }: { reset: () => void }) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Something went wrong.</h2>
|
||||
<button onClick={() => reset()}>Try again</button>
|
||||
<div className="mx-auto my-4 flex max-w-xl flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12">
|
||||
<h2 className="text-xl font-bold">Oh no!</h2>
|
||||
<p className="my-2">
|
||||
There was an issue with our storefront. This could be a temporary issue, please try your
|
||||
action again.
|
||||
</p>
|
||||
<button
|
||||
className="mx-auto mt-4 flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90"
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 535 B After Width: | Height: | Size: 15 KiB |
@@ -2,6 +2,12 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (font: -apple-system-body) and (-webkit-appearance: none) {
|
||||
img[loading='lazy'] {
|
||||
clip-path: inset(0.6px);
|
||||
|
||||
@@ -1,40 +1,43 @@
|
||||
import Navbar from 'components/layout/navbar';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { GeistSans } from 'geist/font';
|
||||
import { ensureStartsWith } from 'lib/utils';
|
||||
import { ReactNode, Suspense } from 'react';
|
||||
import './globals.css';
|
||||
|
||||
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;
|
||||
|
||||
export const metadata = {
|
||||
metadataBase: new URL(baseUrl),
|
||||
title: {
|
||||
default: SITE_NAME,
|
||||
default: SITE_NAME!,
|
||||
template: `%s | ${SITE_NAME}`
|
||||
},
|
||||
robots: {
|
||||
follow: true,
|
||||
index: true
|
||||
},
|
||||
...(TWITTER_CREATOR &&
|
||||
TWITTER_SITE && {
|
||||
...(twitterCreator &&
|
||||
twitterSite && {
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
creator: TWITTER_CREATOR,
|
||||
site: TWITTER_SITE
|
||||
creator: twitterCreator,
|
||||
site: twitterSite
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-inter'
|
||||
});
|
||||
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={inter.variable}>
|
||||
<html lang="en" className={GeistSans.variable}>
|
||||
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
|
||||
<Navbar />
|
||||
<Suspense>
|
||||
<Navbar />
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<main>{children}</main>
|
||||
</Suspense>
|
||||
|
||||
@@ -14,7 +14,7 @@ import Link from 'next/link';
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
params
|
||||
}: {
|
||||
params: { handle: string };
|
||||
}): Promise<Metadata> {
|
||||
@@ -23,17 +23,17 @@ export async function generateMetadata({
|
||||
if (!product) return notFound();
|
||||
|
||||
const { url, width, height, altText: alt } = product.featuredImage || {};
|
||||
const hide = !product.tags.includes(HIDDEN_PRODUCT_TAG);
|
||||
const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
|
||||
|
||||
return {
|
||||
title: product.seo.title || product.title,
|
||||
description: product.seo.description || product.description,
|
||||
robots: {
|
||||
index: hide,
|
||||
follow: hide,
|
||||
index: indexable,
|
||||
follow: indexable,
|
||||
googleBot: {
|
||||
index: hide,
|
||||
follow: hide
|
||||
index: indexable,
|
||||
follow: indexable
|
||||
}
|
||||
},
|
||||
openGraph: url
|
||||
@@ -51,7 +51,7 @@ export async function generateMetadata({
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductPage({ params, searchParams }: { params: { handle: string }; searchParams: URLSearchParams }) {
|
||||
export default async function ProductPage({ params }: { params: { handle: string } }) {
|
||||
const product = await getProduct(params.handle);
|
||||
|
||||
if (!product) return notFound();
|
||||
@@ -82,8 +82,8 @@ export default async function ProductPage({ params, searchParams }: { params: {
|
||||
}}
|
||||
/>
|
||||
<div className="mx-auto max-w-screen-2xl px-4">
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-8 px-4 dark:border-neutral-800 dark:bg-black md:p-12 lg:grid lg:grid-cols-6">
|
||||
<div className="lg:col-span-4">
|
||||
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row lg:gap-8">
|
||||
<div className="h-full w-full basis-full lg:basis-4/6">
|
||||
<Gallery
|
||||
images={product.images.map((image: Image) => ({
|
||||
src: image.url,
|
||||
@@ -92,8 +92,8 @@ export default async function ProductPage({ params, searchParams }: { params: {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="py-6 pr-8 md:pr-12 lg:col-span-2">
|
||||
<ProductDescription product={product} searchParams={searchParams} />
|
||||
<div className="basis-full lg:basis-2/6">
|
||||
<ProductDescription product={product} />
|
||||
</div>
|
||||
</div>
|
||||
<Suspense>
|
||||
@@ -115,14 +115,13 @@ async function RelatedProducts({ id }: { id: string }) {
|
||||
return (
|
||||
<div className="py-8">
|
||||
<h2 className="mb-4 text-2xl font-bold">Related Products</h2>
|
||||
<div className="flex w-full gap-4 overflow-x-auto pt-1">
|
||||
{relatedProducts.map((product, i) => {
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
className="w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
|
||||
href={`/product/${product.handle}`}
|
||||
>
|
||||
<ul className="flex w-full gap-4 overflow-x-auto pt-1">
|
||||
{relatedProducts.map((product) => (
|
||||
<li
|
||||
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}`}>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
@@ -131,13 +130,13 @@ async function RelatedProducts({ id }: { id: string }) {
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
width={600}
|
||||
height={600}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, (min-width: 475px) 50vw, 100vw"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default async function SearchPage({
|
||||
return (
|
||||
<>
|
||||
{searchValue ? (
|
||||
<p>
|
||||
<p className="mb-4">
|
||||
{products.length === 0
|
||||
? 'There are no products that match '
|
||||
: `Showing ${products.length} ${resultsText} for `}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { getCollections, getPages, getProducts } from 'lib/shopify';
|
||||
import { validateEnvironmentVariables } from 'lib/utils';
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
type Route = {
|
||||
url: string;
|
||||
lastModified: string;
|
||||
};
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: 'http://localhost:3000';
|
||||
|
||||
export default async function sitemap(): Promise<Promise<Promise<MetadataRoute.Sitemap>>> {
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
validateEnvironmentVariables();
|
||||
|
||||
const routesMap = [''].map((route) => ({
|
||||
url: `${baseUrl}${route}`,
|
||||
lastModified: new Date().toISOString()
|
||||
@@ -32,9 +40,13 @@ export default async function sitemap(): Promise<Promise<Promise<MetadataRoute.S
|
||||
}))
|
||||
);
|
||||
|
||||
const fetchedRoutes = (
|
||||
await Promise.all([collectionsPromise, productsPromise, pagesPromise])
|
||||
).flat();
|
||||
let fetchedRoutes: Route[] = [];
|
||||
|
||||
try {
|
||||
fetchedRoutes = (await Promise.all([collectionsPromise, productsPromise, pagesPromise])).flat();
|
||||
} catch (error) {
|
||||
throw JSON.stringify(error, null, 2);
|
||||
}
|
||||
|
||||
return [...routesMap, ...fetchedRoutes];
|
||||
}
|
||||
|
||||
@@ -8,29 +8,33 @@ export async function Carousel() {
|
||||
|
||||
if (!products?.length) return null;
|
||||
|
||||
// Purposefully duplicating products to make the carousel loop and not run out of products on wide screens.
|
||||
const carouselProducts = [...products, ...products, ...products];
|
||||
|
||||
return (
|
||||
<div className=" w-full overflow-x-auto pb-6 pt-1">
|
||||
<div className="flex animate-carousel gap-4">
|
||||
{[...products, ...products].map((product, i) => (
|
||||
<Link
|
||||
<ul className="flex animate-carousel gap-4">
|
||||
{carouselProducts.map((product, i) => (
|
||||
<li
|
||||
key={`${product.handle}${i}`}
|
||||
href={`/product/${product.handle}`}
|
||||
className="h-[30vh] w-2/3 flex-none md:w-1/3"
|
||||
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
|
||||
>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
title: product.title,
|
||||
amount: product.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
width={600}
|
||||
height={600}
|
||||
/>
|
||||
</Link>
|
||||
<Link href={`/product/${product.handle}`} className="relative h-full w-full">
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
title: product.title,
|
||||
amount: product.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use server';
|
||||
|
||||
import { TAGS } from 'lib/constants';
|
||||
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const addItem = async (variantId: string | undefined): Promise<Error | undefined> => {
|
||||
export async function addItem(prevState: any, selectedVariantId: string | undefined) {
|
||||
let cartId = cookies().get('cartId')?.value;
|
||||
let cart;
|
||||
|
||||
@@ -17,44 +19,56 @@ export const addItem = async (variantId: string | undefined): Promise<Error | un
|
||||
cookies().set('cartId', cartId);
|
||||
}
|
||||
|
||||
if (!variantId) {
|
||||
return new Error('Missing variantId');
|
||||
if (!selectedVariantId) {
|
||||
return 'Missing product variant ID';
|
||||
}
|
||||
try {
|
||||
await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
|
||||
} catch (e) {
|
||||
return new Error('Error adding item', { cause: e });
|
||||
}
|
||||
};
|
||||
|
||||
export const removeItem = async (lineId: string): Promise<Error | undefined> => {
|
||||
try {
|
||||
await addToCart(cartId, [{ 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;
|
||||
|
||||
if (!cartId) {
|
||||
return new Error('Missing cartId');
|
||||
return 'Missing cart ID';
|
||||
}
|
||||
|
||||
try {
|
||||
await removeFromCart(cartId, [lineId]);
|
||||
revalidateTag(TAGS.cart);
|
||||
} catch (e) {
|
||||
return new Error('Error removing item', { cause: e });
|
||||
return 'Error removing item from cart';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const updateItemQuantity = async ({
|
||||
lineId,
|
||||
variantId,
|
||||
quantity
|
||||
}: {
|
||||
lineId: string;
|
||||
variantId: string;
|
||||
quantity: number;
|
||||
}): Promise<Error | undefined> => {
|
||||
export async function updateItemQuantity(
|
||||
prevState: any,
|
||||
payload: {
|
||||
lineId: string;
|
||||
variantId: string;
|
||||
quantity: number;
|
||||
}
|
||||
) {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return new Error('Missing cartId');
|
||||
return 'Missing cart ID';
|
||||
}
|
||||
|
||||
const { lineId, variantId, quantity } = payload;
|
||||
|
||||
try {
|
||||
if (quantity === 0) {
|
||||
await removeFromCart(cartId, [lineId]);
|
||||
revalidateTag(TAGS.cart);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateCart(cartId, [
|
||||
{
|
||||
id: lineId,
|
||||
@@ -62,7 +76,8 @@ export const updateItemQuantity = async ({
|
||||
quantity
|
||||
}
|
||||
]);
|
||||
revalidateTag(TAGS.cart);
|
||||
} catch (e) {
|
||||
return new Error('Error updating item quantity', { cause: e });
|
||||
return 'Error updating item quantity';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,37 +2,91 @@
|
||||
|
||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { addItem } from 'components/cart/actions';
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import { experimental_useFormStatus as useFormStatus } from 'react-dom';
|
||||
import { ProductVariant } from 'lib/shopify/types';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useFormState, useFormStatus } from 'react-dom';
|
||||
|
||||
export function AddToCart({
|
||||
function SubmitButton({
|
||||
availableForSale,
|
||||
addToCart,
|
||||
selectedVariantId
|
||||
}: {
|
||||
availableForSale: boolean;
|
||||
addToCart: any;
|
||||
selectedVariantId: string | undefined;
|
||||
}) {
|
||||
const { pending } = useFormStatus()
|
||||
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';
|
||||
|
||||
return (
|
||||
<form action={addToCart}>
|
||||
if (!availableForSale) {
|
||||
return (
|
||||
<button aria-disabled className={clsx(buttonClasses, disabledClasses)}>
|
||||
Out Of Stock
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedVariantId) {
|
||||
return (
|
||||
<button
|
||||
aria-label="Add item to cart"
|
||||
disabled={pending}
|
||||
type="submit"
|
||||
className={clsx(
|
||||
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90',
|
||||
{
|
||||
'cursor-not-allowed opacity-60': !availableForSale,
|
||||
'cursor-not-allowed': pending
|
||||
}
|
||||
)}
|
||||
aria-label="Please select an option"
|
||||
aria-disabled
|
||||
className={clsx(buttonClasses, disabledClasses)}
|
||||
>
|
||||
<div className="absolute left-0 ml-4">
|
||||
{!pending ? <PlusIcon className="h-5" /> : <LoadingDots className="mb-3 bg-white" />}
|
||||
<PlusIcon className="h-5" />
|
||||
</div>
|
||||
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
|
||||
Add To Cart
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
|
||||
if (pending) e.preventDefault();
|
||||
}}
|
||||
aria-label="Add to cart"
|
||||
aria-disabled={pending}
|
||||
className={clsx(buttonClasses, {
|
||||
'hover:opacity-90': true,
|
||||
disabledClasses: pending
|
||||
})}
|
||||
>
|
||||
<div className="absolute left-0 ml-4">
|
||||
{pending ? <LoadingDots className="mb-3 bg-white" /> : <PlusIcon className="h-5" />}
|
||||
</div>
|
||||
Add To Cart
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddToCart({
|
||||
variants,
|
||||
availableForSale
|
||||
}: {
|
||||
variants: ProductVariant[];
|
||||
availableForSale: boolean;
|
||||
}) {
|
||||
const [message, formAction] = useFormState(addItem, null);
|
||||
const searchParams = useSearchParams();
|
||||
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
variant.selectedOptions.every(
|
||||
(option) => option.value === searchParams.get(option.name.toLowerCase())
|
||||
)
|
||||
);
|
||||
const selectedVariantId = variant?.id || defaultVariantId;
|
||||
const actionWithVariant = formAction.bind(null, selectedVariantId);
|
||||
|
||||
return (
|
||||
<form action={actionWithVariant}>
|
||||
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} />
|
||||
<p aria-live="polite" className="sr-only" role="status">
|
||||
{message}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,31 @@
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import { useRouter } from 'next/navigation';
|
||||
'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 { useTransition } from 'react';
|
||||
import { useFormState, useFormStatus } from 'react-dom';
|
||||
|
||||
export default function DeleteItemButton({ item }: { item: CartItem }) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Remove cart item"
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
const error = await removeItem(item.id);
|
||||
|
||||
if (error) {
|
||||
alert(error);
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
});
|
||||
type="submit"
|
||||
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
|
||||
if (pending) e.preventDefault();
|
||||
}}
|
||||
disabled={isPending}
|
||||
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': isPending
|
||||
'cursor-not-allowed px-0': pending
|
||||
}
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
{pending ? (
|
||||
<LoadingDots className="bg-white" />
|
||||
) : (
|
||||
<XMarkIcon className="hover:text-accent-3 mx-[1px] h-4 w-4 text-white dark:text-black" />
|
||||
@@ -42,3 +33,18 @@ export default function DeleteItemButton({ item }: { item: CartItem }) {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteItemButton({ item }: { item: CartItem }) {
|
||||
const [message, formAction] = useFormState(removeItem, null);
|
||||
const itemId = item.id;
|
||||
const actionWithVariant = formAction.bind(null, itemId);
|
||||
|
||||
return (
|
||||
<form action={actionWithVariant}>
|
||||
<SubmitButton />
|
||||
<p aria-live="polite" className="sr-only" role="status">
|
||||
{message}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,32 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTransition } from 'react';
|
||||
'use client';
|
||||
|
||||
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { removeItem, updateItemQuantity } from 'components/cart/actions';
|
||||
import { updateItemQuantity } from 'components/cart/actions';
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import type { CartItem } from 'lib/shopify/types';
|
||||
import { useFormState, useFormStatus } from 'react-dom';
|
||||
|
||||
export default function EditItemQuantityButton({
|
||||
item,
|
||||
type
|
||||
}: {
|
||||
item: CartItem;
|
||||
type: 'plus' | 'minus';
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
const error =
|
||||
type === 'minus' && item.quantity - 1 === 0
|
||||
? await removeItem(item.id)
|
||||
: await updateItemQuantity({
|
||||
lineId: item.id,
|
||||
variantId: item.merchandise.id,
|
||||
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
|
||||
});
|
||||
|
||||
if (error) {
|
||||
alert(error);
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
});
|
||||
type="submit"
|
||||
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
|
||||
if (pending) e.preventDefault();
|
||||
}}
|
||||
disabled={isPending}
|
||||
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
|
||||
aria-disabled={pending}
|
||||
className={clsx(
|
||||
'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full px-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80',
|
||||
{
|
||||
'cursor-not-allowed': isPending,
|
||||
'cursor-not-allowed': pending,
|
||||
'ml-auto': type === 'minus'
|
||||
}
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
{pending ? (
|
||||
<LoadingDots className="bg-black dark:bg-white" />
|
||||
) : type === 'plus' ? (
|
||||
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||
@@ -58,3 +36,22 @@ export default function EditItemQuantityButton({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditItemQuantityButton({ item, type }: { item: CartItem; type: 'plus' | 'minus' }) {
|
||||
const [message, formAction] = useFormState(updateItemQuantity, null);
|
||||
const payload = {
|
||||
lineId: item.id,
|
||||
variantId: item.merchandise.id,
|
||||
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
|
||||
};
|
||||
const actionWithVariant = formAction.bind(null, payload);
|
||||
|
||||
return (
|
||||
<form action={actionWithVariant}>
|
||||
<SubmitButton type={type} />
|
||||
<p aria-live="polite" className="sr-only" role="status">
|
||||
{message}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import CloseCart from './close-cart';
|
||||
import DeleteItemButton from './delete-item-button';
|
||||
import EditItemQuantityButton from './edit-item-quantity-button';
|
||||
import { DeleteItemButton } from './delete-item-button';
|
||||
import { EditItemQuantityButton } from './edit-item-quantity-button';
|
||||
import OpenCart from './open-cart';
|
||||
|
||||
type MerchandiseSearchParams = {
|
||||
@@ -25,7 +25,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||
const closeCart = () => setIsOpen(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Open cart modal when when quantity changes.
|
||||
// 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 (!isOpen) {
|
||||
@@ -111,7 +111,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||
>
|
||||
<div className="relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
|
||||
<Image
|
||||
className="h-full w-full object-cover "
|
||||
className="h-full w-full object-cover"
|
||||
width={64}
|
||||
height={64}
|
||||
alt={
|
||||
@@ -127,7 +127,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||
{item.merchandise.product.title}
|
||||
</span>
|
||||
{item.merchandise.title !== DEFAULT_OPTION ? (
|
||||
<p className="text-sm text-neutral-400 dark:text-neutral-600">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{item.merchandise.title}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -141,7 +141,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||
/>
|
||||
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
|
||||
<EditItemQuantityButton item={item} type="minus" />
|
||||
<p className="w-6 text-center ">
|
||||
<p className="w-6 text-center">
|
||||
<span className="w-full text-sm">{item.quantity}</span>
|
||||
</p>
|
||||
<EditItemQuantityButton item={item} type="plus" />
|
||||
@@ -152,7 +152,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="py-4 text-sm text-neutral-400 dark:text-neutral-500">
|
||||
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
|
||||
<p>Taxes</p>
|
||||
<Price
|
||||
|
||||
@@ -3,17 +3,27 @@ import { getCollectionProducts } from 'lib/shopify';
|
||||
import type { Product } from 'lib/shopify/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
function ThreeItemGridItem({ item, size }: { item: Product; size: 'full' | 'half' }) {
|
||||
function ThreeItemGridItem({
|
||||
item,
|
||||
size,
|
||||
priority
|
||||
}: {
|
||||
item: Product;
|
||||
size: 'full' | 'half';
|
||||
priority?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={size === 'full' ? 'lg:col-span-4 lg:row-span-2' : 'lg:col-span-2 lg:row-span-1'}
|
||||
className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
|
||||
>
|
||||
<Link className="block h-full" href={`/product/${item.handle}`}>
|
||||
<Link className="relative block aspect-square h-full w-full" href={`/product/${item.handle}`}>
|
||||
<GridTileImage
|
||||
src={item.featuredImage.url}
|
||||
width={size === 'full' ? 1080 : 540}
|
||||
height={size === 'full' ? 1080 : 540}
|
||||
priority={true}
|
||||
fill
|
||||
sizes={
|
||||
size === 'full' ? '(min-width: 768px) 66vw, 100vw' : '(min-width: 768px) 33vw, 100vw'
|
||||
}
|
||||
priority={priority}
|
||||
alt={item.title}
|
||||
label={{
|
||||
position: size === 'full' ? 'center' : 'bottom',
|
||||
@@ -38,9 +48,9 @@ export async function ThreeItemGrid() {
|
||||
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
|
||||
|
||||
return (
|
||||
<section className="mx-auto grid max-w-screen-2xl gap-4 pb-4 lg:grid-cols-6 lg:grid-rows-2">
|
||||
<ThreeItemGridItem size="full" item={firstProduct} />
|
||||
<ThreeItemGridItem size="half" item={secondProduct} />
|
||||
<section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2">
|
||||
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
|
||||
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
|
||||
<ThreeItemGridItem size="half" item={thirdProduct} />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ export function GridTileImage({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-full w-full items-center justify-center overflow-hidden rounded-lg border bg-white hover:border-blue-600 dark:bg-black',
|
||||
'group flex h-full w-full items-center justify-center overflow-hidden rounded-lg border bg-white hover:border-blue-600 dark:bg-black',
|
||||
{
|
||||
relative: label,
|
||||
'border-2 border-blue-600': active,
|
||||
@@ -32,7 +32,7 @@ export function GridTileImage({
|
||||
// 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 hover:scale-105': isInteractive
|
||||
'transition duration-300 ease-in-out group-hover:scale-105': isInteractive
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
export default function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
shapeRendering="geometricPrecision"
|
||||
className={className}
|
||||
>
|
||||
<path d="M12 0C5.37 0 0 5.50583 0 12.3035C0 17.7478 3.435 22.3463 8.205 23.9765C8.805 24.0842 9.03 23.715 9.03 23.3921C9.03 23.0999 9.015 22.131 9.015 21.1005C6 21.6696 5.22 20.347 4.98 19.6549C4.845 19.3012 4.26 18.2092 3.75 17.917C3.33 17.6863 2.73 17.1173 3.735 17.1019C4.68 17.0865 5.355 17.9939 5.58 18.363C6.66 20.2239 8.385 19.701 9.075 19.3781C9.18 18.5783 9.495 18.04 9.84 17.7325C7.17 17.4249 4.38 16.3637 4.38 11.6576C4.38 10.3196 4.845 9.21227 5.61 8.35102C5.49 8.04343 5.07 6.78232 5.73 5.09058C5.73 5.09058 6.735 4.76762 9.03 6.3517C9.99 6.07487 11.01 5.93645 12.03 5.93645C13.05 5.93645 14.07 6.07487 15.03 6.3517C17.325 4.75224 18.33 5.09058 18.33 5.09058C18.99 6.78232 18.57 8.04343 18.45 8.35102C19.215 9.21227 19.68 10.3042 19.68 11.6576C19.68 16.3791 16.875 17.4249 14.205 17.7325C14.64 18.1169 15.015 18.8552 15.015 20.0086C15.015 21.6542 15 22.9768 15 23.3921C15 23.715 15.225 24.0995 15.825 23.9765C18.2072 23.1519 20.2773 21.5822 21.7438 19.4882C23.2103 17.3942 23.9994 14.8814 24 12.3035C24 5.50583 18.63 0 12 0Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -18,8 +18,8 @@ const Label = ({
|
||||
'lg:px-20 lg:pb-[35%]': position === 'center'
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center rounded-full border bg-white/70 p-1 text-[10px] font-semibold text-black backdrop-blur-md @[275px]/label:text-xs dark:border-neutral-800 dark:bg-black/70 dark:text-white">
|
||||
<h3 className="mr-4 inline pl-2 leading-none tracking-tight">{title}</h3>
|
||||
<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>
|
||||
<Price
|
||||
className="flex-none rounded-full bg-blue-600 p-2 text-white"
|
||||
amount={amount}
|
||||
|
||||
@@ -15,11 +15,11 @@ const FooterMenuItem = ({ item }: { item: Menu }) => {
|
||||
}, [pathname, item.path]);
|
||||
|
||||
return (
|
||||
<li className="mt-2 first:mt-1">
|
||||
<li>
|
||||
<Link
|
||||
href={item.path}
|
||||
className={clsx(
|
||||
'underline-offset-4 hover:text-black hover:underline dark:hover:text-neutral-300',
|
||||
'block p-2 text-lg underline-offset-4 hover:text-black hover:underline dark:hover:text-neutral-300 md:inline-block md:text-sm',
|
||||
{
|
||||
'text-black dark:text-neutral-300': active
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import GitHubIcon from 'components/icons/github';
|
||||
import FooterMenu from 'components/layout/footer-menu';
|
||||
import LogoSquare from 'components/logo-square';
|
||||
import { getMenu } from 'lib/shopify';
|
||||
@@ -16,12 +15,12 @@ export default async function Footer() {
|
||||
const copyrightName = COMPANY_NAME || SITE_NAME || '';
|
||||
|
||||
return (
|
||||
<footer className="text-sm text-neutral-400 dark:text-neutral-600">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm dark:border-neutral-700 md:flex-row md:gap-12 md:px-4 xl:px-0">
|
||||
<footer className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm dark:border-neutral-700 md:flex-row md:gap-12 md:px-4 min-[1320px]:px-0">
|
||||
<div>
|
||||
<Link className="flex items-center gap-2 text-black dark:text-white" href="/">
|
||||
<Link className="flex items-center gap-2 text-black dark:text-white md:pt-1" href="/">
|
||||
<LogoSquare size="sm" />
|
||||
<span className="font-bold uppercase">{SITE_NAME}</span>
|
||||
<span className="uppercase">{SITE_NAME}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<Suspense
|
||||
@@ -40,17 +39,18 @@ export default async function Footer() {
|
||||
</Suspense>
|
||||
<div className="md:ml-auto">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:text-black dark:hover:text-neutral-300"
|
||||
aria-label="Github Repository"
|
||||
href="https://github.com/vercel/commerce"
|
||||
className="flex h-8 w-max flex-none items-center justify-center rounded-md border border-neutral-200 bg-white text-xs text-black dark:border-neutral-700 dark:bg-black dark:text-white"
|
||||
aria-label="Deploy on Vercel"
|
||||
href="https://vercel.com/templates/next.js/nextjs-commerce"
|
||||
>
|
||||
<GitHubIcon className="h-6" />
|
||||
<p>Source</p>
|
||||
<span className="px-3">▲</span>
|
||||
<hr className="h-full border-r border-neutral-200 dark:border-neutral-700" />
|
||||
<span className="px-3">Deploy</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-neutral-200 py-6 text-sm dark:border-neutral-700">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col items-center gap-1 md:flex-row md:gap-0">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col items-center gap-1 px-4 md:flex-row md:gap-0 md:px-4 min-[1320px]:px-0">
|
||||
<p>
|
||||
© {copyrightDate} {copyrightName}
|
||||
{copyrightName.length && !copyrightName.endsWith('.') ? '.' : ''} All rights reserved.
|
||||
@@ -58,9 +58,8 @@ export default async function Footer() {
|
||||
<hr className="mx-4 hidden h-4 w-[1px] border-l border-neutral-400 md:inline-block" />
|
||||
<p>Designed in California</p>
|
||||
<p className="md:ml-auto">
|
||||
Crafted by{' '}
|
||||
<a href="https://vercel.com" className="text-black dark:text-white">
|
||||
▲ Vercel
|
||||
Crafted by ▲ Vercel
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -19,23 +19,19 @@ export default async function Navbar() {
|
||||
</div>
|
||||
<div className="flex w-full items-center">
|
||||
<div className="flex w-full md:w-1/3">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Go back home"
|
||||
className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6"
|
||||
>
|
||||
<Link href="/" 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}
|
||||
</div>
|
||||
</Link>
|
||||
{menu.length ? (
|
||||
<ul className="hidden text-sm md:flex md:items-center">
|
||||
<ul className="hidden gap-6 text-sm md:flex md:items-center">
|
||||
{menu.map((item: Menu) => (
|
||||
<li key={item.title}>
|
||||
<Link
|
||||
href={item.path}
|
||||
className="mr-3 text-neutral-400 underline-offset-4 hover:text-black hover:underline dark:text-neutral-600 dark:hover:text-neutral-300 lg:mr-8"
|
||||
className="text-neutral-500 underline-offset-4 hover:text-black hover:underline dark:text-neutral-400 dark:hover:text-neutral-300"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
@@ -48,7 +44,7 @@ export default async function Navbar() {
|
||||
<Search />
|
||||
</div>
|
||||
<div className="flex justify-end md:w-1/3">
|
||||
<Suspense fallback={<OpenCart className="h-6" />}>
|
||||
<Suspense fallback={<OpenCart />}>
|
||||
<Cart />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -32,8 +32,12 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={openMobileMenu} aria-label="Open mobile menu" className="md:hidden">
|
||||
<Bars3Icon className="h-6" />
|
||||
<button
|
||||
onClick={openMobileMenu}
|
||||
aria-label="Open mobile menu"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white md:hidden"
|
||||
>
|
||||
<Bars3Icon className="h-4" />
|
||||
</button>
|
||||
<Transition show={isOpen}>
|
||||
<Dialog onClose={closeMobileMenu} className="relative z-50">
|
||||
@@ -59,7 +63,11 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
||||
>
|
||||
<Dialog.Panel className="fixed bottom-0 left-0 right-0 top-0 flex h-full w-full flex-col bg-white pb-6 dark:bg-black">
|
||||
<div className="p-4">
|
||||
<button className="mb-4" onClick={closeMobileMenu} aria-label="Close mobile menu">
|
||||
<button
|
||||
className="mb-4 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"
|
||||
onClick={closeMobileMenu}
|
||||
aria-label="Close mobile menu"
|
||||
>
|
||||
<XMarkIcon className="h-6" />
|
||||
</button>
|
||||
|
||||
@@ -67,14 +75,13 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
||||
<Search />
|
||||
</div>
|
||||
{menu.length ? (
|
||||
<ul className="flex flex-col">
|
||||
<ul className="flex w-full flex-col">
|
||||
{menu.map((item: Menu) => (
|
||||
<li key={item.title}>
|
||||
<Link
|
||||
href={item.path}
|
||||
className="rounded-lg py-1 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<li
|
||||
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}>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
export default function Search() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setSearchValue(searchParams?.get('q') || '');
|
||||
}, [searchParams, setSearchValue]);
|
||||
|
||||
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
@@ -32,15 +25,15 @@ export default function Search() {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="relative w-full lg:w-[320px]">
|
||||
<form onSubmit={onSubmit} className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
|
||||
<input
|
||||
key={searchParams?.get('q')}
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search for products..."
|
||||
autoComplete="off"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
className="w-full rounded-lg border bg-white px-4 py-2 text-sm text-black placeholder:text-neutral-800 dark:border-neutral-800 dark:bg-transparent dark:text-neutral-500 dark:placeholder:text-neutral-500"
|
||||
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"
|
||||
/>
|
||||
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
|
||||
<MagnifyingGlassIcon className="h-4" />
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function ProductGridItems({ products }: { products: Product[] })
|
||||
<>
|
||||
{products.map((product) => (
|
||||
<Grid.Item key={product.handle} className="animate-fadeIn">
|
||||
<Link className="inline-block h-full w-full" href={`/product/${product.handle}`}>
|
||||
<Link className="relative inline-block h-full w-full" href={`/product/${product.handle}`}>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
@@ -17,8 +17,8 @@ export default function ProductGridItems({ products }: { products: Product[] })
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
width={600}
|
||||
height={600}
|
||||
fill
|
||||
sizes="(min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
/>
|
||||
</Link>
|
||||
</Grid.Item>
|
||||
|
||||
@@ -19,7 +19,11 @@ export default function FilterList({ list, title }: { list: ListItem[]; title?:
|
||||
return (
|
||||
<>
|
||||
<nav>
|
||||
{title ? <h3 className="hidden text-xs text-neutral-500 md:block">{title}</h3> : null}
|
||||
{title ? (
|
||||
<h3 className="hidden text-xs text-neutral-500 dark:text-neutral-400 md:block">
|
||||
{title}
|
||||
</h3>
|
||||
) : null}
|
||||
<ul className="hidden md:block">
|
||||
<FilterItemList list={list} />
|
||||
</ul>
|
||||
|
||||
@@ -5,22 +5,17 @@ import { SortFilterItem } from 'lib/constants';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ListItem, PathFilterItem } from '.';
|
||||
|
||||
function PathFilterItem({ item }: { item: PathFilterItem }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [active, setActive] = useState(pathname === item.path);
|
||||
const active = pathname === item.path;
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
const DynamicTag = active ? 'p' : Link;
|
||||
|
||||
newParams.delete('q');
|
||||
|
||||
useEffect(() => {
|
||||
setActive(pathname === item.path);
|
||||
}, [pathname, item.path]);
|
||||
|
||||
return (
|
||||
<li className="mt-2 flex text-black dark:text-white" key={item.title}>
|
||||
<DynamicTag
|
||||
@@ -41,7 +36,7 @@ function PathFilterItem({ item }: { item: PathFilterItem }) {
|
||||
function SortFilterItem({ item }: { item: SortFilterItem }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [active, setActive] = useState(searchParams.get('sort') === item.slug);
|
||||
const active = searchParams.get('sort') === item.slug;
|
||||
const q = searchParams.get('q');
|
||||
const href = createUrl(
|
||||
pathname,
|
||||
@@ -52,16 +47,12 @@ function SortFilterItem({ item }: { item: SortFilterItem }) {
|
||||
);
|
||||
const DynamicTag = active ? 'p' : Link;
|
||||
|
||||
useEffect(() => {
|
||||
setActive(searchParams.get('sort') === item.slug);
|
||||
}, [searchParams, item.slug]);
|
||||
|
||||
return (
|
||||
<li className="mt-2 flex text-sm text-black dark:text-white" key={item.title}>
|
||||
<DynamicTag
|
||||
prefetch={!active ? false : undefined}
|
||||
href={href}
|
||||
className={clsx('w-full', {
|
||||
className={clsx('w-full hover:underline hover:underline-offset-4', {
|
||||
'underline underline-offset-4': active
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ImageResponse } from 'next/server';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import LogoIcon from './icons/logo';
|
||||
|
||||
export type Props = {
|
||||
|
||||
@@ -2,33 +2,40 @@
|
||||
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
|
||||
export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const imageSearchParam = searchParams.get('image');
|
||||
const imageIndex = imageSearchParam ? parseInt(imageSearchParam) : 0;
|
||||
|
||||
function handleNavigate(direction: 'next' | 'previous') {
|
||||
if (direction === 'next') {
|
||||
setCurrentImageIndex(currentImageIndex + 1 < images.length ? currentImageIndex + 1 : 0);
|
||||
} else {
|
||||
setCurrentImageIndex(currentImageIndex === 0 ? images.length - 1 : currentImageIndex - 1);
|
||||
}
|
||||
}
|
||||
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';
|
||||
'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 (
|
||||
<div className="mr-8 h-full">
|
||||
<div className="relative mb-12 h-full max-h-[550px] overflow-hidden">
|
||||
{images[currentImageIndex] && (
|
||||
<>
|
||||
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden">
|
||||
{images[imageIndex] && (
|
||||
<Image
|
||||
className="relative h-full w-full object-contain"
|
||||
height={600}
|
||||
width={600}
|
||||
alt={images[currentImageIndex]?.altText as string}
|
||||
src={images[currentImageIndex]?.src as string}
|
||||
className="h-full w-full object-contain"
|
||||
fill
|
||||
sizes="(min-width: 1024px) 66vw, 100vw"
|
||||
alt={images[imageIndex]?.altText as string}
|
||||
src={images[imageIndex]?.src as string}
|
||||
priority={true}
|
||||
/>
|
||||
)}
|
||||
@@ -36,49 +43,57 @@ 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">
|
||||
<button
|
||||
<Link
|
||||
aria-label="Previous product image"
|
||||
onClick={() => handleNavigate('previous')}
|
||||
href={previousUrl}
|
||||
className={buttonClassName}
|
||||
scroll={false}
|
||||
>
|
||||
<ArrowLeftIcon className="h-5" />
|
||||
</button>
|
||||
</Link>
|
||||
<div className="mx-1 h-6 w-px bg-neutral-500"></div>
|
||||
<button
|
||||
<Link
|
||||
aria-label="Next product image"
|
||||
onClick={() => handleNavigate('next')}
|
||||
href={nextUrl}
|
||||
className={buttonClassName}
|
||||
scroll={false}
|
||||
>
|
||||
<ArrowRightIcon className="h-5" />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{images.length > 1 ? (
|
||||
<div className="flex items-center justify-center gap-2 overflow-auto py-1">
|
||||
<ul className="my-12 flex items-center justify-center gap-2 overflow-auto py-1 lg:mb-0">
|
||||
{images.map((image, index) => {
|
||||
const isActive = index === currentImageIndex;
|
||||
const isActive = index === imageIndex;
|
||||
const imageSearchParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
imageSearchParams.set('image', index.toString());
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Enlarge product image"
|
||||
key={image.src}
|
||||
className="h-auto w-20"
|
||||
onClick={() => setCurrentImageIndex(index)}
|
||||
>
|
||||
<GridTileImage
|
||||
alt={image.altText}
|
||||
src={image.src}
|
||||
width={600}
|
||||
height={600}
|
||||
active={isActive}
|
||||
/>
|
||||
</button>
|
||||
<li key={image.src} className="h-20 w-20">
|
||||
<Link
|
||||
aria-label="Enlarge product image"
|
||||
href={createUrl(pathname, imageSearchParams)}
|
||||
scroll={false}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<GridTileImage
|
||||
alt={image.altText}
|
||||
src={image.src}
|
||||
width={80}
|
||||
height={80}
|
||||
active={isActive}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,11 @@
|
||||
import { AddToCart } from 'components/cart/add-to-cart';
|
||||
import Price from 'components/price';
|
||||
import Prose from 'components/prose';
|
||||
import { FitToCart } from 'lib/ai/fit-to-cart';
|
||||
import { Product } from 'lib/shopify/types';
|
||||
// import { VariantSelector } from './variant-selector';
|
||||
|
||||
export function ProductDescription({ product, searchParams }: { product: Product; searchParams: URLSearchParams }) {
|
||||
async function addToCart() {
|
||||
'use server';
|
||||
|
||||
if (!product.availableForSale) return;
|
||||
|
||||
console.log(product.variants)
|
||||
const variant = product.variants.find((variant: ProductVariant) =>
|
||||
variant.selectedOptions.every(
|
||||
(option) => option.value === searchParams.get(option.name.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
const variantId = variant?.id || product.variants[0]!.id
|
||||
|
||||
console.log(variantId)
|
||||
// const error = await addItem(variantId);
|
||||
|
||||
// if (error) {
|
||||
// console.error(error);
|
||||
// return;
|
||||
// }
|
||||
}
|
||||
import { VariantSelector } from './variant-selector';
|
||||
|
||||
export function ProductDescription({ product }: { product: Product }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700">
|
||||
@@ -39,7 +17,7 @@ export function ProductDescription({ product, searchParams }: { product: Product
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* <VariantSelector options={product.options} variants={product.variants} /> */}
|
||||
<VariantSelector options={product.options} variants={product.variants} />
|
||||
|
||||
{product.descriptionHtml ? (
|
||||
<Prose
|
||||
@@ -47,8 +25,8 @@ export function ProductDescription({ product, searchParams }: { product: Product
|
||||
html={product.descriptionHtml}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AddToCart availableForSale={product.availableForSale} addToCart={addToCart} />
|
||||
<AddToCart variants={product.variants} availableForSale={product.availableForSale} />
|
||||
<FitToCart currentProduct={product} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,18 +3,12 @@
|
||||
import clsx from 'clsx';
|
||||
import { ProductOption, ProductVariant } from 'lib/shopify/types';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
type ParamsMap = {
|
||||
[key: string]: string; // ie. { color: 'Red', size: 'Large', ... }
|
||||
};
|
||||
|
||||
type OptimizedVariant = {
|
||||
type Combination = {
|
||||
id: string;
|
||||
availableForSale: boolean;
|
||||
params: URLSearchParams;
|
||||
[key: string]: string | boolean | URLSearchParams; // ie. { color: 'Red', size: 'Large', ... }
|
||||
[key: string]: string | boolean; // ie. { color: 'Red', size: 'Large', ... }
|
||||
};
|
||||
|
||||
export function VariantSelector({
|
||||
@@ -24,9 +18,9 @@ export function VariantSelector({
|
||||
options: ProductOption[];
|
||||
variants: ProductVariant[];
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const currentParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const hasNoOptionsOrJustOneOption =
|
||||
!options.length || (options.length === 1 && options[0]?.values.length === 1);
|
||||
|
||||
@@ -34,103 +28,76 @@ export function VariantSelector({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Discard any unexpected options or values from url and create params map.
|
||||
const paramsMap: ParamsMap = Object.fromEntries(
|
||||
Array.from(currentParams.entries()).filter(([key, value]) =>
|
||||
options.find((option) => option.name.toLowerCase() === key && option.values.includes(value))
|
||||
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 }),
|
||||
{}
|
||||
)
|
||||
);
|
||||
|
||||
// Optimize variants for easier lookups.
|
||||
const optimizedVariants: OptimizedVariant[] = variants.map((variant) => {
|
||||
const optimized: OptimizedVariant = {
|
||||
id: variant.id,
|
||||
availableForSale: variant.availableForSale,
|
||||
params: new URLSearchParams()
|
||||
};
|
||||
|
||||
variant.selectedOptions.forEach((selectedOption) => {
|
||||
const name = selectedOption.name.toLowerCase();
|
||||
const value = selectedOption.value;
|
||||
|
||||
optimized[name] = value;
|
||||
optimized.params.set(name, value);
|
||||
});
|
||||
|
||||
return optimized;
|
||||
});
|
||||
|
||||
// Find the first variant that is:
|
||||
//
|
||||
// 1. Available for sale
|
||||
// 2. Matches all options specified in the url (note that this
|
||||
// could be a partial match if some options are missing from the url).
|
||||
//
|
||||
// If no match (full or partial) is found, use the first variant that is
|
||||
// available for sale.
|
||||
const selectedVariant: OptimizedVariant | undefined =
|
||||
optimizedVariants.find(
|
||||
(variant) =>
|
||||
variant.availableForSale &&
|
||||
Object.entries(paramsMap).every(([key, value]) => variant[key] === value)
|
||||
) || optimizedVariants.find((variant) => variant.availableForSale);
|
||||
|
||||
const selectedVariantParams = new URLSearchParams(selectedVariant?.params);
|
||||
const currentUrl = createUrl(pathname, currentParams);
|
||||
const selectedVariantUrl = createUrl(pathname, selectedVariantParams);
|
||||
|
||||
if (currentUrl !== selectedVariantUrl) {
|
||||
router.replace(selectedVariantUrl);
|
||||
}
|
||||
}));
|
||||
|
||||
return options.map((option) => (
|
||||
<dl className="mb-8" key={option.id}>
|
||||
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
|
||||
<dd className="flex flex-wrap gap-3">
|
||||
{option.values.map((value) => {
|
||||
// Base option params on selected variant params.
|
||||
const optionParams = new URLSearchParams(selectedVariantParams);
|
||||
// Update the params using the current option to reflect how the url would change.
|
||||
optionParams.set(option.name.toLowerCase(), value);
|
||||
const optionNameLowerCase = option.name.toLowerCase();
|
||||
|
||||
const optionUrl = createUrl(pathname, optionParams);
|
||||
// Base option params on current params so we can preserve any other param state in the url.
|
||||
const optionSearchParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
// The option is active if it in the url params.
|
||||
const isActive = selectedVariantParams.get(option.name.toLowerCase()) === 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);
|
||||
|
||||
// The option is available for sale if it fully matches the variant in the option's url params.
|
||||
// It's super important to note that this is the options params, *not* the selected variant's params.
|
||||
// This is the "magic" that will cross check possible future variant combinations and preemptively
|
||||
// disable combinations that are not possible.
|
||||
const isAvailableForSale = optimizedVariants.find((a) =>
|
||||
Array.from(optionParams.entries()).every(([key, value]) => a[key] === value)
|
||||
)?.availableForSale;
|
||||
// In order to determine if an option is available for sale, we need to:
|
||||
//
|
||||
// 1. Filter out all other param state
|
||||
// 2. Filter out invalid options
|
||||
// 3. Check if the option combination is available for sale
|
||||
//
|
||||
// This is the "magic" that will cross check possible variant combinations and preemptively
|
||||
// disable combinations that are not available. For example, if the color gray is only available in size medium,
|
||||
// then all other sizes should be disabled.
|
||||
const filtered = Array.from(optionSearchParams.entries()).filter(([key, value]) =>
|
||||
options.find(
|
||||
(option) => option.name.toLowerCase() === key && option.values.includes(value)
|
||||
)
|
||||
);
|
||||
const isAvailableForSale = combinations.find((combination) =>
|
||||
filtered.every(
|
||||
([key, value]) => combination[key] === value && combination.availableForSale
|
||||
)
|
||||
);
|
||||
|
||||
const DynamicTag = isAvailableForSale ? Link : 'p';
|
||||
const dynamicProps = {
|
||||
...(isAvailableForSale && { scroll: false })
|
||||
};
|
||||
// The option is active if it's in the url params.
|
||||
const isActive = searchParams.get(optionNameLowerCase) === value;
|
||||
|
||||
return (
|
||||
<DynamicTag
|
||||
<button
|
||||
key={value}
|
||||
aria-disabled={!isAvailableForSale}
|
||||
href={optionUrl}
|
||||
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-900 dark:bg-neutral-900',
|
||||
'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900',
|
||||
{
|
||||
'cursor-default ring-2 ring-blue-600': isActive,
|
||||
'ring-1 ring-transparent transition duration-300 ease-in-out hover:scale-110 hover:ring-blue-600 ':
|
||||
!isActive && isAvailableForSale,
|
||||
'relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-400 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-600 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 before:dark:bg-neutral-700':
|
||||
!isAvailableForSale
|
||||
}
|
||||
)}
|
||||
{...dynamicProps}
|
||||
>
|
||||
{value}
|
||||
</DynamicTag>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</dd>
|
||||
|
||||
84
lib/ai/fit-to-cart.tsx
Normal file
84
lib/ai/fit-to-cart.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { OpenAIStream, StreamingTextResponse } from 'ai';
|
||||
import { getCart } from 'lib/shopify';
|
||||
import { Product } from 'lib/shopify/types';
|
||||
import { unstable_cache } from 'next/cache';
|
||||
import { cookies } from 'next/headers';
|
||||
import OpenAI from 'openai';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
async function getCartFromCookies() {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
if (cartId) {
|
||||
return getCart(cartId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function FitToCart({ currentProduct }: { currentProduct: Product }) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<FitToCartInternal currentProduct={currentProduct} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function FitToCartInternal({ currentProduct }: { currentProduct: Product }) {
|
||||
const pitch = await getPitch({ currentProduct });
|
||||
if (!pitch) return null;
|
||||
return <div className="mt-6 text-sm leading-tight dark:text-white/[60%]">{pitch}</div>;
|
||||
}
|
||||
|
||||
const fireworks = new OpenAI({
|
||||
baseURL: 'https://api.fireworks.ai/inference/v1',
|
||||
apiKey: process.env.FIREWORKS_API_KEY!
|
||||
});
|
||||
|
||||
function buildPrompt(prompt: string) {
|
||||
return prompt.split('\n').map((message) => ({
|
||||
role: 'user' as const,
|
||||
content: message
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getPitch({ currentProduct }: { currentProduct: Product }) {
|
||||
const cart = await getCartFromCookies();
|
||||
if (!cart) return null;
|
||||
const products = cart.lines
|
||||
.filter((line) => line.merchandise.product.id !== currentProduct.id)
|
||||
.map((line) => `"${line.merchandise.product.title}"`);
|
||||
if (!products.length) return null;
|
||||
|
||||
const prompt = `Write a 30 word pitch for why a person who has ${products.join(
|
||||
' and '
|
||||
)} in their shopping cart should also purchase the "${currentProduct.title}"`;
|
||||
|
||||
const query = {
|
||||
model: 'accounts/fireworks/models/mistral-7b-instruct-4k',
|
||||
stream: true,
|
||||
messages: buildPrompt(prompt),
|
||||
max_tokens: 1000,
|
||||
temperature: 0.75,
|
||||
top_p: 1,
|
||||
frequency_penalty: 1
|
||||
} as const;
|
||||
|
||||
return unstable_cache(async () => {
|
||||
// Request the Fireworks API for the response based on the prompt
|
||||
const response = await fireworks.chat.completions.create(query);
|
||||
|
||||
// Convert the response into a friendly text-stream
|
||||
const stream = OpenAIStream(response);
|
||||
|
||||
// Respond with the stream
|
||||
const streamingResponse = new StreamingTextResponse(stream);
|
||||
let text = await streamingResponse.text();
|
||||
// Remove the quotes from the response tht the LLM sometimes adds.
|
||||
text = text.trim().replace(/^"/, '').replace(/"$/, '');
|
||||
return text;
|
||||
}, [
|
||||
JSON.stringify(query),
|
||||
'1.0',
|
||||
process.env.VERCEL_BRANCH_URL || '',
|
||||
process.env.NODE_ENV || ''
|
||||
])();
|
||||
}
|
||||
@@ -22,7 +22,8 @@ export const sorting: SortFilterItem[] = [
|
||||
|
||||
export const TAGS = {
|
||||
collections: 'collections',
|
||||
products: 'products'
|
||||
products: 'products',
|
||||
cart: 'cart'
|
||||
};
|
||||
|
||||
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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 { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
addToCartMutation,
|
||||
createCartMutation,
|
||||
@@ -46,7 +50,9 @@ import {
|
||||
ShopifyUpdateCartOperation
|
||||
} from './types';
|
||||
|
||||
const domain = `https://${process.env.SHOPIFY_STORE_DOMAIN!}`;
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
|
||||
: '';
|
||||
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
|
||||
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
|
||||
|
||||
@@ -94,6 +100,7 @@ export async function shopifyFetch<T>({
|
||||
} catch (e) {
|
||||
if (isShopifyError(e)) {
|
||||
throw {
|
||||
cause: e.cause?.toString() || 'unknown',
|
||||
status: e.status || 500,
|
||||
message: e.message,
|
||||
query
|
||||
@@ -203,6 +210,10 @@ export async function createCart(): Promise<Cart> {
|
||||
return reshapeCart(res.body.data.cartCreate.cart);
|
||||
}
|
||||
|
||||
function revalidateCart(cartId: string) {
|
||||
revalidateTag(`cart-${cartId}`);
|
||||
}
|
||||
|
||||
export async function addToCart(
|
||||
cartId: string,
|
||||
lines: { merchandiseId: string; quantity: number }[]
|
||||
@@ -215,6 +226,7 @@ export async function addToCart(
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
revalidateCart(cartId);
|
||||
return reshapeCart(res.body.data.cartLinesAdd.cart);
|
||||
}
|
||||
|
||||
@@ -227,6 +239,7 @@ export async function removeFromCart(cartId: string, lineIds: string[]): Promise
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
revalidateCart(cartId);
|
||||
|
||||
return reshapeCart(res.body.data.cartLinesRemove.cart);
|
||||
}
|
||||
@@ -243,6 +256,7 @@ export async function updateCart(
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
revalidateCart(cartId);
|
||||
|
||||
return reshapeCart(res.body.data.cartLinesUpdate.cart);
|
||||
}
|
||||
@@ -251,7 +265,7 @@ export async function getCart(cartId: string): Promise<Cart | undefined> {
|
||||
const res = await shopifyFetch<ShopifyCartOperation>({
|
||||
query: getCartQuery,
|
||||
variables: { cartId },
|
||||
cache: 'no-store'
|
||||
tags: [TAGS.cart, `cart-${cartId}`]
|
||||
});
|
||||
|
||||
// Old carts becomes `null` when you checkout.
|
||||
@@ -408,3 +422,35 @@ export async function getProducts({
|
||||
|
||||
return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
|
||||
}
|
||||
|
||||
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
|
||||
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 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 });
|
||||
}
|
||||
|
||||
if (!isCollectionUpdate && !isProductUpdate) {
|
||||
// We don't need to revalidate anything for any other topics.
|
||||
return NextResponse.json({ status: 200 });
|
||||
}
|
||||
|
||||
if (isCollectionUpdate) {
|
||||
revalidateTag(TAGS.collections);
|
||||
}
|
||||
|
||||
if (isProductUpdate) {
|
||||
revalidateTag(TAGS.products);
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface ShopifyErrorLike {
|
||||
status: number;
|
||||
message: Error;
|
||||
cause?: Error;
|
||||
}
|
||||
|
||||
export const isObject = (object: unknown): object is Record<string, unknown> => {
|
||||
|
||||
31
lib/utils.ts
31
lib/utils.ts
@@ -6,3 +6,34 @@ export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyUR
|
||||
|
||||
return `${pathname}${queryString}`;
|
||||
};
|
||||
|
||||
export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
|
||||
stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
|
||||
|
||||
export const validateEnvironmentVariables = () => {
|
||||
const requiredEnvironmentVariables = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_STOREFRONT_ACCESS_TOKEN'];
|
||||
const missingEnvironmentVariables = [] as string[];
|
||||
|
||||
requiredEnvironmentVariables.forEach((envVar) => {
|
||||
if (!process.env[envVar]) {
|
||||
missingEnvironmentVariables.push(envVar);
|
||||
}
|
||||
});
|
||||
|
||||
if (missingEnvironmentVariables.length) {
|
||||
throw new Error(
|
||||
`The following environment variables are missing. Your site will not work without them. Read more: https://vercel.com/docs/integrations/shopify#configure-environment-variables\n\n${missingEnvironmentVariables.join(
|
||||
'\n'
|
||||
)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.SHOPIFY_STORE_DOMAIN?.includes('[') ||
|
||||
process.env.SHOPIFY_STORE_DOMAIN?.includes(']')
|
||||
) {
|
||||
throw new Error(
|
||||
'Your `SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,9 +4,6 @@ module.exports = {
|
||||
// Disabling on production builds because we're running checks on PRs via GitHub Actions.
|
||||
ignoreDuringBuilds: true
|
||||
},
|
||||
experimental: {
|
||||
serverActions: true
|
||||
},
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
remotePatterns: [
|
||||
@@ -16,5 +13,14 @@ module.exports = {
|
||||
pathname: '/s/files/**'
|
||||
}
|
||||
]
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/password',
|
||||
destination: '/',
|
||||
permanent: true
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
39
package.json
39
package.json
@@ -2,7 +2,7 @@
|
||||
"private": true,
|
||||
"packageManager": "pnpm@8.2.0",
|
||||
"engines": {
|
||||
"node": ">=16",
|
||||
"node": ">=18",
|
||||
"pnpm": ">=7"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -22,30 +22,33 @@
|
||||
"*": "prettier --write --ignore-unknown"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"ai": "^2.2.33",
|
||||
"clsx": "^2.0.0",
|
||||
"next": "13.4.12",
|
||||
"geist": "^1.0.0",
|
||||
"next": "14.1.1-canary.27",
|
||||
"openai": "^4.26.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/node": "20.4.4",
|
||||
"@types/react": "18.2.16",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/node": "20.8.9",
|
||||
"@types/react": "18.2.33",
|
||||
"@types/react-dom": "18.2.14",
|
||||
"@vercel/git-hooks": "^1.0.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-config-next": "^13.4.12",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-unicorn": "^48.0.0",
|
||||
"lint-staged": "^13.2.3",
|
||||
"postcss": "^8.4.27",
|
||||
"prettier": "^3.0.0",
|
||||
"prettier-plugin-tailwindcss": "^0.4.1",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "5.1.6"
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.52.0",
|
||||
"eslint-config-next": "^14.0.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-unicorn": "^48.0.1",
|
||||
"lint-staged": "^15.0.2",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "3.0.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.6",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "5.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
2249
pnpm-lock.yaml
generated
2249
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,9 @@
|
||||
/** @type {import('prettier').Config} */
|
||||
module.exports = {
|
||||
singleQuote: true,
|
||||
arrowParens: 'always',
|
||||
trailingComma: 'none',
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
// pnpm doesn't support plugin autoloading
|
||||
// https://github.com/tailwindlabs/prettier-plugin-tailwindcss#installation
|
||||
plugins: [require('prettier-plugin-tailwindcss')]
|
||||
plugins: ['prettier-plugin-tailwindcss']
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['var(--font-inter)']
|
||||
sans: ['var(--font-geist-sans)']
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
|
||||
Reference in New Issue
Block a user