Next.js Commerce refresh. (#966)

We're making some updates to Next.js Commerce. Everything prior to this commit marks what we're calling [`v1`](https://github.com/vercel/commerce/releases/tag/v1) as a point in time to be able to reference and still use going into the future. The current architecture of Commerce is a multi-vendor, interoperable solution, including:

- [Shopify](https://shopify.vercel.store/)
- [Swell](https://swell.vercel.store/)
- [BigCommerce](https://bigcommerce.vercel.store/)
- [Vendure](https://vendure.vercel.store/)
- [Saleor](https://saleor.vercel.store/)
- [Ordercloud](https://ordercloud.vercel.store/)
- [Spree](https://spree.vercel.store/)
- [Kibo Commerce](https://kibocommerce.vercel.store/)
- [Commerce.js](https://commercejs.vercel.store/)
- [SalesForce Cloud Commerce](https://salesforce-cloud-commerce.vercel.store/)

All features can be toggled on or off, and it's easy to change between commerce providers. To support this, we needed to create a ["commerce metaframework"](d1d9e8c434/packages/commerce/new-provider.md) where providers could confirm to an API spec to add support for Next.js Commerce. While this worked and was successful for `v1`, we have different design goals and ambitions for `v2`.

**What You Need To Know**

- `v1` will not be updated moving forward. If you need to reference `v1`, you will still be able to clone and deploy the version tagged at this release.
- `v2` will be shifting to be a single provider vs. provider agnostic. Other providers are welcome to fork this repository and swap out the underlying `lib/` implementation that connects to the selected commerce provider (Shopify). This architecture was chosen to reduce the surface area of the codebase, remove the intermediate metaframework layer for provider-interoperability, and enable usage with the latest Next.js and React features.
- We will be sharing more about `v2` in the future as we continue to iterate before the marked release.
This commit is contained in:
Lee Robinson 2023-04-17 23:00:47 -04:00 committed by GitHub
parent d1d9e8c434
commit fd9450aecb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
1288 changed files with 4997 additions and 148456 deletions

View File

@ -1,23 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 2
tab_width = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[*.js]
quote_type = single
[{*.c,*.cc,*.h,*.hh,*.cpp,*.hpp,*.m,*.mm,*.mpp,*.js,*.java,*.go,*.rs,*.php,*.ng,*.jsx,*.ts,*.d,*.cs,*.swift}]
curly_bracket_next_line = false
spaces_around_operators = true
spaces_around_brackets = outside
# close enough to 1TB
indent_brace_style = K&R

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
SHOPIFY_STOREFRONT_ACCESS_TOKEN=
SHOPIFY_STORE_DOMAIN=
SHOPIFY_REVALIDATION_TOKEN=

23
.eslintrc.js Normal file
View File

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

View File

@ -1,55 +0,0 @@
name: Core package Bug Report
description: Create a bug report for the Next.js commerce core package
labels: 'template: core bug'
body:
- type: markdown
attributes:
value: Thanks for taking the time to file a bug report! Please fill out this form as completely as possible.
- type: checkboxes
attributes:
label: Verify latest commit
description: `main` is the latest version of Next.js Commerce.
options:
- label: I verified that the issue exists on `main`
required: true
- type: textarea
attributes:
label: Provide environment information
description: Please run `npx --no-install next info` in the root directory of your project and paste the results.
validations:
required: true
- type: input
attributes:
label: What browser are you using? (if relevant)
description: 'Please specify the exact version. For example: Chrome 100.0.4878.0'
- type: input
attributes:
label: How are you deploying your application? (if relevant)
description: 'For example: next start, next export, Vercel, Other platform'
- type: textarea
attributes:
label: Describe the Bug
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
attributes:
label: To Reproduce
description: Steps to reproduce the behavior, please provide a clear code snippets that always reproduces the issue or a GitHub repository. Screenshots can be provided in the issue body below.
validations:
required: true
- type: markdown
attributes:
value: Before posting the issue go through the steps you've written down to make sure the steps provided are detailed and clear.
- type: markdown
attributes:
value: Contributors should be able to follow the steps provided in order to reproduce the bug.
- type: markdown
attributes:
value: These steps are used to add integration tests to ensure the same issue does not happen again. Thanks in advance!

View File

@ -1,59 +0,0 @@
name: Provider package Bug Report
description: Create a bug report for the Next.js commerce core package
labels: 'template: provider bug'
body:
- type: markdown
attributes:
value: Thanks for taking the time to file a bug report! Please fill out this form as completely as possible.
- type: checkboxes
attributes:
label: Verify latest commit
description: `main` is the latest version of Next.js Commerce.
options:
- label: I verified that the issue exists on `main`
required: true
- type: textarea
attributes:
label: Provide environment information
description: Please run `npx --no-install next info` in the root directory of your project and paste the results.
validations:
required: true
- type: input
attributes:
label: What Provider are you using?
description: 'Please specify the provider package name. For example: `bigcommerce`'
- type: input
attributes:
label: What browser are you using? (if relevant)
description: 'Please specify the exact version. For example: Chrome 100.0.4878.0'
- type: input
attributes:
label: How are you deploying your application? (if relevant)
description: 'For example: next start, next export, Vercel, Other platform'
- type: textarea
attributes:
label: Describe the Bug
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
attributes:
label: To Reproduce
description: Steps to reproduce the behavior, please provide a clear code snippets that always reproduces the issue or a GitHub repository. Screenshots can be provided in the issue body below.
validations:
required: true
- type: markdown
attributes:
value: Before posting the issue go through the steps you've written down to make sure the steps provided are detailed and clear.
- type: markdown
attributes:
value: Contributors should be able to follow the steps provided in order to reproduce the bug.
- type: markdown
attributes:
value: These steps are used to add integration tests to ensure the same issue does not happen again. Thanks in advance!

View File

@ -1,28 +0,0 @@
name: Feature Request
description: Create a feature request for the Next.js core
labels: 'template: story'
body:
- type: markdown
attributes:
value: Thanks for taking the time to file a feature request! Please fill out this form as completely as possible.
- type: markdown
attributes:
value: 'Feature requests will be converted to the GitHub Discussions "Ideas" section.'
- type: textarea
attributes:
label: Describe the feature you'd like to request
description: A clear and concise description of what you want and what your use case is.
validations:
required: true
- type: textarea
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: true

View File

@ -1,18 +0,0 @@
name: 'Docs Request for an Update or Improvement'
description: A request to update or improve Next.js Commerce documentation
title: 'Docs: '
labels:
- 'template: documentation'
body:
- type: textarea
attributes:
label: What is the improvement or update you wish to see?
description: 'Example: I would like to see more examples of how to use hooks.'
validations:
required: true
- type: textarea
attributes:
label: Is there any context that might help us understand?
description: A clear description of any added context that might help us understand.
validations:
required: true

View File

@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Ask a question
url: https://github.com/vercel/commerce/discussions
about: Ask questions and discuss with other community members

6
.github/dependabot.yml vendored Normal file
View File

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

49
.github/workflows/e2e.yml vendored Normal file
View File

@ -0,0 +1,49 @@
name: e2e
on:
schedule:
# Runs "at 09:00 and 15:00, Monday through Friday" (see https://crontab.guru)
- cron: '0 9,15 * * 1-5'
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- name: Cancel running workflows
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Checkout repo
uses: actions/checkout@v3
- name: Set node version
uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
- name: Set pnpm version
uses: pnpm/action-setup@v2
with:
run_install: false
version: 7
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
run: pnpm install
- name: Get playwright version
run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./node_modules/@playwright/test/package.json').version)")" >> $GITHUB_ENV
- name: Cache playwright
uses: actions/cache@v3
id: playwright-cache
with:
path: '~/.cache/ms-playwright'
key: playwright-cache-${{ env.PLAYWRIGHT_VERSION }}
- name: Install playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps
- name: Install playwright browser dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps
- name: Run tests
run: pnpm test:e2e

32
.github/workflows/test.yml vendored Normal file
View File

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

31
.gitignore vendored
View File

@ -1,39 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
/node_modules
/.pnp
.pnp.js
.pnpm-debug.log
# testing
coverage
/coverage
.playwright
# next.js
.next
out
/.next/
/out/
# production
build
dist
/build
# misc
.DS_Store
*.pem
.idea
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*
!.env.example
# vercel
.vercel
# Turborepo
.turbo
# typescript
*.tsbuildinfo
next-env.d.ts

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
16

View File

@ -1,5 +1,3 @@
# Every package defines its prettier config
node_modules
dist
.vercel
.next
public
pnpm-lock.yaml

View File

@ -1,6 +0,0 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false
}

View File

@ -1,8 +0,0 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"csstools.postcss",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-typescript-next"
]
}

28
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,28 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev",
"serverReadyAction": {
"pattern": "started server on .+, url: (https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

View File

@ -1,4 +1,9 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
"typescript.tsdk": "node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.codeActionsOnSave": {
"source.fixAll": true,
"source.organizeImports": true,
"source.sortMembers": true
}
}

194
README.md
View File

@ -1,199 +1,31 @@
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-description=An%20all-in-one%20starter%20kit%20for%20high-performance%20e-commerce%20sites.&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&integration-ids=oac_MuWZiE4jtmQ2ejZQaQ7ncuDT,oac_9HSKtXld74NG0srzdxSiBGty&skippable-integrations=1&root-directory=site&build-command=cd%20..%20%26%26%20yarn%20build)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=SHOPIFY_STOREFRONT_ACCESS_TOKEN,SHOPIFY_STORE_DOMAIN,SHOPIFY_REVALIDATION_TOKEN)
# Next.js Commerce
The all-in-one starter kit for high-performance e-commerce sites. With a few clicks, Next.js developers can clone, deploy and fully customize their own store.
Start right now at [nextjs.org/commerce](https://nextjs.org/commerce)
Next.js 13 and App Router-ready ecommerce template, built with Shopify and Tailwind CSS.
Demo live at: [demo.vercel.store](https://demo.vercel.store/)
We will be shortly updating the demo at [demo.vercel.store](https://demo.vercel.store/) with this new version.
- Shopify Demo: https://shopify.vercel.store/
- Swell Demo: https://swell.vercel.store/
- BigCommerce Demo: https://bigcommerce.vercel.store/
- Vendure Demo: https://vendure.vercel.store
- Saleor Demo: https://saleor.vercel.store/
- Ordercloud Demo: https://ordercloud.vercel.store/
- Spree Demo: https://spree.vercel.store/
- Kibo Commerce Demo: https://kibocommerce.vercel.store/
- Commerce.js Demo: https://commercejs.vercel.store/
- SalesForce Cloud Commerce Demo: https://salesforce-cloud-commerce.vercel.store/
## Run minimal version locally
> To run a minimal version of Next.js Commerce you can start with the default local provider `@vercel/commerce-local` that has all features disabled (cart, auth) and uses static files for the backend
```bash
pnpm install & pnpm build # run these commands in the root folder of the mono repo
pnpm dev # run this command in the site folder
```
> If you encounter any problems while installing and running for the first time, please see the Troubleshoot section
Looking for Next.js Commerce v1? [View the release notes](https://github.com/vercel/commerce/releases/tag/v1).
## Features
- Performant by default
- SEO Ready
- Internationalization
- Responsive
- UI Components
- Theming
- Standardized Data Hooks
- Integrations - Integrate seamlessly with the most common ecommerce platforms.
- Dark Mode Support
- Next.js App Router
- Optimized for SEO
- Styling with Tailwind CSS
- Checkout/Cart with Shopify
- Themeing with System (Light/Dark Mode) Support
## Integrations
Next.js Commerce integrates out-of-the-box with BigCommerce, Shopify, Swell, Saleor, Vendure, Spree and Commerce.js. We plan to support all major ecommerce backends.
## Considerations
- `packages/commerce` contains all types, helpers and functions to be used as a base to build a new **provider**.
- **Providers** live under `packages`'s root folder and they will extend Next.js Commerce types and functionality (`packages/commerce`).
- We have a **Features API** to ensure feature parity between the UI and the Provider. The UI should update accordingly and no extra code should be bundled. All extra configuration for features will live under `features` in `commerce.config.json` and if needed it can also be accessed programmatically.
- Each **provider** should add its corresponding `next.config.js` and `commerce.config.json` adding specific data related to the provider. For example in the case of BigCommerce, the images CDN and additional API routes.
## Configuration
### How to change providers
Open `site/.env.local` and change the value of `COMMERCE_PROVIDER` to the provider you would like to use, then set the environment variables for that provider (use `site/.env.template` as the base).
The setup for Shopify would look like this for example:
```
COMMERCE_PROVIDER=@vercel/commerce-shopify
NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN=xxxxxxx.myshopify.com
```
### Features
Every provider defines the features that it supports under `packages/{provider}/src/commerce.config.json`
#### Features Available
The following features can be enabled or disabled. This means that the UI will remove all code related to the feature.
For example: turning `cart` off will disable Cart capabilities.
- cart
- search
- wishlist
- customerAuth
- customCheckout
#### How to turn Features on and off
> NOTE: The selected provider should support the feature that you are toggling. (This means that you can't turn wishlist on if the provider doesn't support this functionality out of the box)
- Open `site/commerce.config.json`
- You'll see a config file like this:
```json
{
"features": {
"wishlist": false,
"customCheckout": true
}
}
```
- Turn `wishlist` on by setting `wishlist` to `true`.
- Run the app and the wishlist functionality should be back on.
### How to create a new provider
Follow our docs for [Adding a new Commerce Provider](packages/commerce/new-provider.md).
If you succeeded building a provider, submit a PR with a valid demo and we'll review it asap.
## Contribute
Our commitment to Open Source can be found [here](https://vercel.com/oss).
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
2. Create a new branch `git checkout -b MY_BRANCH_NAME`
3. Install the dependencies: `pnpm install`
4. Build the packages: `pnpm build`
5. Duplicate `site/.env.template` and rename it to `site/.env.local`
6. Add proper store values to `site/.env.local`
7. Run `cd site` & `pnpm dev` to watch for code changes
8. Run `pnpm turbo run build` to check the build after your changes
## Work in progress
We're using Github Projects to keep track of issues in progress and todo's. Here is our [Board](https://github.com/vercel/commerce/projects/1)
People actively working on this project: @okbel, @lfades, @dominiksipowicz, @gbibeaul.
## Troubleshoot
<details>
<summary>I already own a BigCommerce store. What should I do?</summary>
<br>
First thing you do is: <b>set your environment variables</b>
<br>
<br>
.env.local
```sh
BIGCOMMERCE_STOREFRONT_API_URL=<>
BIGCOMMERCE_STOREFRONT_API_TOKEN=<>
BIGCOMMERCE_STORE_API_URL=<>
BIGCOMMERCE_STORE_API_TOKEN=<>
BIGCOMMERCE_STORE_API_CLIENT_ID=<>
BIGCOMMERCE_CHANNEL_ID=<>
```
If your project was started with a "Deploy with Vercel" button, you can use Vercel's CLI to retrieve these credentials.
## Running Locally
1. Install Vercel CLI: `npm i -g vercel`
2. Link local instance with Vercel and Github accounts (creates .vercel file): `vercel link`
3. Download your environment variables: `vercel env pull .env.local`
Next, you're free to customize the starter. More updates coming soon. Stay tuned..
</details>
<details>
<summary>BigCommerce shows a Coming Soon page and requests a Preview Code</summary>
<br>
After Email confirmation, Checkout should be manually enabled through BigCommerce platform. Look for "Review & test your store" section through BigCommerce's dashboard.
<br>
<br>
BigCommerce team has been notified and they plan to add more details about this subject.
</details>
<details>
<summary>When run locally I get `Error: Cannot find module '...@vercel/commerce/dist/config'`</summary>
```bash
commerce/site
yarn dev
yarn run v1.22.17
$ next dev
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
info - Loaded env from /commerce/site/.env.local
error - Failed to load next.config.js, see more info here https://nextjs.org/docs/messages/next-config-error
Error: Cannot find module '/Users/dom/work/vercel/commerce/node_modules/@vercel/commerce/dist/config.cjs'
at createEsmNotFoundErr (node:internal/modules/cjs/loader:960:15)
at finalizeEsmResolution (node:internal/modules/cjs/loader:953:15)
at resolveExports (node:internal/modules/cjs/loader:482:14)
at Function.Module._findPath (node:internal/modules/cjs/loader:522:31)
at Function.Module._resolveFilename (node:internal/modules/cjs/loader:919:27)
at Function.mod._resolveFilename (/Users/dom/work/vercel/commerce/node_modules/next/dist/build/webpack/require-hook.js:179:28)
at Function.Module._load (node:internal/modules/cjs/loader:778:27)
at Module.require (node:internal/modules/cjs/loader:1005:19)
at require (node:internal/modules/cjs/helpers:102:18)
at Object.<anonymous> (/Users/dom/work/vercel/commerce/site/commerce-config.js:9:14) {
code: 'MODULE_NOT_FOUND',
path: '/Users/dom/work/vercel/commerce/node_modules/@vercel/commerce/package.json'
}
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
pnpm install
pnpm dev
```
The error usually occurs when running `pnpm dev` inside of the `/site/` folder after installing a fresh repository.
In order to fix this, run `pnpm build` in the monorepo root folder first.
> Using `pnpm dev` from the root is recommended for developing, which will run watch mode on all packages.
</details>
Your app should now be running on [localhost:3000](http://localhost:3000/).

16
app/[page]/layout.tsx Normal file
View File

@ -0,0 +1,16 @@
import Footer from 'components/layout/footer';
import { Suspense } from 'react';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<Suspense>
<div className="w-full bg-white dark:bg-black">
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">
<Suspense>{children}</Suspense>
</div>
</div>
{/* @ts-expect-error Server Component */}
<Footer />
</Suspense>
);
}

56
app/[page]/page.tsx Normal file
View File

@ -0,0 +1,56 @@
import type { Metadata } from 'next';
import Prose from 'components/prose';
import { getPage } from 'lib/shopify';
import { notFound } from 'next/navigation';
export const runtime = 'edge';
export const revalidate = 43200; // 12 hours in seconds
export async function generateMetadata({
params
}: {
params: { page: string };
}): Promise<Metadata> {
const page = await getPage(params.page);
if (!page) return notFound();
return {
title: page.seo?.title || page.title,
description: page.seo?.description || page.bodySummary,
openGraph: {
images: [
{
url: `/api/og?title=${encodeURIComponent(page.title)}`,
width: 1200,
height: 630
}
],
publishedTime: page.createdAt,
modifiedTime: page.updatedAt,
type: 'article'
}
};
}
export default async function Page({ params }: { params: { page: string } }) {
const page = await getPage(params.page);
if (!page) return notFound();
return (
<>
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
<Prose className="mb-8" html={page.body as string} />
<p className="text-sm italic">
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(new Date(page.updatedAt))}.`}
</p>
</>
);
}

75
app/api/cart/route.ts Normal file
View File

@ -0,0 +1,75 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { addToCart, removeFromCart, updateCart } from 'lib/shopify';
import { isShopifyError } from 'lib/type-guards';
function formatErrorMessage(err: Error): string {
return JSON.stringify(err, Object.getOwnPropertyNames(err));
}
export async function POST(req: NextRequest): Promise<Response> {
const cartId = cookies().get('cartId')?.value;
const { merchandiseId } = await req.json();
if (!cartId?.length || !merchandiseId?.length) {
return NextResponse.json({ error: 'Missing cartId or variantId' }, { status: 400 });
}
try {
await addToCart(cartId, [{ merchandiseId, quantity: 1 }]);
return NextResponse.json({ status: 204 });
} catch (e) {
if (isShopifyError(e)) {
return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status });
}
return NextResponse.json({ status: 500 });
}
}
export async function PUT(req: NextRequest): Promise<Response> {
const cartId = cookies().get('cartId')?.value;
const { variantId, quantity, lineId } = await req.json();
if (!cartId || !variantId || !quantity || !lineId) {
return NextResponse.json(
{ error: 'Missing cartId, variantId, lineId, or quantity' },
{ status: 400 }
);
}
try {
await updateCart(cartId, [
{
id: lineId,
merchandiseId: variantId,
quantity
}
]);
return NextResponse.json({ status: 204 });
} catch (e) {
if (isShopifyError(e)) {
return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status });
}
return NextResponse.json({ status: 500 });
}
}
export async function DELETE(req: NextRequest): Promise<Response> {
const cartId = cookies().get('cartId')?.value;
const { lineId } = await req.json();
if (!cartId || !lineId) {
return NextResponse.json({ error: 'Missing cartId or lineId' }, { status: 400 });
}
try {
await removeFromCart(cartId, [lineId]);
return NextResponse.json({ status: 204 });
} catch (e) {
if (isShopifyError(e)) {
return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status });
}
return NextResponse.json({ status: 500 });
}
}

BIN
app/api/og/Inter-Bold.ttf Normal file

Binary file not shown.

Binary file not shown.

66
app/api/og/route.tsx Normal file
View File

@ -0,0 +1,66 @@
import { ImageResponse } from '@vercel/og';
import { SITE_NAME } from 'lib/constants';
import { NextRequest } from 'next/server';
export const runtime = 'edge';
const interRegular = fetch(new URL('./Inter-Regular.ttf', import.meta.url)).then((res) =>
res.arrayBuffer()
);
const interBold = fetch(new URL('./Inter-Bold.ttf', import.meta.url)).then((res) =>
res.arrayBuffer()
);
export async function GET(req: NextRequest): Promise<Response | ImageResponse> {
try {
const [regularFont, boldFont] = await Promise.all([interRegular, interBold]);
const { searchParams } = new URL(req.url);
const title = searchParams.has('title') ? searchParams.get('title')?.slice(0, 100) : SITE_NAME;
return new ImageResponse(
(
<div tw="flex h-full w-full flex-col items-center justify-center bg-black">
<svg viewBox="0 0 32 32" width="140">
<rect width="100%" height="100%" rx="16" fill="white" />
<path
fillRule="evenodd"
clipRule="evenodd"
fill="black"
d="M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z"
/>
</svg>
<div tw="mt-12 text-6xl text-white font-bold">{title}</div>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: regularFont,
style: 'normal',
weight: 400
},
{
name: 'Inter',
data: boldFont,
style: 'normal',
weight: 700
}
]
}
);
} catch (e) {
if (!(e instanceof Error)) throw e;
console.log(e.message);
return new Response(`Failed to generate the image`, {
status: 500
});
}
}

10
app/error.tsx Normal file
View File

@ -0,0 +1,10 @@
'use client';
export default function Error({ reset }: { reset: () => void }) {
return (
<div>
<h2>Something went wrong.</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}

View File

Before

Width:  |  Height:  |  Size: 535 B

After

Width:  |  Height:  |  Size: 535 B

9
app/globals.css Normal file
View File

@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@supports (font: -apple-system-body) and (-webkit-appearance: none) {
img[loading='lazy'] {
clip-path: inset(0.6px);
}
}

42
app/layout.tsx Normal file
View File

@ -0,0 +1,42 @@
import { Inter } from 'next/font/google';
import { ReactNode, Suspense } from 'react';
import './globals.css';
import Navbar from 'components/layout/navbar';
import { SITE_CREATOR, SITE_CREATOR_URL, SITE_NAME } from 'lib/constants';
export const metadata = {
title: {
default: SITE_NAME,
template: `%s | ${SITE_NAME}`
},
robots: {
follow: true,
index: true
},
twitter: {
card: 'summary_large_image',
creator: SITE_CREATOR,
site: SITE_CREATOR_URL
}
};
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}>
<body className="bg-white text-black selection:bg-teal-300 dark:bg-black dark:text-white dark:selection:bg-fuchsia-600 dark:selection:text-white">
{/* @ts-expect-error Server Component */}
<Navbar />
<Suspense>
<main>{children}</main>
</Suspense>
</body>
</html>
);
}

38
app/page.tsx Normal file
View File

@ -0,0 +1,38 @@
import { Carousel } from 'components/carousel';
import { ThreeItemGrid } from 'components/grid/three-items';
import Footer from 'components/layout/footer';
import { SITE_NAME } from 'lib/constants';
import { Suspense } from 'react';
export const runtime = 'edge';
export const metadata = {
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
openGraph: {
images: [
{
url: `/api/og?title=${encodeURIComponent(SITE_NAME)}`,
width: 1200,
height: 630
}
],
type: 'website'
}
};
export default async function HomePage() {
return (
<>
{/* @ts-expect-error Server Component */}
<ThreeItemGrid />
<Suspense>
{/* @ts-expect-error Server Component */}
<Carousel />
<Suspense>
{/* @ts-expect-error Server Component */}
<Footer />
</Suspense>
</Suspense>
</>
);
}

View File

@ -0,0 +1,130 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Suspense } from 'react';
import Grid from 'components/grid';
import { GridTileImage } from 'components/grid/tile';
import Footer from 'components/layout/footer';
import { AddToCart } from 'components/product/add-to-cart';
import { Gallery } from 'components/product/gallery';
import { VariantSelector } from 'components/product/variant-selector';
import Prose from 'components/prose';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct, getProductRecommendations } from 'lib/shopify';
import { Image } from 'lib/shopify/types';
export const runtime = 'edge';
export async function generateMetadata({
params
}: {
params: { handle: string };
}): Promise<Metadata> {
const product = await getProduct(params.handle);
if (!product) return notFound();
const { url, width, height, altText: alt } = product.featuredImage || {};
const hide = !product.tags.includes(HIDDEN_PRODUCT_TAG);
return {
title: product.seo.title || product.title,
description: product.seo.description || product.description,
robots: {
index: hide,
follow: hide,
googleBot: {
index: hide,
follow: hide
}
},
openGraph: url
? {
images: [
{
url,
width,
height,
alt
}
]
}
: null
};
}
export default async function ProductPage({ params }: { params: { handle: string } }) {
const product = await getProduct(params.handle);
if (!product) return notFound();
return (
<div>
<div className="lg:grid lg:grid-cols-6">
<div className="lg:col-span-4">
<Gallery
title={product.title}
amount={product.priceRange.maxVariantPrice.amount}
currencyCode={product.priceRange.maxVariantPrice.currencyCode}
images={product.images.map((image: Image) => ({
src: image.url,
altText: image.altText
}))}
/>
</div>
<div className="p-6 lg:col-span-2">
{/* @ts-expect-error Server Component */}
<VariantSelector options={product.options} variants={product.variants} />
{product.descriptionHtml ? (
<Prose className="mb-6 text-sm leading-tight" html={product.descriptionHtml} />
) : null}
<AddToCart variants={product.variants} availableForSale={product.availableForSale} />
</div>
</div>
<Suspense>
{/* @ts-expect-error Server Component */}
<RelatedProducts id={product.id} />
<Suspense>
{/* @ts-expect-error Server Component */}
<Footer />
</Suspense>
</Suspense>
</div>
);
}
async function RelatedProducts({ id }: { id: string }) {
const relatedProducts = await getProductRecommendations(id);
if (!relatedProducts) return null;
return (
<div className="px-4 py-8">
<div className="mb-4 text-3xl font-bold">Related Products</div>
<Grid className="grid-cols-2 lg:grid-cols-5">
{relatedProducts.map((product) => {
return (
<Grid.Item key={product.id} className="animate-fadeIn">
<Link
aria-label={product.title}
className="border-gay-300 group relative block aspect-square overflow-hidden border bg-gray-50"
href={`/product/${product.handle}`}
>
<GridTileImage
alt={product.title}
src={product.featuredImage.url}
width={600}
height={600}
/>
</Link>
</Grid.Item>
);
})}
</Grid>
</div>
);
}

View File

@ -0,0 +1,46 @@
import { getCollection, getCollectionProducts } from 'lib/shopify';
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import SearchResults from 'components/layout/search/results';
export const runtime = 'edge';
export async function generateMetadata({
params
}: {
params: { collection: string };
}): Promise<Metadata> {
const collection = await getCollection(params.collection);
if (!collection) return notFound();
return {
title: collection.seo?.title || collection.title,
description:
collection.seo?.description || collection.description || `${collection.title} products`,
openGraph: {
images: [
{
url: `/api/og?title=${encodeURIComponent(collection.title)}`,
width: 1200,
height: 630
}
]
}
};
}
export default async function CategoryPage({ params }: { params: { collection: string } }) {
const products = await getCollectionProducts(params.collection);
return (
<section>
{/* @ts-expect-error Server Component */}
<SearchResults products={products} />
{products.length === 0 ? (
<p className="py-3 text-lg">{`No products found in this collection`}</p>
) : null}
</section>
);
}

23
app/search/layout.tsx Normal file
View File

@ -0,0 +1,23 @@
import Footer from 'components/layout/footer';
import Collections from 'components/layout/search/collections';
import FilterList from 'components/layout/search/filter';
import { sorting } from 'lib/constants';
import { Suspense } from 'react';
export default function SearchLayout({ children }: { children: React.ReactNode }) {
return (
<Suspense>
<div className="mx-auto flex max-w-7xl flex-col bg-white py-6 text-black dark:bg-black dark:text-white md:flex-row">
<div className="order-first flex-none md:w-1/6">
<Collections />
</div>
<div className="order-last min-h-screen w-full md:order-none">{children}</div>
<div className="order-none md:order-last md:w-1/6 md:flex-none">
<FilterList list={sorting} title="Sort by" />
</div>
</div>
{/* @ts-expect-error Server Component */}
<Footer />
</Suspense>
);
}

13
app/search/loading.tsx Normal file
View File

@ -0,0 +1,13 @@
import Grid from 'components/grid';
export default function Loading() {
return (
<Grid className="grid-cols-2 lg:grid-cols-3">
{Array(12)
.fill(0)
.map((_, index) => {
return <Grid.Item key={index} className="animate-pulse bg-gray-100 dark:bg-gray-900" />;
})}
</Grid>
);
}

41
app/search/page.tsx Normal file
View File

@ -0,0 +1,41 @@
import SearchResults from 'components/layout/search/results';
import { defaultSort, sorting } from 'lib/constants';
import { getProducts } from 'lib/shopify';
export const runtime = 'edge';
export const metadata = {
title: 'Search',
description: 'Search for products in the store.'
};
export default async function SearchPage({
searchParams
}: {
searchParams?: { [key: string]: string | string[] | undefined };
}) {
const { sort, q: searchValue } = searchParams as { [key: string]: string };
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getProducts({ sortKey, reverse, query: searchValue });
const resultsText = products.length > 1 ? 'results' : 'result';
return (
<>
{searchValue &&
(products.length > 0 ? (
<p>
{`Showing ${products.length} ${resultsText} for `}
<span className="font-bold">&quot;{searchValue}&quot;</span>
</p>
) : (
<p>
{'There are no products that match '}
<span className="font-bold">&quot;{searchValue}&quot;</span>
</p>
))}
{/* @ts-expect-error Server Component */}
<SearchResults products={products} />
</>
);
}

39
components/carousel.tsx Normal file
View File

@ -0,0 +1,39 @@
import { getCollectionProducts } from 'lib/shopify';
import Image from 'next/image';
import Link from 'next/link';
export async function Carousel() {
// Collections that start with `hidden-*` are hidden from the search page.
const products = await getCollectionProducts('hidden-homepage-carousel');
if (!products?.length) return null;
return (
<div className="relative w-full overflow-hidden bg-black dark:bg-white">
<div className="flex animate-carousel">
{[...products, ...products].map((product, i) => (
<Link
key={`${product.handle}${i}`}
href={`/product/${product.handle}`}
className="relative h-[30vh] w-1/2 flex-none md:w-1/3"
>
{product.featuredImage ? (
<Image
alt={product.title}
className="h-full object-contain"
fill
sizes="33vw"
src={product.featuredImage.url}
/>
) : null}
<div className="absolute inset-y-0 right-0 flex items-center justify-center">
<div className="inline-flex bg-white p-4 text-xl font-semibold text-black dark:bg-black dark:text-white">
{product.title}
</div>
</div>
</Link>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,64 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useCookies } from 'react-cookie';
import CartIcon from 'components/icons/cart';
import CartModal from './modal';
import type { Cart } from 'lib/shopify/types';
export default function CartButton({
cart,
cartIdUpdated
}: {
cart: Cart;
cartIdUpdated: boolean;
}) {
const [, setCookie] = useCookies(['cartId']);
const [cartIsOpen, setCartIsOpen] = useState(false);
const quantityRef = useRef(cart.totalQuantity);
// Temporary hack to update the `cartId` cookie when it changes since we cannot update it
// on the server-side (yet).
useEffect(() => {
if (cartIdUpdated) {
setCookie('cartId', cart.id, {
path: '/',
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production'
});
}
return;
}, [setCookie, cartIdUpdated, cart.id]);
useEffect(() => {
// Open cart modal when 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 (!cartIsOpen) {
setCartIsOpen(true);
}
// Always update the quantity reference
quantityRef.current = cart.totalQuantity;
}
}, [cartIsOpen, cart.totalQuantity, quantityRef]);
return (
<>
<CartModal isOpen={cartIsOpen} onClose={() => setCartIsOpen(false)} cart={cart} />
<button
aria-label="Open cart"
onClick={() => {
setCartIsOpen(true);
}}
className="relative top-0 right-0"
data-testid="open-cart"
>
<CartIcon quantity={cart.totalQuantity} />
</button>
</>
);
}

View File

@ -0,0 +1,50 @@
import CloseIcon from 'components/icons/close';
import LoadingDots from 'components/loading-dots';
import { useRouter } from 'next/navigation';
import { startTransition, useState } from 'react';
import type { CartItem } from 'lib/shopify/types';
export default function DeleteItemButton({ item }: { item: CartItem }) {
const router = useRouter();
const [removing, setRemoving] = useState(false);
async function handleRemove() {
setRemoving(true);
const response = await fetch(`/api/cart`, {
method: 'DELETE',
body: JSON.stringify({
lineId: item.id
})
});
const data = await response.json();
if (data.error) {
alert(data.error);
return;
}
setRemoving(false);
startTransition(() => {
router.refresh();
});
}
return (
<button
aria-label="Remove cart item"
onClick={handleRemove}
disabled={removing}
className={`${
removing ? 'cursor-not-allowed' : ''
} mr-2 flex h-8 w-8 items-center justify-center border border-black/40 bg-black/0 hover:bg-black/10 dark:border-white/40 dark:bg-white/0 dark:hover:bg-white/10`}
>
{removing ? (
<LoadingDots className="bg-white dark:bg-black" />
) : (
<CloseIcon className="hover:text-accent-3 h-6" />
)}
</button>
);
}

View File

@ -0,0 +1,62 @@
import { useRouter } from 'next/navigation';
import { startTransition, useState } from 'react';
import MinusIcon from 'components/icons/minus';
import PlusIcon from 'components/icons/plus';
import type { CartItem } from 'lib/shopify/types';
import LoadingDots from '../loading-dots';
export default function EditItemQuantityButton({
item,
type
}: {
item: CartItem;
type: 'plus' | 'minus';
}) {
const router = useRouter();
const [editing, setEditing] = useState(false);
async function handleEdit() {
setEditing(true);
const response = await fetch(`/api/cart`, {
method: type === 'minus' && item.quantity - 1 === 0 ? 'DELETE' : 'PUT',
body: JSON.stringify({
lineId: item.id,
variantId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
})
});
const data = await response.json();
if (data.error) {
alert(data.error);
return;
}
setEditing(false);
startTransition(() => {
router.refresh();
});
}
return (
<button
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
onClick={handleEdit}
disabled={editing}
className={`${editing ? 'cursor-not-allowed' : ''} ${
type === 'minus' ? 'ml-auto' : ''
} flex h-8 w-8 items-center justify-center border-l border-black/40 bg-black/0 hover:bg-black/10 dark:border-white/40 dark:bg-white/0 dark:hover:bg-white/10`}
>
{editing ? (
<LoadingDots className="bg-white dark:bg-black" />
) : type === 'plus' ? (
<PlusIcon className="h-4" />
) : (
<MinusIcon className="h-4" />
)}
</button>
);
}

23
components/cart/index.tsx Normal file
View File

@ -0,0 +1,23 @@
import { createCart, getCart } from 'lib/shopify';
import { cookies } from 'next/headers';
import CartButton from './button';
export default async function Cart() {
const cartId = cookies().get('cartId')?.value;
let cartIdUpdated = false;
let cart;
if (cartId) {
cart = await getCart(cartId);
}
// If the `cartId` from the cookie is not set or the cart is empty
// (old carts becomes `null` when you checkout), then get a new `cartId`
// and re-fetch the cart.
if (!cartId || !cart) {
cart = await createCart();
cartIdUpdated = true;
}
return <CartButton cart={cart} cartIdUpdated={cartIdUpdated} />;
}

174
components/cart/modal.tsx Normal file
View File

@ -0,0 +1,174 @@
import { Dialog } from '@headlessui/react';
import { AnimatePresence, motion } from 'framer-motion';
import Image from 'next/image';
import CloseIcon from 'components/icons/close';
import ShoppingBagIcon from 'components/icons/shopping-bag';
import Price from 'components/price';
import { DEFAULT_OPTION } from 'lib/constants';
import type { Cart } from 'lib/shopify/types';
import DeleteItemButton from './delete-item-button';
import EditItemQuantityButton from './edit-item-quantity-button';
export default function CartModal({
isOpen,
onClose,
cart
}: {
isOpen: boolean;
onClose: () => void;
cart: Cart;
}) {
return (
<AnimatePresence initial={false}>
{isOpen && (
<Dialog
as={motion.div}
initial="closed"
animate="open"
exit="closed"
key="dialog"
static
open={isOpen}
onClose={onClose}
className="relative z-50"
>
<motion.div
variants={{
open: { opacity: 1, backdropFilter: 'blur(0.5px)' },
closed: { opacity: 0, backdropFilter: 'blur(0px)' }
}}
className="fixed inset-0 bg-black/30"
aria-hidden="true"
/>
<div className="fixed inset-0 flex justify-end" data-testid="cart">
<Dialog.Panel
as={motion.div}
variants={{
open: { translateX: 0 },
closed: { translateX: '100%' }
}}
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
className="flex w-full flex-col bg-white p-8 text-black dark:bg-black dark:text-white md:w-1/3 lg:w-[30%] lg:px-6"
>
<div className="flex items-center justify-between">
<p className="text-lg font-bold">My Cart</p>
<button
aria-label="Close cart"
onClick={onClose}
className="text-black transition-colors hover:text-gray-500 dark:text-gray-100"
data-testid="close-cart"
>
<CloseIcon className="h-7" />
</button>
</div>
{cart.lines.length === 0 ? (
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
<ShoppingBagIcon className="h-16" />
<p className="mt-6 text-center text-2xl font-bold">Your cart is empty.</p>
</div>
) : null}
{cart.lines.length !== 0 ? (
<div className="flex h-full flex-col justify-between overflow-hidden">
<ul className="flex-grow overflow-auto p-6">
{cart.lines.map((item, i) => {
return (
<li key={i} data-testid="cart-item">
<div className="mb-2 flex w-full">
<div className="relative h-20 w-20 flex-none">
{item.merchandise.product.featuredImage.url && (
<Image
alt={
item.merchandise.product.featuredImage.altText ||
item.merchandise.product.title
}
className="bg-white"
fill
src={item.merchandise.product.featuredImage.url}
/>
)}
</div>
<div className="ml-4 flex w-full flex-col justify-between">
<div className="flex w-full justify-between">
<div>
<p
className="text-lg font-medium"
data-testid="cart-product-name"
>
{item.merchandise.product.title}
</p>
{item.merchandise.title !== DEFAULT_OPTION ? (
<p className="text-sm" data-testid="cart-product-variant">
{item.merchandise.title}
</p>
) : null}
</div>
<Price
className="font-medium"
amount={item.cost.totalAmount.amount}
currencyCode={item.cost.totalAmount.currencyCode}
/>
</div>
</div>
</div>
<div className="mb-4 flex w-full">
<DeleteItemButton item={item} />
<div className="flex h-8 w-full border border-black/40 dark:border-white/40">
<div className="flex h-full items-center px-2 ">{item.quantity}</div>
<EditItemQuantityButton item={item} type="minus" />
<EditItemQuantityButton item={item} type="plus" />
</div>
</div>
</li>
);
})}
</ul>
<div className="border-t border-white/60 p-6">
<div className="text-sm text-white">
<div className="mb-2 flex items-center justify-between">
<p>Subtotal</p>
<Price
className="text-right"
amount={cart.cost.subtotalAmount.amount}
currencyCode={cart.cost.subtotalAmount.currencyCode}
/>
</div>
<div className="mb-2 flex items-center justify-between">
<p>Taxes</p>
<Price
className="text-right"
amount={cart.cost.totalTaxAmount.amount}
currencyCode={cart.cost.totalTaxAmount.currencyCode}
/>
</div>
<div className="mb-2 flex items-center justify-between border-b border-white/30 pb-2">
<p>Shipping</p>
<p className="text-right uppercase">calculated at checkout</p>
</div>
<div className="mb-2 flex items-center justify-between font-bold">
<p>Total</p>
<Price
className="text-right"
amount={cart.cost.totalAmount.amount}
currencyCode={cart.cost.totalAmount.currencyCode}
/>
</div>
</div>
<a
href={cart.checkoutUrl}
className="mt-6 flex w-full items-center justify-center bg-black p-3 text-sm font-medium uppercase text-white opacity-90 hover:opacity-100 dark:bg-white dark:text-black"
>
<span>Proceed to Checkout</span>
</a>
</div>
</div>
) : null}
</Dialog.Panel>
</div>
</Dialog>
)}
</AnimatePresence>
);
}

26
components/grid/index.tsx Normal file
View File

@ -0,0 +1,26 @@
import clsx from 'clsx';
function Grid(props: React.ComponentProps<'ul'>) {
return (
<ul {...props} className={clsx('grid grid-flow-row gap-4 py-5', props.className)}>
{props.children}
</ul>
);
}
function GridItem(props: React.ComponentProps<'li'>) {
return (
<li
{...props}
className={clsx(
'relative aspect-square h-full w-full overflow-hidden transition-opacity',
props.className
)}
>
{props.children}
</li>
);
}
Grid.Item = GridItem;
export default Grid;

View File

@ -0,0 +1,53 @@
import { GridTileImage } from 'components/grid/tile';
import { getCollectionProducts } from 'lib/shopify';
import type { Product } from 'lib/shopify/types';
import Link from 'next/link';
function ThreeItemGridItem({
item,
size,
background
}: {
item: Product;
size: 'full' | 'half';
background: 'white' | 'pink' | 'purple' | 'black';
}) {
return (
<div
className={size === 'full' ? 'lg:col-span-4 lg:row-span-2' : 'lg:col-span-2 lg:row-span-1'}
>
<Link className="block h-full" href={`/product/${item.handle}`}>
<GridTileImage
src={item.featuredImage.url}
width={size === 'full' ? 1080 : 540}
height={size === 'full' ? 1080 : 540}
priority={true}
background={background}
alt={item.title}
labels={{
title: item.title as string,
amount: item.priceRange.maxVariantPrice.amount,
currencyCode: item.priceRange.maxVariantPrice.currencyCode
}}
/>
</Link>
</div>
);
}
export async function ThreeItemGrid() {
// Collections that start with `hidden-*` are hidden from the search page.
const homepageItems = await getCollectionProducts('hidden-homepage-featured-items', 3);
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
return (
<section className="lg:grid lg:grid-cols-6 lg:grid-rows-2" data-testid="homepage-products">
<ThreeItemGridItem size="full" item={firstProduct} background="purple" />
<ThreeItemGridItem size="half" item={secondProduct} background="black" />
<ThreeItemGridItem size="half" item={thirdProduct} background="pink" />
</section>
);
}

70
components/grid/tile.tsx Normal file
View File

@ -0,0 +1,70 @@
import clsx from 'clsx';
import Image from 'next/image';
import Price from 'components/price';
export function GridTileImage({
isInteractive = true,
background,
active,
labels,
...props
}: {
isInteractive?: boolean;
background?: 'white' | 'pink' | 'purple' | 'black' | 'purple-dark' | 'blue' | 'cyan' | 'gray';
active?: boolean;
labels?: {
title: string;
amount: string;
currencyCode: string;
isSmall?: boolean;
};
} & React.ComponentProps<typeof Image>) {
return (
<div
className={clsx('relative flex h-full w-full items-center justify-center overflow-hidden', {
'bg-white dark:bg-white': background === 'white',
'bg-[#ff0080] dark:bg-[#ff0080]': background === 'pink',
'bg-[#7928ca] dark:bg-[#7928ca]': background === 'purple',
'bg-gray-900 dark:bg-gray-900': background === 'black',
'bg-violetDark dark:bg-violetDark': background === 'purple-dark',
'bg-blue-500 dark:bg-blue-500': background === 'blue',
'bg-cyan-500 dark:bg-cyan-500': background === 'cyan',
'bg-gray-100 dark:bg-gray-100': background === 'gray',
'bg-gray-100 dark:bg-gray-900': !background,
relative: labels
})}
>
{active !== undefined && active ? (
<span className="absolute h-full w-full bg-white opacity-25"></span>
) : null}
{props.src ? (
<Image
className={clsx('relative h-full w-full object-contain', {
'transition duration-300 ease-in-out hover:scale-105': isInteractive
})}
{...props}
alt={props.title || ''}
/>
) : null}
{labels ? (
<div className="absolute top-0 left-0 w-3/4 text-black dark:text-white">
<h3
data-testid="product-name"
className={clsx(
'inline bg-white box-decoration-clone py-3 pl-5 font-semibold leading-loose shadow-[1.25rem_0_0] shadow-white dark:bg-black dark:shadow-black',
!labels.isSmall ? 'text-3xl' : 'text-lg'
)}
>
{labels.title}
</h3>
<Price
className="w-fit bg-white px-5 py-3 text-sm font-semibold dark:bg-black dark:text-white"
amount={labels.amount}
currencyCode={labels.currencyCode}
/>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,14 @@
export default function ArrowLeftIcon({ 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="M19 12H5" />
<path d="M12 19L5 12L12 5" />
</svg>
);
}

View File

@ -1,20 +1,17 @@
const ChevronRight = ({ ...props }) => {
export default function CaretRightIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision"
{...props}
className={className}
>
<path d="M9 18l6-6-6-6" />
</svg>
)
);
}
export default ChevronRight

26
components/icons/cart.tsx Normal file
View File

@ -0,0 +1,26 @@
import clsx from 'clsx';
import ShoppingBagIcon from './shopping-bag';
export default function CartIcon({
className,
quantity
}: {
className?: string;
quantity?: number;
}) {
return (
<div className="relative">
<ShoppingBagIcon
className={clsx(
'h-6 transition-all ease-in-out hover:scale-110 hover:text-gray-500 dark:hover:text-gray-300',
className
)}
/>
{quantity ? (
<div className="absolute bottom-0 left-0 -mb-3 -ml-3 flex h-5 w-5 items-center justify-center rounded-full border-2 border-white bg-black text-xs text-white dark:border-black dark:bg-white dark:text-black">
{quantity}
</div>
) : null}
</div>
);
}

View File

@ -1,21 +1,17 @@
const Cross = ({ ...props }) => {
export default function CloseIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision"
{...props}
className={className}
>
<path d="M18 6L6 18" />
<path d="M6 6l12 12" />
</svg>
)
);
}
export default Cross

View File

@ -0,0 +1,13 @@
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>
);
}

22
components/icons/logo.tsx Normal file
View File

@ -0,0 +1,22 @@
import { SITE_NAME } from 'lib/constants';
export default function LogoIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-label={`${SITE_NAME} logo`}
viewBox="0 0 32 32"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
shapeRendering="geometricPrecision"
className={className}
>
<rect width="100%" height="100%" rx="16" className="fill-black dark:fill-white" />
<path
className=" fill-white dark:fill-black"
d="M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z"
/>
</svg>
);
}

View File

@ -1,20 +1,16 @@
const ChevronDown = ({ ...props }) => {
export default function MenuIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision"
{...props}
className={className}
>
<path d="M6 9l6 6 6-6" />
<path d="M4 6h16M4 12h16m-7 6h7" />
</svg>
)
);
}
export default ChevronDown

View File

@ -1,20 +1,16 @@
const ChevronLeft = ({ ...props }) => {
export default function MinusIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision"
{...props}
className={className}
>
<path d="M15 18l-6-6 6-6" />
<path d="M5 12H19" />
</svg>
)
);
}
export default ChevronLeft

View File

@ -1,20 +1,17 @@
const ChevronUp = ({ ...props }) => {
export default function PlusIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision"
{...props}
className={className}
>
<path d="M18 15l-6-6-6 6" />
<path d="M12 5V19" />
<path d="M5 12H19" />
</svg>
)
);
}
export default ChevronUp

View File

@ -0,0 +1,11 @@
export default function SearchIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
/>
</svg>
);
}

View File

@ -0,0 +1,19 @@
export default function ShoppingBagIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 22"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
shapeRendering="geometricPrecision"
className={className}
>
<path d="M4 1L1 5V19C1 19.5304 1.21071 20.0391 1.58579 20.4142C1.96086 20.7893 2.46957 21 3 21H17C17.5304 21 18.0391 20.7893 18.4142 20.4142C18.7893 20.0391 19 19.5304 19 19V5L16 1H4Z" />
<path d="M1 5H19" />
<path d="M14 9C14 10.0609 13.5786 11.0783 12.8284 11.8284C12.0783 12.5786 11.0609 13 10 13C8.93913 13 7.92172 12.5786 7.17157 11.8284C6.42143 11.0783 6 10.0609 6 9" />
</svg>
);
}

View File

@ -0,0 +1,20 @@
export default function VercelIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-label="Vercel.com Logo"
viewBox="0 0 89 20"
fill="currentColor"
shapeRendering="geometricPrecision"
className={className}
>
<path d="M11.5625 0L23.125 20H0L11.5625 0Z" />
<path d="M49.875 10.625C49.875 7.40625 47.5 5.15625 44.0937 5.15625C40.6875 5.15625 38.3125 7.40625 38.3125 10.625C38.3125 13.7812 40.875 16.0937 44.4062 16.0937C46.3438 16.0937 48.0938 15.375 49.2188 14.0625L47.0938 12.8437C46.4375 13.5 45.4688 13.9062 44.4062 13.9062C42.8438 13.9062 41.5 13.0625 41.0312 11.7812L40.9375 11.5625H49.7812C49.8438 11.25 49.875 10.9375 49.875 10.625ZM40.9062 9.6875L40.9688 9.5C41.375 8.15625 42.5625 7.34375 44.0625 7.34375C45.5938 7.34375 46.75 8.15625 47.1562 9.5L47.2188 9.6875H40.9062Z" />
<path d="M83.5313 10.625C83.5313 7.40625 81.1563 5.15625 77.75 5.15625C74.3438 5.15625 71.9688 7.40625 71.9688 10.625C71.9688 13.7812 74.5313 16.0937 78.0625 16.0937C80 16.0937 81.75 15.375 82.875 14.0625L80.75 12.8437C80.0938 13.5 79.125 13.9062 78.0625 13.9062C76.5 13.9062 75.1563 13.0625 74.6875 11.7812L74.5938 11.5625H83.4375C83.5 11.25 83.5313 10.9375 83.5313 10.625ZM74.5625 9.6875L74.625 9.5C75.0313 8.15625 76.2188 7.34375 77.7188 7.34375C79.25 7.34375 80.4063 8.15625 80.8125 9.5L80.875 9.6875H74.5625Z" />
<path d="M68.5313 8.84374L70.6563 7.62499C69.6563 6.06249 67.875 5.18749 65.7188 5.18749C62.3125 5.18749 59.9375 7.43749 59.9375 10.6562C59.9375 13.875 62.3125 16.125 65.7188 16.125C67.875 16.125 69.6563 15.25 70.6563 13.6875L68.5313 12.4687C67.9688 13.4062 66.9688 13.9375 65.7188 13.9375C63.75 13.9375 62.4375 12.625 62.4375 10.6562C62.4375 8.68749 63.75 7.37499 65.7188 7.37499C66.9375 7.37499 67.9688 7.90624 68.5313 8.84374Z" />
<path d="M88.2188 1.75H85.7188V15.8125H88.2188V1.75Z" />
<path d="M40.1563 1.75H37.2813L31.7813 11.25L26.2813 1.75H23.375L31.7813 16.25L40.1563 1.75Z" />
<path d="M57.8438 8.0625C58.125 8.0625 58.4062 8.09375 58.6875 8.15625V5.5C56.5625 5.5625 54.5625 6.75 54.5625 8.21875V5.5H52.0625V15.8125H54.5625V11.3437C54.5625 9.40625 55.9062 8.0625 57.8438 8.0625Z" />
</svg>
);
}

View File

@ -0,0 +1,65 @@
import Link from 'next/link';
import GitHubIcon from 'components/icons/github';
import LogoIcon from 'components/icons/logo';
import VercelIcon from 'components/icons/vercel';
import { SITE_NAME } from 'lib/constants';
import { getMenu } from 'lib/shopify';
import { Menu } from 'lib/shopify/types';
export default async function Footer() {
const menu = await getMenu('next-js-frontend-footer-menu');
return (
<footer className="border-t border-gray-700 bg-white text-black dark:bg-black dark:text-white">
<div className="mx-auto w-full max-w-7xl px-6">
<div className="grid grid-cols-1 gap-8 border-b border-gray-700 py-12 transition-colors duration-150 lg:grid-cols-12">
<div className="col-span-1 lg:col-span-3">
<a className="flex flex-initial items-center font-bold md:mr-24" href="/">
<span className="mr-2">
<LogoIcon className="h-8" />
</span>
<span>{SITE_NAME}</span>
</a>
</div>
{menu.length ? (
<nav className="col-span-1 lg:col-span-7">
<ul className="grid md:grid-flow-col md:grid-cols-3 md:grid-rows-4">
{menu.map((item: Menu) => (
<li key={item.title} className="py-3 md:py-0 md:pb-4">
<Link
href={item.path}
className="text-gray-800 transition duration-150 ease-in-out hover:text-gray-300 dark:text-gray-100"
>
{item.title}
</Link>
</li>
))}
</ul>
</nav>
) : null}
<div className="col-span-1 text-black dark:text-white lg:col-span-2">
<a aria-label="Github Repository" href="https://github.com/vercel/commerce">
<GitHubIcon className="h-6" />
</a>
</div>
</div>
<div className="flex flex-col items-center justify-between space-y-4 pt-6 pb-10 text-sm md:flex-row">
<p>&copy; 2023 {SITE_NAME}. All rights reserved.</p>
<div className="flex items-center text-sm text-white dark:text-black">
<span className="text-black dark:text-white">Created by</span>
<a
rel="noopener noreferrer"
href="https://vercel.com"
aria-label="Vercel.com Link"
target="_blank"
className="text-black dark:text-white"
>
<VercelIcon className="ml-3 inline-block h-6" />
</a>
</div>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,53 @@
import Link from 'next/link';
import { Suspense } from 'react';
import Cart from 'components/cart';
import CartIcon from 'components/icons/cart';
import LogoIcon from 'components/icons/logo';
import { getMenu } from 'lib/shopify';
import { Menu } from 'lib/shopify/types';
import MobileMenu from './mobile-menu';
import Search from './search';
export default async function Navbar() {
const menu = await getMenu('next-js-frontend-header-menu');
return (
<nav className="relative flex items-center justify-between bg-white p-4 dark:bg-black lg:px-6">
<div className="block w-1/3 md:hidden">
<MobileMenu menu={menu} />
</div>
<div className="flex justify-self-center md:w-1/3 md:justify-self-start">
<div className="md:mr-4">
<Link href="/" aria-label="Go back home">
<LogoIcon className="h-8 transition-transform hover:scale-110" />
</Link>
</div>
{menu.length ? (
<ul className="hidden md:flex">
{menu.map((item: Menu) => (
<li key={item.title}>
<Link
href={item.path}
className="rounded-lg px-2 py-1 text-gray-800 hover:text-gray-500 dark:text-gray-200 dark:hover:text-gray-400"
>
{item.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
<div className="hidden w-1/3 md:block">
<Search />
</div>
<div className="flex w-1/3 justify-end">
<Suspense fallback={<CartIcon className="h-6" />}>
{/* @ts-expect-error Server Component */}
<Cart />
</Suspense>
</div>
</nav>
);
}

View File

@ -0,0 +1,98 @@
'use client';
import { Dialog } from '@headlessui/react';
import { motion } from 'framer-motion';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import CloseIcon from 'components/icons/close';
import MenuIcon from 'components/icons/menu';
import { Menu } from 'lib/shopify/types';
import Search from './search';
export default function MobileMenu({ menu }: { menu: Menu[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [mobileMenuIsOpen, setMobileMenuIsOpen] = useState(false);
useEffect(() => {
const handleResize = () => {
if (window.innerWidth > 768) {
setMobileMenuIsOpen(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [mobileMenuIsOpen]);
useEffect(() => {
setMobileMenuIsOpen(false);
}, [pathname, searchParams]);
return (
<>
<button
onClick={() => {
setMobileMenuIsOpen(!mobileMenuIsOpen);
}}
aria-label="Open mobile menu"
className="md:hidden"
data-testid="open-mobile-menu"
>
<MenuIcon className="h-6" />
</button>
<Dialog
open={mobileMenuIsOpen}
onClose={() => {
setMobileMenuIsOpen(false);
}}
className="relative z-50"
>
<div className="fixed inset-0 flex justify-end" data-testid="mobile-menu">
<Dialog.Panel
as={motion.div}
variants={{
open: { opacity: 1 }
}}
className="flex w-full flex-col bg-white pb-6 dark:bg-black"
>
<div className="p-4">
<button
className="mb-4"
onClick={() => {
setMobileMenuIsOpen(false);
}}
aria-label="Close mobile menu"
data-testid="close-mobile-menu"
>
<CloseIcon className="h-6" />
</button>
<div className="mb-4 w-full">
<Search />
</div>
{menu.length ? (
<ul className="flex 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-gray-500 dark:text-white"
onClick={() => {
setMobileMenuIsOpen(false);
}}
>
{item.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
</Dialog.Panel>
</div>
</Dialog>
</>
);
}

View File

@ -0,0 +1,42 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import SearchIcon from 'components/icons/search';
export default function Search() {
const router = useRouter();
const searchParams = useSearchParams();
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const val = e.target as HTMLFormElement;
const search = val.search as HTMLInputElement;
if (search.value) {
router.push(`/search?q=${search.value}`);
} else {
router.push(`/search`);
}
}
return (
<form
onSubmit={onSubmit}
className="relative m-0 flex w-full items-center border border-gray-200 bg-transparent p-0 dark:border-gray-500"
>
<input
type="text"
name="search"
placeholder="Search for products..."
autoComplete="off"
defaultValue={searchParams?.get('q') || ''}
className="w-full py-2 px-4 text-black dark:bg-black dark:text-gray-100"
/>
<div className="absolute top-0 right-0 mr-3 flex h-full items-center">
<SearchIcon className="h-5" />
</div>
</form>
);
}

View File

@ -0,0 +1,38 @@
import clsx from 'clsx';
import { Suspense } from 'react';
import { getCollections } from 'lib/shopify';
import FilterList from './filter';
async function CollectionList() {
const collections = await getCollections();
return <FilterList list={collections} title="Collections" />;
}
const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded';
const activeAndTitles = 'bg-gray-800 dark:bg-gray-300';
const items = 'bg-gray-400 dark:bg-gray-700';
export default function Collections() {
return (
<Suspense
fallback={
<div className="col-span-2 hidden h-[400px] w-full flex-none py-4 pl-10 lg:block">
<div className={clsx(skeleton, activeAndTitles)} />
<div className={clsx(skeleton, activeAndTitles)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
</div>
}
>
{/* @ts-expect-error Server Component */}
<CollectionList />
</Suspense>
);
}

View File

@ -0,0 +1,64 @@
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import Caret from 'components/icons/caret-right';
import type { ListItem } from '.';
import { FilterItem } from './item';
export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [active, setActive] = useState('');
const [openSelect, setOpenSelect] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpenSelect(false);
}
};
window.addEventListener('click', handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside);
}, []);
useEffect(() => {
list.forEach((listItem: ListItem) => {
if (
('path' in listItem && pathname === listItem.path) ||
('slug' in listItem && searchParams.get('sort') === listItem.slug)
) {
setActive(listItem.title);
}
});
}, [pathname, list, searchParams]);
return (
<div className="relative" ref={ref}>
<div
onClick={() => {
setOpenSelect(!openSelect);
}}
className="flex w-full items-center justify-between rounded border border-black/30 px-4 py-2 text-sm dark:border-white/30"
>
<div>{active}</div>
<Caret className="h-4 rotate-90" />
</div>
{openSelect && (
<div
onClick={() => {
setOpenSelect(false);
}}
className="absolute z-40 w-full rounded-b-md bg-white p-4 shadow-md dark:bg-black"
>
{list.map((item: ListItem, i) => (
<FilterItem key={i} item={item} />
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,34 @@
import { SortFilterItem } from 'lib/constants';
import FilterItemDropdown from './dropdown';
import { FilterItem } from './item';
export type ListItem = SortFilterItem | PathFilterItem;
export type PathFilterItem = { title: string; path: string };
function FilterItemList({ list }: { list: ListItem[] }) {
return (
<div className="hidden md:block">
{list.map((item: ListItem, i) => (
<FilterItem key={i} item={item} />
))}
</div>
);
}
export default function FilterList({ list, title }: { list: ListItem[]; title?: string }) {
return (
<>
<nav className="col-span-2 w-full flex-none px-6 py-2 md:py-4 md:pl-10">
{title ? (
<h3 className="hidden font-semibold text-black dark:text-white md:block">{title}</h3>
) : null}
<ul className="hidden md:block">
<FilterItemList list={list} />
</ul>
<ul className="md:hidden">
<FilterItemDropdown list={list} />
</ul>
</nav>
</>
);
}

View File

@ -0,0 +1,67 @@
'use client';
import clsx from 'clsx';
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);
useEffect(() => {
setActive(pathname === item.path);
}, [pathname, item.path]);
return (
<li className="mt-2 flex text-sm text-gray-400" key={item.title}>
<Link
href={createUrl(item.path, searchParams)}
className={clsx('w-full hover:text-gray-800 dark:hover:text-gray-100', {
'text-gray-600 dark:text-gray-400': !active,
'font-semibold text-black dark:text-white': active
})}
>
{item.title}
</Link>
</li>
);
}
function SortFilterItem({ item }: { item: SortFilterItem }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [active, setActive] = useState(searchParams.get('sort') === item.slug);
useEffect(() => {
setActive(searchParams.get('sort') === item.slug);
}, [searchParams, item.slug]);
const href =
item.slug && item.slug.length
? createUrl(pathname, new URLSearchParams({ sort: item.slug }))
: pathname;
return (
<li className="mt-2 flex text-sm text-gray-400" key={item.title}>
<Link
prefetch={false}
href={href}
className={clsx('w-full hover:text-gray-800 dark:hover:text-gray-100', {
'text-gray-600 dark:text-gray-400': !active,
'font-semibold text-black dark:text-white': active
})}
>
{item.title}
</Link>
</li>
);
}
export function FilterItem({ item }: { item: ListItem }) {
return 'path' in item ? <PathFilterItem item={item} /> : <SortFilterItem item={item} />;
}

View File

@ -0,0 +1,33 @@
import Grid from 'components/grid';
import { GridTileImage } from 'components/grid/tile';
import { Product } from 'lib/shopify/types';
import Link from 'next/link';
export default async function SearchResults({ products }: { products: Product[] }) {
return (
<>
{products.length ? (
<Grid className="grid-cols-2 lg:grid-cols-3">
{products.map((product) => (
<Grid.Item key={product.handle} className="animate-fadeIn">
<Link className="h-full w-full" href={`/product/${product.handle}`}>
<GridTileImage
alt={product.title}
labels={{
isSmall: true,
title: product.title,
amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode
}}
src={product.featuredImage.url}
width={600}
height={600}
/>
</Link>
</Grid.Item>
))}
</Grid>
) : null}
</>
);
}

View File

@ -0,0 +1,15 @@
import clsx from 'clsx';
const dots = 'mx-[1px] inline-block h-1 w-1 animate-blink rounded-md';
const LoadingDots = ({ className }: { className: string }) => {
return (
<span className="mx-2 inline-flex items-center">
<span className={clsx(dots, className)} />
<span className={clsx(dots, 'animation-delay-[200ms]', className)} />
<span className={clsx(dots, 'animation-delay-[400ms]', className)} />
</span>
);
};
export default LoadingDots;

18
components/price.tsx Normal file
View File

@ -0,0 +1,18 @@
const Price = ({
amount,
currencyCode = 'USD',
...props
}: {
amount: string;
currencyCode: string;
} & React.ComponentProps<'p'>) => (
<p suppressHydrationWarning={true} {...props}>
{`${new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currencyCode,
currencyDisplay: 'narrowSymbol'
}).format(parseFloat(amount))} ${currencyCode}`}
</p>
);
export default Price;

View File

@ -0,0 +1,74 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
import LoadingDots from 'components/loading-dots';
import { ProductVariant } from 'lib/shopify/types';
export function AddToCart({
variants,
availableForSale
}: {
variants: ProductVariant[];
availableForSale: boolean;
}) {
const [selectedVariantId, setSelectedVariantId] = useState(variants[0]?.id);
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [adding, setAdding] = useState(false);
useEffect(() => {
const variant = variants.find((variant: ProductVariant) =>
variant.selectedOptions.every(
(option) => option.value === searchParams.get(option.name.toLowerCase())
)
);
if (variant) {
setSelectedVariantId(variant.id);
}
}, [searchParams, variants, setSelectedVariantId]);
const isMutating = adding || isPending;
async function handleAdd() {
if (!availableForSale) return;
setAdding(true);
const response = await fetch(`/api/cart`, {
method: 'POST',
body: JSON.stringify({
merchandiseId: selectedVariantId
})
});
const data = await response.json();
if (data.error) {
alert(data.error);
return;
}
setAdding(false);
startTransition(() => {
router.refresh();
});
}
return (
<button
aria-label="Add item to cart"
onClick={handleAdd}
className={`${
availableForSale ? 'opacity-90 hover:opacity-100' : 'cursor-not-allowed opacity-60'
} flex w-full items-center justify-center bg-black p-4 text-sm uppercase tracking-wide text-white dark:bg-white dark:text-black`}
>
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
{isMutating ? <LoadingDots className="bg-white dark:bg-black" /> : null}
</button>
);
}

View File

@ -0,0 +1,99 @@
'use client';
import { useState } from 'react';
import clsx from 'clsx';
import { GridTileImage } from 'components/grid/tile';
import ArrowLeftIcon from 'components/icons/arrow-left';
export function Gallery({
title,
amount,
currencyCode,
images
}: {
title: string;
amount: string;
currencyCode: string;
images: { src: string; altText: string }[];
}) {
const [currentImage, setCurrentImage] = useState(0);
function handleNavigate(direction: 'next' | 'previous') {
if (direction === 'next') {
setCurrentImage(currentImage + 1 < images.length ? currentImage + 1 : 0);
} else {
setCurrentImage(currentImage === 0 ? images.length - 1 : currentImage - 1);
}
}
const buttonClassName =
'px-9 cursor-pointer ease-in-and-out duration-200 transition-bg bg-[#7928ca] hover:bg-violetDark';
return (
<div className="h-full">
<div className="relative h-full max-h-[600px] overflow-hidden">
{images[currentImage] && (
<GridTileImage
src={images[currentImage]?.src as string}
alt={images[currentImage]?.altText as string}
width={600}
height={600}
isInteractive={false}
priority={true}
background="purple"
labels={{
title,
amount,
currencyCode
}}
/>
)}
{images.length > 1 ? (
<div className="absolute bottom-10 right-10 flex h-12 flex-row border border-white text-white shadow-xl dark:border-black dark:text-black">
<button
aria-label="Previous product image"
className={clsx(buttonClassName, 'border-r border-white dark:border-black')}
onClick={() => handleNavigate('previous')}
>
<ArrowLeftIcon className="h-6" />
</button>
<button
aria-label="Next product image"
className={clsx(buttonClassName)}
onClick={() => handleNavigate('next')}
>
<ArrowLeftIcon className="h-6 rotate-180" />
</button>
</div>
) : null}
</div>
{images.length > 1 ? (
<div className="flex">
{images.map((image, index) => {
const isActive = index === currentImage;
return (
<button
aria-label="Enlarge product image"
key={image.src}
className="h-full w-1/4"
onClick={() => setCurrentImage(index)}
>
<GridTileImage
alt={image.altText}
src={image.src}
width={600}
height={600}
background="purple-dark"
active={isActive}
/>
</button>
);
})}
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,135 @@
'use client';
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 = {
id: string;
availableForSale: boolean;
params: URLSearchParams;
[key: string]: string | boolean | URLSearchParams; // ie. { color: 'Red', size: 'Large', ... }
};
export function VariantSelector({
options,
variants
}: {
options: ProductOption[];
variants: ProductVariant[];
}) {
const pathname = usePathname();
const currentParams = useSearchParams();
const router = useRouter();
const hasNoOptionsOrJustOneOption =
!options.length || (options.length === 1 && options[0]?.values.length === 1);
if (hasNoOptionsOrJustOneOption) {
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))
)
);
// 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 optionUrl = createUrl(pathname, optionParams);
// The option is active if it in the url params.
const isActive = selectedVariantParams.get(option.name.toLowerCase()) === value;
// 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;
const DynamicTag = isAvailableForSale ? Link : 'p';
return (
<DynamicTag
key={value}
href={optionUrl}
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
className={clsx(
'flex h-12 min-w-[48px] items-center justify-center rounded-full px-2 text-sm',
{
'cursor-default ring-2 ring-black dark:ring-white': isActive,
'ring-1 ring-gray-300 transition duration-300 ease-in-out hover:scale-110 hover:bg-gray-100 hover:ring-black dark:ring-gray-700 dark:hover:bg-transparent dark:hover:ring-white':
!isActive && isAvailableForSale,
'relative z-10 cursor-not-allowed overflow-hidden bg-gray-100 ring-1 ring-gray-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-gray-300 before:transition-transform dark:bg-gray-900 dark:ring-gray-700 before:dark:bg-gray-700':
!isAvailableForSale
}
)}
data-testid={isActive ? 'selected-variant' : 'variant'}
>
{value}
</DynamicTag>
);
})}
</dd>
</dl>
));
}

21
components/prose.tsx Normal file
View File

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

73
e2e/cart.spec.ts Normal file
View File

@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test';
const regex = (text: string) => new RegExp(text, 'gim');
test('should be able to open and close cart', async ({ page }) => {
let cart;
await page.goto('/');
await page.getByTestId('open-cart').click();
cart = await page.getByTestId('cart');
await expect(cart).toBeVisible();
await expect(cart).toHaveText(regex('your cart is empty'));
await page.getByTestId('close-cart').click();
cart = await page.getByTestId('cart');
await expect(cart).toBeHidden();
});
test('should be able to add item to cart, without selecting a variant, assuming first variant', async ({
page
}) => {
await page.goto('/');
await page.getByTestId('homepage-products').locator('a').first().click();
const productName = await page.getByTestId('product-name').first().innerText();
const firstVariant = await page.getByTestId('variant').first().innerText();
await page.getByRole('button', { name: regex('add to cart') }).click();
const cart = await page.getByTestId('cart');
await expect(cart).toBeVisible();
const cartItems = await page.getByTestId('cart-item').all();
let isItemInCart = false;
for (const item of cartItems) {
const cartProductName = await item.getByTestId('cart-product-name').innerText();
const cartProductVariant = await item.getByTestId('cart-product-variant').innerText();
if (cartProductName === productName && cartProductVariant === firstVariant) {
isItemInCart = true;
break;
}
}
await expect(isItemInCart).toBe(true);
});
test('should be able to add item to cart by selecting a variant', async ({ page }) => {
await page.goto('/');
await page.getByTestId('homepage-products').locator('a').first().click();
const selectedProductName = await page.getByTestId('product-name').first().innerText();
const secondVariant = await page.getByTestId('variant').nth(1);
await secondVariant.click();
const selectedProductVariant = await page.getByTestId('selected-variant').innerText();
await page.getByRole('button', { name: regex('add to cart') }).click();
const cart = await page.getByTestId('cart');
await expect(cart).toBeVisible();
const cartItem = await page.getByTestId('cart-item').first();
const cartItemProductName = await cartItem.getByTestId('cart-product-name').innerText();
const cartItemProductVariant = await cartItem.getByTestId('cart-product-variant').innerText();
await expect(cartItemProductName).toBe(selectedProductName);
await expect(cartItemProductVariant).toBe(selectedProductVariant);
});

16
e2e/mobile-menu.spec.ts Normal file
View File

@ -0,0 +1,16 @@
import { test, expect } from '@playwright/test';
test.use({ viewport: { width: 600, height: 900 } });
test('should be able to open and close mobile menu', async ({ page }) => {
let mobileMenu;
await page.goto('/');
await page.getByTestId('open-mobile-menu').click();
mobileMenu = await page.getByTestId('mobile-menu');
await expect(mobileMenu).toBeVisible();
await page.getByTestId('close-mobile-menu').click();
mobileMenu = await page.getByTestId('mobile-menu');
await expect(mobileMenu).toBeHidden();
});

28
lib/constants.tsx Normal file
View File

@ -0,0 +1,28 @@
export type SortFilterItem = {
title: string;
slug: string | null;
sortKey: 'RELEVANCE' | 'BEST_SELLING' | 'CREATED_AT' | 'PRICE';
reverse: boolean;
};
export const defaultSort: SortFilterItem = {
title: 'Relevance',
slug: null,
sortKey: 'RELEVANCE',
reverse: false
};
export const sorting: SortFilterItem[] = [
defaultSort,
{ title: 'Trending', slug: 'trending-desc', sortKey: 'BEST_SELLING', reverse: false }, // asc
{ title: 'Latest arrivals', slug: 'latest-desc', sortKey: 'CREATED_AT', reverse: true },
{ title: 'Price: Low to high', slug: 'price-asc', sortKey: 'PRICE', reverse: false }, // asc
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
];
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
export const DEFAULT_OPTION = 'Default Title';
export const SITE_NAME = 'Vercel Store';
export const SITE_CREATOR = '@vercel';
export const SITE_CREATOR_URL = 'https://vercel.com';
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';

View File

@ -0,0 +1,49 @@
import productFragment from './product';
const cartFragment = /* GraphQL */ `
fragment cart on Cart {
id
checkoutUrl
cost {
subtotalAmount {
amount
currencyCode
}
totalAmount {
amount
currencyCode
}
totalTaxAmount {
amount
currencyCode
}
}
lines(first: 100) {
edges {
node {
id
quantity
cost {
totalAmount {
amount
currencyCode
}
}
merchandise {
... on ProductVariant {
id
title
product {
...product
}
}
}
}
}
}
totalQuantity
}
${productFragment}
`;
export default cartFragment;

View File

@ -0,0 +1,10 @@
const imageFragment = /* GraphQL */ `
fragment image on Image {
url
altText
width
height
}
`;
export default imageFragment;

View File

@ -0,0 +1,63 @@
import imageFragment from './image';
import seoFragment from './seo';
const productFragment = /* GraphQL */ `
fragment product on Product {
id
handle
availableForSale
title
description
descriptionHtml
options {
id
name
values
}
priceRange {
maxVariantPrice {
amount
currencyCode
}
minVariantPrice {
amount
currencyCode
}
}
variants(first: 250) {
edges {
node {
id
title
availableForSale
selectedOptions {
name
value
}
price {
amount
currencyCode
}
}
}
}
featuredImage {
...image
}
images(first: 20) {
edges {
node {
...image
}
}
}
seo {
...seo
}
tags
}
${imageFragment}
${seoFragment}
`;
export default productFragment;

View File

@ -0,0 +1,8 @@
const seoFragment = /* GraphQL */ `
fragment seo on SEO {
description
title
}
`;
export default seoFragment;

361
lib/shopify/index.ts Normal file
View File

@ -0,0 +1,361 @@
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT } from 'lib/constants';
import { isShopifyError } from 'lib/type-guards';
import {
addToCartMutation,
createCartMutation,
editCartItemsMutation,
removeFromCartMutation
} from './mutations/cart';
import { getCartQuery } from './queries/cart';
import {
getCollectionProductsQuery,
getCollectionQuery,
getCollectionsQuery
} from './queries/collection';
import { getMenuQuery } from './queries/menu';
import { getPageQuery } from './queries/page';
import {
getProductQuery,
getProductRecommendationsQuery,
getProductsQuery
} from './queries/product';
import {
Cart,
Collection,
Connection,
Menu,
Page,
Product,
ShopifyAddToCartOperation,
ShopifyCart,
ShopifyCartOperation,
ShopifyCollection,
ShopifyCollectionOperation,
ShopifyCollectionProductsOperation,
ShopifyCollectionsOperation,
ShopifyCreateCartOperation,
ShopifyMenuOperation,
ShopifyPageOperation,
ShopifyProduct,
ShopifyProductOperation,
ShopifyProductRecommendationsOperation,
ShopifyProductsOperation,
ShopifyRemoveFromCartOperation,
ShopifyUpdateCartOperation
} from './types';
const domain = process.env.SHOPIFY_STORE_DOMAIN!;
const endpoint = process.env.SHOPIFY_STORE_DOMAIN! + SHOPIFY_GRAPHQL_API_ENDPOINT;
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
export async function shopifyFetch<T>({
query,
variables,
headers,
cache = 'force-cache'
}: {
query: string;
variables?: ExtractVariables<T>;
headers?: HeadersInit;
cache?: RequestCache;
}): Promise<{ status: number; body: T } | never> {
try {
const result = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': key,
...headers
},
body: JSON.stringify({
...(query && { query }),
...(variables && { variables })
}),
cache,
next: { revalidate: 900 } // 15 minutes
});
const body = await result.json();
if (body.errors) {
throw body.errors[0];
}
return {
status: result.status,
body
};
} catch (e) {
if (isShopifyError(e)) {
throw {
status: e.status || 500,
message: e.message,
query
};
}
throw {
error: e,
query
};
}
}
const removeEdgesAndNodes = (array: Connection<any>) => {
return array.edges.map((edge) => edge?.node);
};
const reshapeCart = (cart: ShopifyCart): Cart => {
if (!cart.cost?.totalTaxAmount) {
cart.cost.totalTaxAmount = {
amount: '0.0',
currencyCode: 'USD'
};
}
return {
...cart,
lines: removeEdgesAndNodes(cart.lines)
};
};
const reshapeCollection = (collection: ShopifyCollection): Collection | undefined => {
if (!collection) {
return undefined;
}
return {
...collection,
path: `/search/${collection.handle}`
};
};
const reshapeCollections = (collections: ShopifyCollection[]) => {
const reshapedCollections = [];
for (const collection of collections) {
if (collection) {
const reshapedCollection = reshapeCollection(collection);
if (reshapedCollection) {
reshapedCollections.push(reshapedCollection);
}
}
}
return reshapedCollections;
};
const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean = true) => {
if (!product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))) {
return undefined;
}
const { images, variants, ...rest } = product;
return {
...rest,
images: removeEdgesAndNodes(images),
variants: removeEdgesAndNodes(variants)
};
};
const reshapeProducts = (products: ShopifyProduct[]) => {
const reshapedProducts = [];
for (const product of products) {
if (product) {
const reshapedProduct = reshapeProduct(product);
if (reshapedProduct) {
reshapedProducts.push(reshapedProduct);
}
}
}
return reshapedProducts;
};
export async function createCart(): Promise<Cart> {
const res = await shopifyFetch<ShopifyCreateCartOperation>({
query: createCartMutation,
cache: 'no-store'
});
return reshapeCart(res.body.data.cartCreate.cart);
}
export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyAddToCartOperation>({
query: addToCartMutation,
variables: {
cartId,
lines
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesAdd.cart);
}
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
query: removeFromCartMutation,
variables: {
cartId,
lineIds
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesRemove.cart);
}
export async function updateCart(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyUpdateCartOperation>({
query: editCartItemsMutation,
variables: {
cartId,
lines
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
export async function getCart(cartId: string): Promise<Cart | null> {
const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery,
variables: { cartId },
cache: 'no-store'
});
if (!res.body.data.cart) {
return null;
}
return reshapeCart(res.body.data.cart);
}
export async function getCollection(handle: string): Promise<Collection | undefined> {
const res = await shopifyFetch<ShopifyCollectionOperation>({
query: getCollectionQuery,
variables: {
handle
}
});
return reshapeCollection(res.body.data.collection);
}
export async function getCollectionProducts(handle: string, limit?: number): Promise<Product[]> {
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
query: getCollectionProductsQuery,
variables: {
handle,
first: limit
}
});
return reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products));
}
export async function getCollections(): Promise<Collection[]> {
const res = await shopifyFetch<ShopifyCollectionsOperation>({ query: getCollectionsQuery });
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
const collections = [
{
handle: '',
title: 'All',
description: 'All products',
seo: {
title: 'All',
description: 'All products'
},
path: '/search'
},
// Filter out the `hidden` collections.
// Collections that start with `hidden-*` need to be hidden on the search page.
...reshapeCollections(shopifyCollections).filter(
(collection) => !collection.handle.startsWith('hidden')
)
];
return collections;
}
export async function getMenu(handle: string): Promise<Menu[]> {
const res = await shopifyFetch<ShopifyMenuOperation>({
query: getMenuQuery,
variables: {
handle
}
});
return (
res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({
title: item.title,
path: item.url.replace(domain, '').replace('/collections', '/search').replace('/pages', '')
})) || []
);
}
export async function getPage(handle: string): Promise<Page> {
const res = await shopifyFetch<ShopifyPageOperation>({
query: getPageQuery,
variables: { handle }
});
return res.body.data.pageByHandle;
}
export async function getProduct(handle: string): Promise<Product | undefined> {
const res = await shopifyFetch<ShopifyProductOperation>({
query: getProductQuery,
variables: {
handle
}
});
return reshapeProduct(res.body.data.product, false);
}
export async function getProductRecommendations(productId: string): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
query: getProductRecommendationsQuery,
variables: {
productId
}
});
return reshapeProducts(res.body.data.productRecommendations);
}
export async function getProducts({
query,
reverse,
sortKey
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductsOperation>({
query: getProductsQuery,
variables: {
query,
reverse,
sortKey
}
});
return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
}

View File

@ -0,0 +1,45 @@
import cartFragment from '../fragments/cart';
export const addToCartMutation = /* GraphQL */ `
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const createCartMutation = /* GraphQL */ `
mutation createCart($lineItems: [CartLineInput!]) {
cartCreate(input: { lines: $lineItems }) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const editCartItemsMutation = /* GraphQL */ `
mutation editCartItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const removeFromCartMutation = /* GraphQL */ `
mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
...cart
}
}
}
${cartFragment}
`;

View File

@ -0,0 +1,10 @@
import cartFragment from '../fragments/cart';
export const getCartQuery = /* GraphQL */ `
query getCart($cartId: ID!) {
cart(id: $cartId) {
...cart
}
}
${cartFragment}
`;

View File

@ -0,0 +1,51 @@
import productFragment from '../fragments/product';
import seoFragment from '../fragments/seo';
const collectionFragment = /* GraphQL */ `
fragment collection on Collection {
handle
title
description
seo {
...seo
}
}
${seoFragment}
`;
export const getCollectionQuery = /* GraphQL */ `
query getCollection($handle: String!) {
collection(handle: $handle) {
...collection
}
}
${collectionFragment}
`;
export const getCollectionsQuery = /* GraphQL */ `
query getCollections {
collections(first: 100, sortKey: TITLE) {
edges {
node {
...collection
}
}
}
}
${collectionFragment}
`;
export const getCollectionProductsQuery = /* GraphQL */ `
query getCollectionProducts($handle: String!, $first: Int = 100) {
collection(handle: $handle) {
products(first: $first) {
edges {
node {
...product
}
}
}
}
}
${productFragment}
`;

View File

@ -0,0 +1,10 @@
export const getMenuQuery = /* GraphQL */ `
query getMenu($handle: String!) {
menu(handle: $handle) {
items {
title
url
}
}
}
`;

View File

@ -0,0 +1,21 @@
import seoFragment from '../fragments/seo';
export const getPageQuery = /* GraphQL */ `
query getPage($handle: String!) {
pageByHandle(handle: $handle) {
id
... on Page {
title
handle
body
bodySummary
seo {
...seo
}
createdAt
updatedAt
}
}
}
${seoFragment}
`;

View File

@ -0,0 +1,32 @@
import productFragment from '../fragments/product';
export const getProductQuery = /* GraphQL */ `
query getProduct($handle: String!) {
product(handle: $handle) {
...product
}
}
${productFragment}
`;
export const getProductsQuery = /* GraphQL */ `
query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) {
products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
edges {
node {
...product
}
}
}
}
${productFragment}
`;
export const getProductRecommendationsQuery = /* GraphQL */ `
query getProductRecommendations($productId: ID!) {
productRecommendations(productId: $productId) {
...product
}
}
${productFragment}
`;

258
lib/shopify/types.ts Normal file
View File

@ -0,0 +1,258 @@
export type Maybe<T> = T | null;
export type Connection<T> = {
edges: Array<Edge<T>>;
};
export type Edge<T> = {
node: T;
};
export type Cart = Omit<ShopifyCart, 'lines'> & {
lines: CartItem[];
};
export type CartItem = {
id: string;
quantity: number;
cost: {
totalAmount: Money;
};
merchandise: {
id: string;
title: string;
product: Product;
};
};
export type Collection = ShopifyCollection & {
path: string;
};
export type Image = {
url: string;
altText: string;
width: number;
height: number;
};
export type Menu = {
title: string;
path: string;
};
export type Money = {
amount: string;
currencyCode: string;
};
export type Page = {
id: string;
title: string;
handle: string;
body: string;
bodySummary: string;
seo?: SEO;
createdAt: string;
updatedAt: string;
};
export type Product = Omit<ShopifyProduct, 'variants' | 'images'> & {
variants: ProductVariant[];
images: Image[];
};
export type ProductOption = {
id: string;
name: string;
values: string[];
};
export type ProductVariant = {
id: string;
title: string;
availableForSale: boolean;
selectedOptions: {
name: string;
value: string;
}[];
price: Money;
};
export type SEO = {
title: string;
description: string;
};
export type ShopifyCart = {
id: string;
checkoutUrl: string;
cost: {
subtotalAmount: Money;
totalAmount: Money;
totalTaxAmount: Money;
};
lines: Connection<CartItem>;
totalQuantity: number;
};
export type ShopifyCollection = {
handle: string;
title: string;
description: string;
seo: SEO;
};
export type ShopifyProduct = {
id: string;
handle: string;
availableForSale: boolean;
title: string;
description: string;
descriptionHtml: string;
options: ProductOption[];
priceRange: {
maxVariantPrice: Money;
minVariantPrice: Money;
};
variants: Connection<ProductVariant>;
featuredImage: Image;
images: Connection<Image>;
seo: SEO;
tags: string[];
};
export type ShopifyCartOperation = {
data: {
cart: ShopifyCart;
};
variables: {
cartId: string;
};
};
export type ShopifyCreateCartOperation = {
data: { cartCreate: { cart: ShopifyCart } };
};
export type ShopifyAddToCartOperation = {
data: {
cartLinesAdd: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyRemoveFromCartOperation = {
data: {
cartLinesRemove: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lineIds: string[];
};
};
export type ShopifyUpdateCartOperation = {
data: {
cartLinesUpdate: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
id: string;
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyCollectionOperation = {
data: {
collection: ShopifyCollection;
};
variables: {
handle: string;
};
};
export type ShopifyCollectionProductsOperation = {
data: {
collection: {
products: Connection<ShopifyProduct>;
};
};
variables: {
handle: string;
first?: number;
};
};
export type ShopifyCollectionsOperation = {
data: {
collections: Connection<ShopifyCollection>;
};
};
export type ShopifyMenuOperation = {
data: {
menu?: {
items: {
title: string;
url: string;
}[];
};
};
variables: {
handle: string;
};
};
export type ShopifyPageOperation = {
data: { pageByHandle: Page };
variables: { handle: string };
};
export type ShopifyPagesOperation = {
data: {
pages: Connection<Page>;
};
};
export type ShopifyProductOperation = {
data: { product: ShopifyProduct };
variables: {
handle: string;
};
};
export type ShopifyProductRecommendationsOperation = {
data: {
productRecommendations: ShopifyProduct[];
};
variables: {
productId: string;
};
};
export type ShopifyProductsOperation = {
data: {
products: Connection<ShopifyProduct>;
};
variables: {
query?: string;
reverse?: boolean;
sortKey?: string;
};
};

26
lib/type-guards.ts Normal file
View File

@ -0,0 +1,26 @@
export interface ShopifyErrorLike {
status: number;
message: Error;
}
export const isObject = (object: unknown): object is Record<string, unknown> => {
return typeof object === 'object' && object !== null && !Array.isArray(object);
};
export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
if (!isObject(error)) return false;
if (error instanceof Error) return true;
return findError(error);
};
function findError<T extends object>(error: T): boolean {
if (Object.prototype.toString.call(error) === '[object Error]') {
return true;
}
const prototype = Object.getPrototypeOf(error) as T | null;
return prototype === null ? false : findError(prototype);
}

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
export const createUrl = (pathname: string, params: URLSearchParams) => {
const paramsString = params.toString();
const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;
return `${pathname}${queryString}`;
};

View File

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

20
next.config.js Normal file
View File

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

View File

@ -1,23 +1,54 @@
{
"name": "commerce",
"license": "MIT",
"private": true,
"engines": {
"node": ">=16",
"pnpm": ">=7"
},
"scripts": {
"build": "turbo run build --filter=next-commerce...",
"dev": "turbo run dev",
"start": "turbo run start",
"types": "turbo run types",
"prettier-fix": "prettier --write ."
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint-staged": "lint-staged",
"prettier": "prettier --write --ignore-unknown .",
"prettier:check": "prettier --check --ignore-unknown .",
"test": "pnpm lint && pnpm prettier:check",
"test:e2e": "playwright test"
},
"git": {
"pre-commit": "lint-staged"
},
"lint-staged": {
"*": "prettier --write --ignore-unknown"
},
"dependencies": {
"@headlessui/react": "^1.7.10",
"@vercel/og": "^0.1.0",
"clsx": "^1.2.1",
"framer-motion": "^8.4.0",
"is-empty-iterable": "^3.0.0",
"next": "13.3.1-canary.7",
"react": "18.2.0",
"react-cookie": "^4.1.1",
"react-dom": "18.2.0"
},
"devDependencies": {
"husky": "^8.0.1",
"prettier": "^2.7.1",
"turbo": "^1.4.6"
},
"husky": {
"hooks": {
"pre-commit": "turbo run lint"
}
},
"packageManager": "pnpm@7.5.0"
"@playwright/test": "^1.31.2",
"@tailwindcss/typography": "^0.5.9",
"@types/node": "18.13.0",
"@types/react": "18.0.27",
"@types/react-dom": "18.0.10",
"@vercel/git-hooks": "^1.0.0",
"autoprefixer": "^10.4.13",
"eslint": "^8.35.0",
"eslint-config-next": "^13.2.1",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-unicorn": "^45.0.2",
"lint-staged": "^13.1.1",
"postcss": "^8.4.21",
"prettier": "^2.8.4",
"prettier-plugin-tailwindcss": "^0.2.2",
"tailwindcss": "^3.2.6",
"typescript": "4.9.5"
}
}

View File

@ -1,8 +0,0 @@
COMMERCE_PROVIDER=@vercel/commerce-bigcommerce
BIGCOMMERCE_STOREFRONT_API_URL=
BIGCOMMERCE_STOREFRONT_API_TOKEN=
BIGCOMMERCE_STORE_API_URL=
BIGCOMMERCE_STORE_API_TOKEN=
BIGCOMMERCE_STORE_API_CLIENT_ID=
BIGCOMMERCE_CHANNEL_ID=

View File

@ -1,2 +0,0 @@
node_modules
dist

View File

@ -1,6 +0,0 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false
}

View File

@ -1,59 +0,0 @@
# Bigcommerce Provider
**Demo:** https://bigcommerce.demo.vercel.store/
With the deploy button below you'll be able to have a [BigCommerce](https://www.bigcommerce.com/) account and a store that works with this starter:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-description=An%20all-in-one%20starter%20kit%20for%20high-performance%20e-commerce%20sites.&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&integration-ids=oac_MuWZiE4jtmQ2ejZQaQ7ncuDT)
If you already have a BigCommerce account and want to use your current store, then copy the `.env.template` file in this directory to `.env.local` in the main directory (which will be ignored by Git):
```bash
cp packages/bigcommerce/.env.template .env.local
```
Then, set the environment variables in `.env.local` to match the ones from your store.
## Contribute
Our commitment to Open Source can be found [here](https://vercel.com/oss).
If you find an issue with the provider or want a new feature, feel free to open a PR or [create a new issue](https://github.com/vercel/commerce/issues).
## Troubleshoot
<details>
<summary>I already own a BigCommerce store. What should I do?</summary>
<br>
First thing you do is: <b>set your environment variables</b>
<br>
<br>
.env.local
```sh
BIGCOMMERCE_STOREFRONT_API_URL=<>
BIGCOMMERCE_STOREFRONT_API_TOKEN=<>
BIGCOMMERCE_STORE_API_URL=<>
BIGCOMMERCE_STORE_API_TOKEN=<>
BIGCOMMERCE_STORE_API_CLIENT_ID=<>
BIGCOMMERCE_CHANNEL_ID=<>
```
If your project was started with a "Deploy with Vercel" button, you can use Vercel's CLI to retrieve these credentials.
1. Install Vercel CLI: `npm i -g vercel`
2. Link local instance with Vercel and Github accounts (creates .vercel file): `vercel link`
3. Download your environment variables: `vercel env pull .env.local`
Next, you're free to customize the starter. More updates coming soon. Stay tuned.
</details>
<details>
<summary>BigCommerce shows a Coming Soon page and requests a Preview Code</summary>
<br>
After Email confirmation, Checkout should be manually enabled through BigCommerce platform. Look for "Review & test your store" section through BigCommerce's dashboard.
<br>
<br>
BigCommerce team has been notified and they plan to add more detailed about this subject.
</details>

View File

@ -1,27 +0,0 @@
{
"schema": {
"https://buybutton.store/graphql": {
"headers": {
"Authorization": "Bearer xzy"
}
}
},
"documents": [
{
"./src/api/**/*.ts": {
"noRequire": true
}
}
],
"generates": {
"./schema.d.ts": {
"plugins": ["typescript", "typescript-operations"]
},
"./schema.graphql": {
"plugins": ["schema-ast"]
}
},
"hooks": {
"afterAllFileWrite": ["prettier --write"]
}
}

View File

@ -1,90 +0,0 @@
{
"name": "@vercel/commerce-bigcommerce",
"version": "0.0.1",
"license": "MIT",
"scripts": {
"release": "taskr release",
"build": "taskr build",
"dev": "taskr",
"types": "tsc --emitDeclarationOnly",
"generate:definitions": "node scripts/generate-definitions.js"
},
"sideEffects": false,
"type": "module",
"exports": {
".": "./dist/index.js",
"./*": [
"./dist/*.js",
"./dist/*/index.js"
],
"./next.config": "./dist/next.config.cjs"
},
"typesVersions": {
"*": {
"*": [
"src/*",
"src/*/index"
],
"next.config": [
"dist/next.config.d.cts"
]
}
},
"files": [
"dist"
],
"publishConfig": {
"typesVersions": {
"*": {
"*": [
"dist/*.d.ts",
"dist/*/index.d.ts"
],
"config": [
"dist/next.config.d.cts"
]
}
}
},
"dependencies": {
"@cfworker/uuid": "^1.12.4",
"@tsndr/cloudflare-worker-jwt": "^2.1.0",
"@vercel/commerce": "workspace:*",
"cookie": "^0.4.1",
"immutability-helper": "^3.1.1",
"js-cookie": "^3.0.1",
"jsonwebtoken": "^8.5.1",
"lodash.debounce": "^4.0.8",
"uuidv4": "^6.2.13"
},
"peerDependencies": {
"next": "^13",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@taskr/clear": "^1.1.0",
"@taskr/esnext": "^1.1.0",
"@taskr/watch": "^1.1.0",
"@types/cookie": "^0.4.1",
"@types/jsonwebtoken": "^8.5.7",
"@types/lodash.debounce": "^4.0.6",
"@types/node": "^17.0.8",
"@types/node-fetch": "^2.6.2",
"@types/react": "^18.0.14",
"lint-staged": "^12.1.7",
"next": "^13.0.6",
"prettier": "^2.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"taskr": "^1.1.0",
"taskr-swc": "^0.0.1",
"typescript": "^4.7.4"
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json}": [
"prettier --write",
"git add"
]
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +0,0 @@
/**
* Generates definitions for REST API endpoints that are being
* used by ../api using https://github.com/drwpow/swagger-to-ts
*/
const { readFileSync, promises } = require('fs')
const path = require('path')
const fetch = require('node-fetch')
const swaggerToTS = require('@manifoldco/swagger-to-ts').default
async function getSchema(filename) {
const url = `https://next-api.stoplight.io/projects/8433/files/${filename}`
const res = await fetch(url)
if (!res.ok) {
throw new Error(`Request failed with ${res.status}: ${res.statusText}`)
}
return res.json()
}
const schemas = Object.entries({
'../api/definitions/catalog.ts':
'BigCommerce_Catalog_API.oas2.yml?ref=version%2F20.930',
'../api/definitions/store-content.ts':
'BigCommerce_Store_Content_API.oas2.yml?ref=version%2F20.930',
'../api/definitions/wishlist.ts':
'BigCommerce_Wishlist_API.oas2.yml?ref=version%2F20.930',
// swagger-to-ts is not working for the schema of the cart API
// '../api/definitions/cart.ts':
// 'BigCommerce_Server_to_Server_Cart_API.oas2.yml',
})
async function writeDefinitions() {
const ops = schemas.map(async ([dest, filename]) => {
const destination = path.join(__dirname, dest)
const schema = await getSchema(filename)
const definition = swaggerToTS(schema.content, {
prettierConfig: 'package.json',
})
await promises.writeFile(destination, definition)
console.log(`✔️ Added definitions for: ${dest}`)
})
await Promise.all(ops)
}
writeDefinitions()

Some files were not shown because too many files have changed in this diff Show More