mirror of
https://github.com/vercel/commerce.git
synced 2025-04-19 09:35:53 +00:00
Compare commits
131 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
fa1306916c | ||
|
ef2883a8d9 | ||
|
28f9a645bd | ||
|
9f4fdbb600 | ||
|
63725d82d9 | ||
|
6946bf713a | ||
|
7f8f9ff1a3 | ||
|
675942141b | ||
|
88762ba1bc | ||
|
386392be02 | ||
|
3a26bae429 | ||
|
cf413a51fc | ||
|
8d4cc9a9a7 | ||
|
ce004c05fa | ||
|
b7e9e1c7e3 | ||
|
cb99695b72 | ||
|
815bea2c1a | ||
|
64ca2ac790 | ||
|
694c5c17ba | ||
|
556aa77649 | ||
|
84224f8d7e | ||
|
94b85fca6f | ||
|
37cb5e38da | ||
|
9a4c995bb6 | ||
|
dd7449f975 | ||
|
cea56f608b | ||
|
0ebf071826 | ||
|
d7a4f3dc46 | ||
|
ec21369389 | ||
|
7c1b34abdb | ||
|
7fd9ad8a8c | ||
|
42d5d8efcf | ||
|
a5de9173e8 | ||
|
887d437795 | ||
|
610b0e8692 | ||
|
25ddc5e643 | ||
|
3a18f9a098 | ||
|
2fe1527bea | ||
|
2448f5201c | ||
|
80e48001d9 | ||
|
1f47796529 | ||
|
ece49c4265 | ||
|
2035fa0431 | ||
|
5cb1245432 | ||
|
d9f875b539 | ||
|
b0f6e94fba | ||
|
d8703e8140 | ||
|
18167d22f3 | ||
|
4993fca356 | ||
|
e9643a546e | ||
|
6a153b627c | ||
|
528ad9b8ce | ||
|
fc92f70c00 | ||
|
e8c0ee04fc | ||
|
ec838fd4e6 | ||
|
5f2348d89d | ||
|
74b5a25120 | ||
|
857a1df0f6 | ||
|
3f1a4f65ae | ||
|
c6eb7a30f9 | ||
|
faa7491a55 | ||
|
c3f3936732 | ||
|
a11b6ad83b | ||
|
36360a5fc3 | ||
|
469cd7bffd | ||
|
ef92d578cd | ||
|
0e13cfc3dd | ||
|
9044baf44e | ||
|
80bb15a7dc | ||
|
9e1388f974 | ||
|
9c813577e1 | ||
|
0f700e2d07 | ||
|
1d5242eef3 | ||
|
71c9cb96fa | ||
|
ee534492a0 | ||
|
36b28b4aab | ||
|
455a7327f3 | ||
|
10b1d4bbae | ||
|
45afbc548e | ||
|
7ae036b385 | ||
|
cd8f4c6b4c | ||
|
1449489c3c | ||
|
cccf6afdeb | ||
|
049d903a5b | ||
|
61b134a66c | ||
|
7dc7e6d6e4 | ||
|
586f9bfe56 | ||
|
6342808f94 | ||
|
69a68dd408 | ||
|
fa4c0fb8b8 | ||
|
29aaa8cac6 | ||
|
326f516138 | ||
|
51dab5aee5 | ||
|
37d7522d87 | ||
|
59fc2bc2e9 | ||
|
d918fcc895 | ||
|
1918c25f4a | ||
|
70dcfa9736 | ||
|
8c8240956a | ||
|
9678306b23 | ||
|
dd9d5497d9 | ||
|
585b3bbff8 | ||
|
7eb8816854 | ||
|
f67ab3c0b6 | ||
|
87c385fcd6 | ||
|
e4fcf19321 | ||
|
fecc60eb36 | ||
|
7de01c40b9 | ||
|
cb31b7141b | ||
|
30a080182c | ||
|
50b4e6cbc6 | ||
|
f5dade74fb | ||
|
a0c0d10fae | ||
|
a5e799b16e | ||
|
23d15496d1 | ||
|
3be4f4e6b5 | ||
|
86dca04eec | ||
|
9ea5671579 | ||
|
af21b29b73 | ||
|
c2b96d6e2f | ||
|
7cdecd322b | ||
|
a53ee3e3a0 | ||
|
ee900a48e8 | ||
|
e3785d0269 | ||
|
8ff670d7d6 | ||
|
7de3ae5583 | ||
|
acb4ff400b | ||
|
a677c17f78 | ||
|
67a192eba8 | ||
|
e9a26c2935 | ||
|
fd9450aecb |
@ -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
|
|
5
.env.example
Normal file
5
.env.example
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
COMPANY_NAME="Vercel Inc."
|
||||||
|
SITE_NAME="Next.js Commerce"
|
||||||
|
SHOPIFY_REVALIDATION_SECRET=""
|
||||||
|
SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
|
||||||
|
SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
|
55
.github/ISSUE_TEMPLATE/1.core_bug_report.yml
vendored
55
.github/ISSUE_TEMPLATE/1.core_bug_report.yml
vendored
@ -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!
|
|
59
.github/ISSUE_TEMPLATE/2.provider_bug_report.yml
vendored
59
.github/ISSUE_TEMPLATE/2.provider_bug_report.yml
vendored
@ -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!
|
|
28
.github/ISSUE_TEMPLATE/3.feature_request.yml
vendored
28
.github/ISSUE_TEMPLATE/3.feature_request.yml
vendored
@ -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
|
|
18
.github/ISSUE_TEMPLATE/4.docs_request.yml
vendored
18
.github/ISSUE_TEMPLATE/4.docs_request.yml
vendored
@ -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
|
|
5
.github/ISSUE_TEMPLATE/config.yml
vendored
5
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -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
|
|
32
.gitignore
vendored
32
.gitignore
vendored
@ -1,39 +1,39 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules
|
/node_modules
|
||||||
.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
.pnpm-debug.log
|
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
coverage
|
/coverage
|
||||||
|
.playwright
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
.next
|
/.next/
|
||||||
out
|
/out/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
build
|
/build
|
||||||
dist
|
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
.idea
|
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env
|
.env*
|
||||||
.env.local
|
!.env.example
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# Turborepo
|
# typescript
|
||||||
.turbo
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
.env*.local
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
# Every package defines its prettier config
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
.next
|
|
||||||
public
|
|
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false
|
|
||||||
}
|
|
8
.vscode/extensions.json
vendored
8
.vscode/extensions.json
vendored
@ -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
28
.vscode/launch.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@ -1,4 +1,9 @@
|
|||||||
{
|
{
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"editor.formatOnSave": true
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.organizeImports": "explicit",
|
||||||
|
"source.sortMembers": "explicit"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
216
README.md
216
README.md
@ -1,199 +1,75 @@
|
|||||||
[](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)
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=COMPANY_NAME,SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STORE_DOMAIN,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SITE_NAME)
|
||||||
|
|
||||||
# Next.js Commerce
|
# 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.
|
A high-performance, server-rendered Next.js App Router ecommerce application.
|
||||||
Start right now at [nextjs.org/commerce](https://nextjs.org/commerce)
|
|
||||||
|
|
||||||
Demo live at: [demo.vercel.store](https://demo.vercel.store/)
|
This template uses React Server Components, Server Actions, `Suspense`, `useOptimistic`, and more.
|
||||||
|
|
||||||
- Shopify Demo: https://shopify.vercel.store/
|
<h3 id="v1-note"></h3>
|
||||||
- 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
|
> Note: Looking for Next.js Commerce v1? View the [code](https://github.com/vercel/commerce/tree/v1), [demo](https://commerce-v1.vercel.store), and [release notes](https://github.com/vercel/commerce/releases/tag/v1).
|
||||||
|
|
||||||
> 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
|
## Providers
|
||||||
|
|
||||||
```bash
|
Vercel will only be actively maintaining a Shopify version [as outlined in our vision and strategy for Next.js Commerce](https://github.com/vercel/commerce/pull/966).
|
||||||
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
|
Vercel is happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged.
|
||||||
|
|
||||||
## Features
|
- Shopify (this repository)
|
||||||
|
- [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
|
||||||
|
- [Ecwid by Lightspeed](https://github.com/Ecwid/ecwid-nextjs-commerce/) ([Demo](https://ecwid-nextjs-commerce.vercel.app/))
|
||||||
|
- [Geins](https://github.com/geins-io/vercel-nextjs-commerce) ([Demo](https://geins-nextjs-commerce-starter.vercel.app/))
|
||||||
|
- [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
|
||||||
|
- [Prodigy Commerce](https://github.com/prodigycommerce/nextjs-commerce) ([Demo](https://prodigy-nextjs-commerce.vercel.app/))
|
||||||
|
- [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
|
||||||
|
- [Shopware](https://github.com/shopwareLabs/vercel-commerce) ([Demo](https://shopware-vercel-commerce-react.vercel.app/))
|
||||||
|
- [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
|
||||||
|
- [Umbraco](https://github.com/umbraco/Umbraco.VercelCommerce.Demo) ([Demo](https://vercel-commerce-demo.umbraco.com/))
|
||||||
|
- [Wix](https://github.com/wix/headless-templates/tree/main/nextjs/commerce) ([Demo](https://wix-nextjs-commerce.vercel.app/))
|
||||||
|
- [Fourthwall](https://github.com/FourthwallHQ/vercel-commerce) ([Demo](https://vercel-storefront.fourthwall.app/))
|
||||||
|
|
||||||
- Performant by default
|
> Note: Providers, if you are looking to use similar products for your demo, you can [download these assets](https://drive.google.com/file/d/1q_bKerjrwZgHwCw0ovfUMW6He9VtepO_/view?usp=sharing).
|
||||||
- SEO Ready
|
|
||||||
- Internationalization
|
|
||||||
- Responsive
|
|
||||||
- UI Components
|
|
||||||
- Theming
|
|
||||||
- Standardized Data Hooks
|
|
||||||
- Integrations - Integrate seamlessly with the most common ecommerce platforms.
|
|
||||||
- Dark Mode Support
|
|
||||||
|
|
||||||
## Integrations
|
## 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.
|
Integrations enable upgraded or additional functionality for Next.js Commerce
|
||||||
|
|
||||||
## Considerations
|
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
|
||||||
|
|
||||||
- `packages/commerce` contains all types, helpers and functions to be used as a base to build a new **provider**.
|
- Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
|
||||||
- **Providers** live under `packages`'s root folder and they will extend Next.js Commerce types and functionality (`packages/commerce`).
|
- Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
|
||||||
- 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
|
- [React Bricks](https://github.com/ReactBricks/nextjs-commerce-rb) ([Demo](https://nextjs-commerce.reactbricks.com/))
|
||||||
|
- Edit pages, product details, and footer content visually using [React Bricks](https://www.reactbricks.com) visual headless CMS.
|
||||||
|
|
||||||
### How to change providers
|
## Running locally
|
||||||
|
|
||||||
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).
|
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary.
|
||||||
|
|
||||||
The setup for Shopify would look like this for example:
|
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify store.
|
||||||
|
|
||||||
```
|
|
||||||
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.
|
|
||||||
|
|
||||||
1. Install Vercel CLI: `npm i -g vercel`
|
1. Install Vercel CLI: `npm i -g vercel`
|
||||||
2. Link local instance with Vercel and Github accounts (creates .vercel file): `vercel link`
|
2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
|
||||||
3. Download your environment variables: `vercel env pull .env.local`
|
3. Download your environment variables: `vercel env pull`
|
||||||
|
|
||||||
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
|
```bash
|
||||||
commerce/site
|
pnpm install
|
||||||
❯ yarn dev
|
pnpm 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.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The error usually occurs when running `pnpm dev` inside of the `/site/` folder after installing a fresh repository.
|
Your app should now be running on [localhost:3000](http://localhost:3000/).
|
||||||
|
|
||||||
In order to fix this, run `pnpm build` in the monorepo root folder first.
|
<details>
|
||||||
|
<summary>Expand if you work at Vercel and want to run locally and / or contribute</summary>
|
||||||
> Using `pnpm dev` from the root is recommended for developing, which will run watch mode on all packages.
|
|
||||||
|
|
||||||
|
1. Run `vc link`.
|
||||||
|
1. Select the `Vercel Solutions` scope.
|
||||||
|
1. Connect to the existing `commerce-shopify` project.
|
||||||
|
1. Run `vc env pull` to get environment variables.
|
||||||
|
1. Run `pnpm dev` to ensure everything is working correctly.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
## Vercel, Next.js Commerce, and Shopify Integration Guide
|
||||||
|
|
||||||
|
You can use this comprehensive [integration guide](https://vercel.com/docs/integrations/ecommerce/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel.
|
||||||
|
12
app/[page]/layout.tsx
Normal file
12
app/[page]/layout.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import Footer from 'components/layout/footer';
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">{children}</div>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
9
app/[page]/opengraph-image.tsx
Normal file
9
app/[page]/opengraph-image.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import OpengraphImage from 'components/opengraph-image';
|
||||||
|
import { getPage } from 'lib/shopify';
|
||||||
|
|
||||||
|
export default async function Image({ params }: { params: { page: string } }) {
|
||||||
|
const page = await getPage(params.page);
|
||||||
|
const title = page.seo?.title || page.title;
|
||||||
|
|
||||||
|
return await OpengraphImage({ title });
|
||||||
|
}
|
45
app/[page]/page.tsx
Normal file
45
app/[page]/page.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import Prose from 'components/prose';
|
||||||
|
import { getPage } from 'lib/shopify';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ page: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const page = await getPage(params.page);
|
||||||
|
|
||||||
|
if (!page) return notFound();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: page.seo?.title || page.title,
|
||||||
|
description: page.seo?.description || page.bodySummary,
|
||||||
|
openGraph: {
|
||||||
|
publishedTime: page.createdAt,
|
||||||
|
modifiedTime: page.updatedAt,
|
||||||
|
type: 'article'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Page(props: { params: Promise<{ page: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
const page = await getPage(params.page);
|
||||||
|
|
||||||
|
if (!page) return notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
|
||||||
|
<Prose className="mb-8" html={page.body} />
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
6
app/api/revalidate/route.ts
Normal file
6
app/api/revalidate/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { revalidate } from 'lib/shopify';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
|
return revalidate(req);
|
||||||
|
}
|
19
app/error.tsx
Normal file
19
app/error.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export default function Error({ reset }: { reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto my-4 flex max-w-xl flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 dark:border-neutral-800 dark:bg-black">
|
||||||
|
<h2 className="text-xl font-bold">Oh no!</h2>
|
||||||
|
<p className="my-2">
|
||||||
|
There was an issue with our storefront. This could be a temporary issue, please try your
|
||||||
|
action again.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="mx-auto mt-4 flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90"
|
||||||
|
onClick={() => reset()}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
32
app/globals.css
Normal file
32
app/globals.css
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@plugin "@tailwindcss/container-queries";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (font: -apple-system-body) and (-webkit-appearance: none) {
|
||||||
|
img[loading='lazy'] {
|
||||||
|
clip-path: inset(0.6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
input,
|
||||||
|
button {
|
||||||
|
@apply focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900;
|
||||||
|
}
|
47
app/layout.tsx
Normal file
47
app/layout.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { CartProvider } from 'components/cart/cart-context';
|
||||||
|
import { Navbar } from 'components/layout/navbar';
|
||||||
|
import { WelcomeToast } from 'components/welcome-toast';
|
||||||
|
import { GeistSans } from 'geist/font/sans';
|
||||||
|
import { getCart } from 'lib/shopify';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
|
import './globals.css';
|
||||||
|
import { baseUrl } from 'lib/utils';
|
||||||
|
|
||||||
|
const { SITE_NAME } = process.env;
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
metadataBase: new URL(baseUrl),
|
||||||
|
title: {
|
||||||
|
default: SITE_NAME!,
|
||||||
|
template: `%s | ${SITE_NAME}`
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
follow: true,
|
||||||
|
index: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
// Don't await the fetch, pass the Promise to the context provider
|
||||||
|
const cart = getCart();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="en" className={GeistSans.variable}>
|
||||||
|
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
|
||||||
|
<CartProvider cartPromise={cart}>
|
||||||
|
<Navbar />
|
||||||
|
<main>
|
||||||
|
{children}
|
||||||
|
<Toaster closeButton />
|
||||||
|
<WelcomeToast />
|
||||||
|
</main>
|
||||||
|
</CartProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
5
app/opengraph-image.tsx
Normal file
5
app/opengraph-image.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import OpengraphImage from 'components/opengraph-image';
|
||||||
|
|
||||||
|
export default async function Image() {
|
||||||
|
return await OpengraphImage();
|
||||||
|
}
|
21
app/page.tsx
Normal file
21
app/page.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Carousel } from 'components/carousel';
|
||||||
|
import { ThreeItemGrid } from 'components/grid/three-items';
|
||||||
|
import Footer from 'components/layout/footer';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
description:
|
||||||
|
'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
|
||||||
|
openGraph: {
|
||||||
|
type: 'website'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ThreeItemGrid />
|
||||||
|
<Carousel />
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
149
app/product/[handle]/page.tsx
Normal file
149
app/product/[handle]/page.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { GridTileImage } from 'components/grid/tile';
|
||||||
|
import Footer from 'components/layout/footer';
|
||||||
|
import { Gallery } from 'components/product/gallery';
|
||||||
|
import { ProductProvider } from 'components/product/product-context';
|
||||||
|
import { ProductDescription } from 'components/product/product-description';
|
||||||
|
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
|
||||||
|
import { getProduct, getProductRecommendations } from 'lib/shopify';
|
||||||
|
import { Image } from 'lib/shopify/types';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ handle: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const product = await getProduct(params.handle);
|
||||||
|
|
||||||
|
if (!product) return notFound();
|
||||||
|
|
||||||
|
const { url, width, height, altText: alt } = product.featuredImage || {};
|
||||||
|
const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: product.seo.title || product.title,
|
||||||
|
description: product.seo.description || product.description,
|
||||||
|
robots: {
|
||||||
|
index: indexable,
|
||||||
|
follow: indexable,
|
||||||
|
googleBot: {
|
||||||
|
index: indexable,
|
||||||
|
follow: indexable
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openGraph: url
|
||||||
|
? {
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
alt
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductPage(props: { params: Promise<{ handle: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
const product = await getProduct(params.handle);
|
||||||
|
|
||||||
|
if (!product) return notFound();
|
||||||
|
|
||||||
|
const productJsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Product',
|
||||||
|
name: product.title,
|
||||||
|
description: product.description,
|
||||||
|
image: product.featuredImage.url,
|
||||||
|
offers: {
|
||||||
|
'@type': 'AggregateOffer',
|
||||||
|
availability: product.availableForSale
|
||||||
|
? 'https://schema.org/InStock'
|
||||||
|
: 'https://schema.org/OutOfStock',
|
||||||
|
priceCurrency: product.priceRange.minVariantPrice.currencyCode,
|
||||||
|
highPrice: product.priceRange.maxVariantPrice.amount,
|
||||||
|
lowPrice: product.priceRange.minVariantPrice.amount
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProductProvider>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify(productJsonLd)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="mx-auto max-w-(--breakpoint-2xl) px-4">
|
||||||
|
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black">
|
||||||
|
<div className="h-full w-full basis-full lg:basis-4/6">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Gallery
|
||||||
|
images={product.images.slice(0, 5).map((image: Image) => ({
|
||||||
|
src: image.url,
|
||||||
|
altText: image.altText
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="basis-full lg:basis-2/6">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ProductDescription product={product} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RelatedProducts id={product.id} />
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</ProductProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function RelatedProducts({ id }: { id: string }) {
|
||||||
|
const relatedProducts = await getProductRecommendations(id);
|
||||||
|
|
||||||
|
if (!relatedProducts.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-8">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold">Related Products</h2>
|
||||||
|
<ul className="flex w-full gap-4 overflow-x-auto pt-1">
|
||||||
|
{relatedProducts.map((product) => (
|
||||||
|
<li
|
||||||
|
key={product.handle}
|
||||||
|
className="aspect-square w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className="relative h-full w-full"
|
||||||
|
href={`/product/${product.handle}`}
|
||||||
|
prefetch={true}
|
||||||
|
>
|
||||||
|
<GridTileImage
|
||||||
|
alt={product.title}
|
||||||
|
label={{
|
||||||
|
title: product.title,
|
||||||
|
amount: product.priceRange.maxVariantPrice.amount,
|
||||||
|
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||||
|
}}
|
||||||
|
src={product.featuredImage?.url}
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, (min-width: 475px) 50vw, 100vw"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
13
app/robots.ts
Normal file
13
app/robots.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { baseUrl } from 'lib/utils';
|
||||||
|
|
||||||
|
export default function robots() {
|
||||||
|
return {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
userAgent: '*'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sitemap: `${baseUrl}/sitemap.xml`,
|
||||||
|
host: baseUrl
|
||||||
|
};
|
||||||
|
}
|
13
app/search/[collection]/opengraph-image.tsx
Normal file
13
app/search/[collection]/opengraph-image.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import OpengraphImage from 'components/opengraph-image';
|
||||||
|
import { getCollection } from 'lib/shopify';
|
||||||
|
|
||||||
|
export default async function Image({
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
params: { collection: string };
|
||||||
|
}) {
|
||||||
|
const collection = await getCollection(params.collection);
|
||||||
|
const title = collection?.seo?.title || collection?.title;
|
||||||
|
|
||||||
|
return await OpengraphImage({ title });
|
||||||
|
}
|
45
app/search/[collection]/page.tsx
Normal file
45
app/search/[collection]/page.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { getCollection, getCollectionProducts } from 'lib/shopify';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import Grid from 'components/grid';
|
||||||
|
import ProductGridItems from 'components/layout/product-grid-items';
|
||||||
|
import { defaultSort, sorting } from 'lib/constants';
|
||||||
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ collection: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const collection = await getCollection(params.collection);
|
||||||
|
|
||||||
|
if (!collection) return notFound();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: collection.seo?.title || collection.title,
|
||||||
|
description:
|
||||||
|
collection.seo?.description || collection.description || `${collection.title} products`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CategoryPage(props: {
|
||||||
|
params: Promise<{ collection: string }>;
|
||||||
|
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const params = await props.params;
|
||||||
|
const { sort } = searchParams as { [key: string]: string };
|
||||||
|
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||||
|
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<p className="py-3 text-lg">{`No products found in this collection`}</p>
|
||||||
|
) : (
|
||||||
|
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<ProductGridItems products={products} />
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
10
app/search/children-wrapper.tsx
Normal file
10
app/search/children-wrapper.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
|
||||||
|
// Ensure children are re-rendered when the search query changes
|
||||||
|
export default function ChildrenWrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
return <Fragment key={searchParams.get('q')}>{children}</Fragment>;
|
||||||
|
}
|
31
app/search/layout.tsx
Normal file
31
app/search/layout.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import Footer from 'components/layout/footer';
|
||||||
|
import Collections from 'components/layout/search/collections';
|
||||||
|
import FilterList from 'components/layout/search/filter';
|
||||||
|
import { sorting } from 'lib/constants';
|
||||||
|
import ChildrenWrapper from './children-wrapper';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
export default function SearchLayout({
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto flex max-w-(--breakpoint-2xl) flex-col gap-8 px-4 pb-4 text-black md:flex-row dark:text-white">
|
||||||
|
<div className="order-first w-full flex-none md:max-w-[125px]">
|
||||||
|
<Collections />
|
||||||
|
</div>
|
||||||
|
<div className="order-last min-h-screen w-full md:order-none">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ChildrenWrapper>{children}</ChildrenWrapper>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<div className="order-none flex-none md:order-last md:w-[125px]">
|
||||||
|
<FilterList list={sorting} title="Sort by" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
18
app/search/loading.tsx
Normal file
18
app/search/loading.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import Grid from 'components/grid';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 h-6" />
|
||||||
|
<Grid className="grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array(12)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, index) => {
|
||||||
|
return (
|
||||||
|
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-800" />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
38
app/search/page.tsx
Normal file
38
app/search/page.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import Grid from 'components/grid';
|
||||||
|
import ProductGridItems from 'components/layout/product-grid-items';
|
||||||
|
import { defaultSort, sorting } from 'lib/constants';
|
||||||
|
import { getProducts } from 'lib/shopify';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Search',
|
||||||
|
description: 'Search for products in the store.'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function SearchPage(props: {
|
||||||
|
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const { sort, q: searchValue } = searchParams as { [key: string]: string };
|
||||||
|
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||||
|
|
||||||
|
const products = await getProducts({ sortKey, reverse, query: searchValue });
|
||||||
|
const resultsText = products.length > 1 ? 'results' : 'result';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{searchValue ? (
|
||||||
|
<p className="mb-4">
|
||||||
|
{products.length === 0
|
||||||
|
? 'There are no products that match '
|
||||||
|
: `Showing ${products.length} ${resultsText} for `}
|
||||||
|
<span className="font-bold">"{searchValue}"</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{products.length > 0 ? (
|
||||||
|
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<ProductGridItems products={products} />
|
||||||
|
</Grid>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
52
app/sitemap.ts
Normal file
52
app/sitemap.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { getCollections, getPages, getProducts } from 'lib/shopify';
|
||||||
|
import { baseUrl, validateEnvironmentVariables } from 'lib/utils';
|
||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
type Route = {
|
||||||
|
url: string;
|
||||||
|
lastModified: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
|
validateEnvironmentVariables();
|
||||||
|
|
||||||
|
const routesMap = [''].map((route) => ({
|
||||||
|
url: `${baseUrl}${route}`,
|
||||||
|
lastModified: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const collectionsPromise = getCollections().then((collections) =>
|
||||||
|
collections.map((collection) => ({
|
||||||
|
url: `${baseUrl}${collection.path}`,
|
||||||
|
lastModified: collection.updatedAt
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const productsPromise = getProducts({}).then((products) =>
|
||||||
|
products.map((product) => ({
|
||||||
|
url: `${baseUrl}/product/${product.handle}`,
|
||||||
|
lastModified: product.updatedAt
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const pagesPromise = getPages().then((pages) =>
|
||||||
|
pages.map((page) => ({
|
||||||
|
url: `${baseUrl}/${page.handle}`,
|
||||||
|
lastModified: page.updatedAt
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
let fetchedRoutes: Route[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
fetchedRoutes = (
|
||||||
|
await Promise.all([collectionsPromise, productsPromise, pagesPromise])
|
||||||
|
).flat();
|
||||||
|
} catch (error) {
|
||||||
|
throw JSON.stringify(error, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...routesMap, ...fetchedRoutes];
|
||||||
|
}
|
40
components/carousel.tsx
Normal file
40
components/carousel.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { getCollectionProducts } from 'lib/shopify';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { GridTileImage } from './grid/tile';
|
||||||
|
|
||||||
|
export async function Carousel() {
|
||||||
|
// Collections that start with `hidden-*` are hidden from the search page.
|
||||||
|
const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' });
|
||||||
|
|
||||||
|
if (!products?.length) return null;
|
||||||
|
|
||||||
|
// Purposefully duplicating products to make the carousel loop and not run out of products on wide screens.
|
||||||
|
const carouselProducts = [...products, ...products, ...products];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full overflow-x-auto pb-6 pt-1">
|
||||||
|
<ul className="flex animate-carousel gap-4">
|
||||||
|
{carouselProducts.map((product, i) => (
|
||||||
|
<li
|
||||||
|
key={`${product.handle}${i}`}
|
||||||
|
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
|
||||||
|
>
|
||||||
|
<Link href={`/product/${product.handle}`} className="relative h-full w-full">
|
||||||
|
<GridTileImage
|
||||||
|
alt={product.title}
|
||||||
|
label={{
|
||||||
|
title: product.title,
|
||||||
|
amount: product.priceRange.maxVariantPrice.amount,
|
||||||
|
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||||
|
}}
|
||||||
|
src={product.featuredImage?.url}
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
106
components/cart/actions.ts
Normal file
106
components/cart/actions.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { TAGS } from 'lib/constants';
|
||||||
|
import {
|
||||||
|
addToCart,
|
||||||
|
createCart,
|
||||||
|
getCart,
|
||||||
|
removeFromCart,
|
||||||
|
updateCart
|
||||||
|
} from 'lib/shopify';
|
||||||
|
import { revalidateTag } from 'next/cache';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export async function addItem(
|
||||||
|
prevState: any,
|
||||||
|
selectedVariantId: string | undefined
|
||||||
|
) {
|
||||||
|
if (!selectedVariantId) {
|
||||||
|
return 'Error adding item to cart';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addToCart([{ merchandiseId: selectedVariantId, quantity: 1 }]);
|
||||||
|
revalidateTag(TAGS.cart);
|
||||||
|
} catch (e) {
|
||||||
|
return 'Error adding item to cart';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeItem(prevState: any, merchandiseId: string) {
|
||||||
|
try {
|
||||||
|
const cart = await getCart();
|
||||||
|
|
||||||
|
if (!cart) {
|
||||||
|
return 'Error fetching cart';
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineItem = cart.lines.find(
|
||||||
|
(line) => line.merchandise.id === merchandiseId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lineItem && lineItem.id) {
|
||||||
|
await removeFromCart([lineItem.id]);
|
||||||
|
revalidateTag(TAGS.cart);
|
||||||
|
} else {
|
||||||
|
return 'Item not found in cart';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return 'Error removing item from cart';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateItemQuantity(
|
||||||
|
prevState: any,
|
||||||
|
payload: {
|
||||||
|
merchandiseId: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { merchandiseId, quantity } = payload;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cart = await getCart();
|
||||||
|
|
||||||
|
if (!cart) {
|
||||||
|
return 'Error fetching cart';
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineItem = cart.lines.find(
|
||||||
|
(line) => line.merchandise.id === merchandiseId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lineItem && lineItem.id) {
|
||||||
|
if (quantity === 0) {
|
||||||
|
await removeFromCart([lineItem.id]);
|
||||||
|
} else {
|
||||||
|
await updateCart([
|
||||||
|
{
|
||||||
|
id: lineItem.id,
|
||||||
|
merchandiseId,
|
||||||
|
quantity
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else if (quantity > 0) {
|
||||||
|
// If the item doesn't exist in the cart and quantity > 0, add it
|
||||||
|
await addToCart([{ merchandiseId, quantity }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateTag(TAGS.cart);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return 'Error updating item quantity';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function redirectToCheckout() {
|
||||||
|
let cart = await getCart();
|
||||||
|
redirect(cart!.checkoutUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCartAndSetCookie() {
|
||||||
|
let cart = await createCart();
|
||||||
|
(await cookies()).set('cartId', cart.id!);
|
||||||
|
}
|
94
components/cart/add-to-cart.tsx
Normal file
94
components/cart/add-to-cart.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { addItem } from 'components/cart/actions';
|
||||||
|
import { useProduct } from 'components/product/product-context';
|
||||||
|
import { Product, ProductVariant } from 'lib/shopify/types';
|
||||||
|
import { useActionState } from 'react';
|
||||||
|
import { useCart } from './cart-context';
|
||||||
|
|
||||||
|
function SubmitButton({
|
||||||
|
availableForSale,
|
||||||
|
selectedVariantId
|
||||||
|
}: {
|
||||||
|
availableForSale: boolean;
|
||||||
|
selectedVariantId: string | undefined;
|
||||||
|
}) {
|
||||||
|
const buttonClasses =
|
||||||
|
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
|
||||||
|
const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';
|
||||||
|
|
||||||
|
if (!availableForSale) {
|
||||||
|
return (
|
||||||
|
<button disabled className={clsx(buttonClasses, disabledClasses)}>
|
||||||
|
Out Of Stock
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedVariantId) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label="Please select an option"
|
||||||
|
disabled
|
||||||
|
className={clsx(buttonClasses, disabledClasses)}
|
||||||
|
>
|
||||||
|
<div className="absolute left-0 ml-4">
|
||||||
|
<PlusIcon className="h-5" />
|
||||||
|
</div>
|
||||||
|
Add To Cart
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label="Add to cart"
|
||||||
|
className={clsx(buttonClasses, {
|
||||||
|
'hover:opacity-90': true
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="absolute left-0 ml-4">
|
||||||
|
<PlusIcon className="h-5" />
|
||||||
|
</div>
|
||||||
|
Add To Cart
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddToCart({ product }: { product: Product }) {
|
||||||
|
const { variants, availableForSale } = product;
|
||||||
|
const { addCartItem } = useCart();
|
||||||
|
const { state } = useProduct();
|
||||||
|
const [message, formAction] = useActionState(addItem, null);
|
||||||
|
|
||||||
|
const variant = variants.find((variant: ProductVariant) =>
|
||||||
|
variant.selectedOptions.every(
|
||||||
|
(option) => option.value === state[option.name.toLowerCase()]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
|
||||||
|
const selectedVariantId = variant?.id || defaultVariantId;
|
||||||
|
const addItemAction = formAction.bind(null, selectedVariantId);
|
||||||
|
const finalVariant = variants.find(
|
||||||
|
(variant) => variant.id === selectedVariantId
|
||||||
|
)!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={async () => {
|
||||||
|
addCartItem(finalVariant, product);
|
||||||
|
addItemAction();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SubmitButton
|
||||||
|
availableForSale={availableForSale}
|
||||||
|
selectedVariantId={selectedVariantId}
|
||||||
|
/>
|
||||||
|
<p aria-live="polite" className="sr-only" role="status">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
238
components/cart/cart-context.tsx
Normal file
238
components/cart/cart-context.tsx
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Cart,
|
||||||
|
CartItem,
|
||||||
|
Product,
|
||||||
|
ProductVariant
|
||||||
|
} from 'lib/shopify/types';
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
use,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useOptimistic
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
type UpdateType = 'plus' | 'minus' | 'delete';
|
||||||
|
|
||||||
|
type CartAction =
|
||||||
|
| {
|
||||||
|
type: 'UPDATE_ITEM';
|
||||||
|
payload: { merchandiseId: string; updateType: UpdateType };
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'ADD_ITEM';
|
||||||
|
payload: { variant: ProductVariant; product: Product };
|
||||||
|
};
|
||||||
|
|
||||||
|
type CartContextType = {
|
||||||
|
cartPromise: Promise<Cart | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CartContext = createContext<CartContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
function calculateItemCost(quantity: number, price: string): string {
|
||||||
|
return (Number(price) * quantity).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCartItem(
|
||||||
|
item: CartItem,
|
||||||
|
updateType: UpdateType
|
||||||
|
): CartItem | null {
|
||||||
|
if (updateType === 'delete') return null;
|
||||||
|
|
||||||
|
const newQuantity =
|
||||||
|
updateType === 'plus' ? item.quantity + 1 : item.quantity - 1;
|
||||||
|
if (newQuantity === 0) return null;
|
||||||
|
|
||||||
|
const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity;
|
||||||
|
const newTotalAmount = calculateItemCost(
|
||||||
|
newQuantity,
|
||||||
|
singleItemAmount.toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
quantity: newQuantity,
|
||||||
|
cost: {
|
||||||
|
...item.cost,
|
||||||
|
totalAmount: {
|
||||||
|
...item.cost.totalAmount,
|
||||||
|
amount: newTotalAmount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOrUpdateCartItem(
|
||||||
|
existingItem: CartItem | undefined,
|
||||||
|
variant: ProductVariant,
|
||||||
|
product: Product
|
||||||
|
): CartItem {
|
||||||
|
const quantity = existingItem ? existingItem.quantity + 1 : 1;
|
||||||
|
const totalAmount = calculateItemCost(quantity, variant.price.amount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: existingItem?.id,
|
||||||
|
quantity,
|
||||||
|
cost: {
|
||||||
|
totalAmount: {
|
||||||
|
amount: totalAmount,
|
||||||
|
currencyCode: variant.price.currencyCode
|
||||||
|
}
|
||||||
|
},
|
||||||
|
merchandise: {
|
||||||
|
id: variant.id,
|
||||||
|
title: variant.title,
|
||||||
|
selectedOptions: variant.selectedOptions,
|
||||||
|
product: {
|
||||||
|
id: product.id,
|
||||||
|
handle: product.handle,
|
||||||
|
title: product.title,
|
||||||
|
featuredImage: product.featuredImage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCartTotals(
|
||||||
|
lines: CartItem[]
|
||||||
|
): Pick<Cart, 'totalQuantity' | 'cost'> {
|
||||||
|
const totalQuantity = lines.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
|
const totalAmount = lines.reduce(
|
||||||
|
(sum, item) => sum + Number(item.cost.totalAmount.amount),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? 'USD';
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalQuantity,
|
||||||
|
cost: {
|
||||||
|
subtotalAmount: { amount: totalAmount.toString(), currencyCode },
|
||||||
|
totalAmount: { amount: totalAmount.toString(), currencyCode },
|
||||||
|
totalTaxAmount: { amount: '0', currencyCode }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyCart(): Cart {
|
||||||
|
return {
|
||||||
|
id: undefined,
|
||||||
|
checkoutUrl: '',
|
||||||
|
totalQuantity: 0,
|
||||||
|
lines: [],
|
||||||
|
cost: {
|
||||||
|
subtotalAmount: { amount: '0', currencyCode: 'USD' },
|
||||||
|
totalAmount: { amount: '0', currencyCode: 'USD' },
|
||||||
|
totalTaxAmount: { amount: '0', currencyCode: 'USD' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cartReducer(state: Cart | undefined, action: CartAction): Cart {
|
||||||
|
const currentCart = state || createEmptyCart();
|
||||||
|
|
||||||
|
switch (action.type) {
|
||||||
|
case 'UPDATE_ITEM': {
|
||||||
|
const { merchandiseId, updateType } = action.payload;
|
||||||
|
const updatedLines = currentCart.lines
|
||||||
|
.map((item) =>
|
||||||
|
item.merchandise.id === merchandiseId
|
||||||
|
? updateCartItem(item, updateType)
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
.filter(Boolean) as CartItem[];
|
||||||
|
|
||||||
|
if (updatedLines.length === 0) {
|
||||||
|
return {
|
||||||
|
...currentCart,
|
||||||
|
lines: [],
|
||||||
|
totalQuantity: 0,
|
||||||
|
cost: {
|
||||||
|
...currentCart.cost,
|
||||||
|
totalAmount: { ...currentCart.cost.totalAmount, amount: '0' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentCart,
|
||||||
|
...updateCartTotals(updatedLines),
|
||||||
|
lines: updatedLines
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'ADD_ITEM': {
|
||||||
|
const { variant, product } = action.payload;
|
||||||
|
const existingItem = currentCart.lines.find(
|
||||||
|
(item) => item.merchandise.id === variant.id
|
||||||
|
);
|
||||||
|
const updatedItem = createOrUpdateCartItem(
|
||||||
|
existingItem,
|
||||||
|
variant,
|
||||||
|
product
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedLines = existingItem
|
||||||
|
? currentCart.lines.map((item) =>
|
||||||
|
item.merchandise.id === variant.id ? updatedItem : item
|
||||||
|
)
|
||||||
|
: [...currentCart.lines, updatedItem];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentCart,
|
||||||
|
...updateCartTotals(updatedLines),
|
||||||
|
lines: updatedLines
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return currentCart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CartProvider({
|
||||||
|
children,
|
||||||
|
cartPromise
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
cartPromise: Promise<Cart | undefined>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CartContext.Provider value={{ cartPromise }}>
|
||||||
|
{children}
|
||||||
|
</CartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCart() {
|
||||||
|
const context = useContext(CartContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useCart must be used within a CartProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialCart = use(context.cartPromise);
|
||||||
|
const [optimisticCart, updateOptimisticCart] = useOptimistic(
|
||||||
|
initialCart,
|
||||||
|
cartReducer
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCartItem = (merchandiseId: string, updateType: UpdateType) => {
|
||||||
|
updateOptimisticCart({
|
||||||
|
type: 'UPDATE_ITEM',
|
||||||
|
payload: { merchandiseId, updateType }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCartItem = (variant: ProductVariant, product: Product) => {
|
||||||
|
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
cart: optimisticCart,
|
||||||
|
updateCartItem,
|
||||||
|
addCartItem
|
||||||
|
}),
|
||||||
|
[optimisticCart]
|
||||||
|
);
|
||||||
|
}
|
38
components/cart/delete-item-button.tsx
Normal file
38
components/cart/delete-item-button.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { removeItem } from 'components/cart/actions';
|
||||||
|
import type { CartItem } from 'lib/shopify/types';
|
||||||
|
import { useActionState } from 'react';
|
||||||
|
|
||||||
|
export function DeleteItemButton({
|
||||||
|
item,
|
||||||
|
optimisticUpdate
|
||||||
|
}: {
|
||||||
|
item: CartItem;
|
||||||
|
optimisticUpdate: any;
|
||||||
|
}) {
|
||||||
|
const [message, formAction] = useActionState(removeItem, null);
|
||||||
|
const merchandiseId = item.merchandise.id;
|
||||||
|
const removeItemAction = formAction.bind(null, merchandiseId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={async () => {
|
||||||
|
optimisticUpdate(merchandiseId, 'delete');
|
||||||
|
removeItemAction();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label="Remove cart item"
|
||||||
|
className="flex h-[24px] w-[24px] items-center justify-center rounded-full bg-neutral-500"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="mx-[1px] h-4 w-4 text-white dark:text-black" />
|
||||||
|
</button>
|
||||||
|
<p aria-live="polite" className="sr-only" role="status">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
61
components/cart/edit-item-quantity-button.tsx
Normal file
61
components/cart/edit-item-quantity-button.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { updateItemQuantity } from 'components/cart/actions';
|
||||||
|
import type { CartItem } from 'lib/shopify/types';
|
||||||
|
import { useActionState } from 'react';
|
||||||
|
|
||||||
|
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label={
|
||||||
|
type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'
|
||||||
|
}
|
||||||
|
className={clsx(
|
||||||
|
'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full p-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80',
|
||||||
|
{
|
||||||
|
'ml-auto': type === 'minus'
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{type === 'plus' ? (
|
||||||
|
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||||
|
) : (
|
||||||
|
<MinusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditItemQuantityButton({
|
||||||
|
item,
|
||||||
|
type,
|
||||||
|
optimisticUpdate
|
||||||
|
}: {
|
||||||
|
item: CartItem;
|
||||||
|
type: 'plus' | 'minus';
|
||||||
|
optimisticUpdate: any;
|
||||||
|
}) {
|
||||||
|
const [message, formAction] = useActionState(updateItemQuantity, null);
|
||||||
|
const payload = {
|
||||||
|
merchandiseId: item.merchandise.id,
|
||||||
|
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
|
||||||
|
};
|
||||||
|
const updateItemQuantityAction = formAction.bind(null, payload);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={async () => {
|
||||||
|
optimisticUpdate(payload.merchandiseId, type);
|
||||||
|
updateItemQuantityAction();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SubmitButton type={type} />
|
||||||
|
<p aria-live="polite" className="sr-only" role="status">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
256
components/cart/modal.tsx
Normal file
256
components/cart/modal.tsx
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Dialog, Transition } from '@headlessui/react';
|
||||||
|
import { ShoppingCartIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import LoadingDots from 'components/loading-dots';
|
||||||
|
import Price from 'components/price';
|
||||||
|
import { DEFAULT_OPTION } from 'lib/constants';
|
||||||
|
import { createUrl } from 'lib/utils';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useFormStatus } from 'react-dom';
|
||||||
|
import { createCartAndSetCookie, redirectToCheckout } from './actions';
|
||||||
|
import { useCart } from './cart-context';
|
||||||
|
import { DeleteItemButton } from './delete-item-button';
|
||||||
|
import { EditItemQuantityButton } from './edit-item-quantity-button';
|
||||||
|
import OpenCart from './open-cart';
|
||||||
|
|
||||||
|
type MerchandiseSearchParams = {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CartModal() {
|
||||||
|
const { cart, updateCartItem } = useCart();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const quantityRef = useRef(cart?.totalQuantity);
|
||||||
|
const openCart = () => setIsOpen(true);
|
||||||
|
const closeCart = () => setIsOpen(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cart) {
|
||||||
|
createCartAndSetCookie();
|
||||||
|
}
|
||||||
|
}, [cart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
cart?.totalQuantity &&
|
||||||
|
cart?.totalQuantity !== quantityRef.current &&
|
||||||
|
cart?.totalQuantity > 0
|
||||||
|
) {
|
||||||
|
if (!isOpen) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
quantityRef.current = cart?.totalQuantity;
|
||||||
|
}
|
||||||
|
}, [isOpen, cart?.totalQuantity, quantityRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button aria-label="Open cart" onClick={openCart}>
|
||||||
|
<OpenCart quantity={cart?.totalQuantity} />
|
||||||
|
</button>
|
||||||
|
<Transition show={isOpen}>
|
||||||
|
<Dialog onClose={closeCart} className="relative z-50">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition-all ease-in-out duration-300"
|
||||||
|
enterFrom="opacity-0 backdrop-blur-none"
|
||||||
|
enterTo="opacity-100 backdrop-blur-[.5px]"
|
||||||
|
leave="transition-all ease-in-out duration-200"
|
||||||
|
leaveFrom="opacity-100 backdrop-blur-[.5px]"
|
||||||
|
leaveTo="opacity-0 backdrop-blur-none"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
|
||||||
|
</Transition.Child>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition-all ease-in-out duration-300"
|
||||||
|
enterFrom="translate-x-full"
|
||||||
|
enterTo="translate-x-0"
|
||||||
|
leave="transition-all ease-in-out duration-200"
|
||||||
|
leaveFrom="translate-x-0"
|
||||||
|
leaveTo="translate-x-full"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black backdrop-blur-xl md:w-[390px] dark:border-neutral-700 dark:bg-black/80 dark:text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-lg font-semibold">My Cart</p>
|
||||||
|
<button aria-label="Close cart" onClick={closeCart}>
|
||||||
|
<CloseCart />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!cart || cart.lines.length === 0 ? (
|
||||||
|
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
|
||||||
|
<ShoppingCartIcon className="h-16" />
|
||||||
|
<p className="mt-6 text-center text-2xl font-bold">
|
||||||
|
Your cart is empty.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col justify-between overflow-hidden p-1">
|
||||||
|
<ul className="grow overflow-auto py-4">
|
||||||
|
{cart.lines
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.merchandise.product.title.localeCompare(
|
||||||
|
b.merchandise.product.title
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map((item, i) => {
|
||||||
|
const merchandiseSearchParams =
|
||||||
|
{} as MerchandiseSearchParams;
|
||||||
|
|
||||||
|
item.merchandise.selectedOptions.forEach(
|
||||||
|
({ name, value }) => {
|
||||||
|
if (value !== DEFAULT_OPTION) {
|
||||||
|
merchandiseSearchParams[name.toLowerCase()] =
|
||||||
|
value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const merchandiseUrl = createUrl(
|
||||||
|
`/product/${item.merchandise.product.handle}`,
|
||||||
|
new URLSearchParams(merchandiseSearchParams)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
|
||||||
|
>
|
||||||
|
<div className="relative flex w-full flex-row justify-between px-1 py-4">
|
||||||
|
<div className="absolute z-40 -ml-1 -mt-2">
|
||||||
|
<DeleteItemButton
|
||||||
|
item={item}
|
||||||
|
optimisticUpdate={updateCartItem}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<div className="relative h-16 w-16 overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
|
||||||
|
<Image
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
alt={
|
||||||
|
item.merchandise.product.featuredImage
|
||||||
|
.altText ||
|
||||||
|
item.merchandise.product.title
|
||||||
|
}
|
||||||
|
src={
|
||||||
|
item.merchandise.product.featuredImage.url
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={merchandiseUrl}
|
||||||
|
onClick={closeCart}
|
||||||
|
className="z-30 ml-2 flex flex-row space-x-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-col text-base">
|
||||||
|
<span className="leading-tight">
|
||||||
|
{item.merchandise.product.title}
|
||||||
|
</span>
|
||||||
|
{item.merchandise.title !==
|
||||||
|
DEFAULT_OPTION ? (
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{item.merchandise.title}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-16 flex-col justify-between">
|
||||||
|
<Price
|
||||||
|
className="flex justify-end space-y-2 text-right text-sm"
|
||||||
|
amount={item.cost.totalAmount.amount}
|
||||||
|
currencyCode={
|
||||||
|
item.cost.totalAmount.currencyCode
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
|
||||||
|
<EditItemQuantityButton
|
||||||
|
item={item}
|
||||||
|
type="minus"
|
||||||
|
optimisticUpdate={updateCartItem}
|
||||||
|
/>
|
||||||
|
<p className="w-6 text-center">
|
||||||
|
<span className="w-full text-sm">
|
||||||
|
{item.quantity}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<EditItemQuantityButton
|
||||||
|
item={item}
|
||||||
|
type="plus"
|
||||||
|
optimisticUpdate={updateCartItem}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
|
||||||
|
<p>Taxes</p>
|
||||||
|
<Price
|
||||||
|
className="text-right text-base text-black dark:text-white"
|
||||||
|
amount={cart.cost.totalTaxAmount.amount}
|
||||||
|
currencyCode={cart.cost.totalTaxAmount.currencyCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
|
||||||
|
<p>Shipping</p>
|
||||||
|
<p className="text-right">Calculated at checkout</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
|
||||||
|
<p>Total</p>
|
||||||
|
<Price
|
||||||
|
className="text-right text-base text-black dark:text-white"
|
||||||
|
amount={cart.cost.totalAmount.amount}
|
||||||
|
currencyCode={cart.cost.totalAmount.currencyCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form action={redirectToCheckout}>
|
||||||
|
<CheckoutButton />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseCart({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
|
||||||
|
<XMarkIcon
|
||||||
|
className={clsx(
|
||||||
|
'h-6 transition-all ease-in-out hover:scale-110',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckoutButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
{pending ? <LoadingDots className="bg-white" /> : 'Proceed to Checkout'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
24
components/cart/open-cart.tsx
Normal file
24
components/cart/open-cart.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export default function OpenCart({
|
||||||
|
className,
|
||||||
|
quantity
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
quantity?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
|
||||||
|
<ShoppingCartIcon
|
||||||
|
className={clsx('h-4 transition-all ease-in-out hover:scale-110', className)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{quantity ? (
|
||||||
|
<div className="absolute right-0 top-0 -mr-2 -mt-2 h-4 w-4 rounded-sm bg-blue-600 text-[11px] font-medium text-white">
|
||||||
|
{quantity}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
21
components/grid/index.tsx
Normal file
21
components/grid/index.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
function Grid(props: React.ComponentProps<'ul'>) {
|
||||||
|
return (
|
||||||
|
<ul {...props} className={clsx('grid grid-flow-row gap-4', props.className)}>
|
||||||
|
{props.children}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GridItem(props: React.ComponentProps<'li'>) {
|
||||||
|
return (
|
||||||
|
<li {...props} className={clsx('aspect-square transition-opacity', props.className)}>
|
||||||
|
{props.children}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Grid.Item = GridItem;
|
||||||
|
|
||||||
|
export default Grid;
|
61
components/grid/three-items.tsx
Normal file
61
components/grid/three-items.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
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,
|
||||||
|
priority
|
||||||
|
}: {
|
||||||
|
item: Product;
|
||||||
|
size: 'full' | 'half';
|
||||||
|
priority?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className="relative block aspect-square h-full w-full"
|
||||||
|
href={`/product/${item.handle}`}
|
||||||
|
prefetch={true}
|
||||||
|
>
|
||||||
|
<GridTileImage
|
||||||
|
src={item.featuredImage.url}
|
||||||
|
fill
|
||||||
|
sizes={
|
||||||
|
size === 'full' ? '(min-width: 768px) 66vw, 100vw' : '(min-width: 768px) 33vw, 100vw'
|
||||||
|
}
|
||||||
|
priority={priority}
|
||||||
|
alt={item.title}
|
||||||
|
label={{
|
||||||
|
position: size === 'full' ? 'center' : 'bottom',
|
||||||
|
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({
|
||||||
|
collection: 'hidden-homepage-featured-items'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
|
||||||
|
|
||||||
|
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto grid max-w-(--breakpoint-2xl) gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2 lg:max-h-[calc(100vh-200px)]">
|
||||||
|
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
|
||||||
|
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
|
||||||
|
<ThreeItemGridItem size="half" item={thirdProduct} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
49
components/grid/tile.tsx
Normal file
49
components/grid/tile.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Label from '../label';
|
||||||
|
|
||||||
|
export function GridTileImage({
|
||||||
|
isInteractive = true,
|
||||||
|
active,
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
isInteractive?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
label?: {
|
||||||
|
title: string;
|
||||||
|
amount: string;
|
||||||
|
currencyCode: string;
|
||||||
|
position?: 'bottom' | 'center';
|
||||||
|
};
|
||||||
|
} & React.ComponentProps<typeof Image>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'group flex h-full w-full items-center justify-center overflow-hidden rounded-lg border bg-white hover:border-blue-600 dark:bg-black',
|
||||||
|
{
|
||||||
|
relative: label,
|
||||||
|
'border-2 border-blue-600': active,
|
||||||
|
'border-neutral-200 dark:border-neutral-800': !active
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.src ? (
|
||||||
|
<Image
|
||||||
|
className={clsx('relative h-full w-full object-contain', {
|
||||||
|
'transition duration-300 ease-in-out group-hover:scale-105': isInteractive
|
||||||
|
})}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{label ? (
|
||||||
|
<Label
|
||||||
|
title={label.title}
|
||||||
|
amount={label.amount}
|
||||||
|
currencyCode={label.currencyCode}
|
||||||
|
position={label.position}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
16
components/icons/logo.tsx
Normal file
16
components/icons/logo.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export default function LogoIcon(props: React.ComponentProps<'svg'>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-label={`${process.env.SITE_NAME} logo`}
|
||||||
|
viewBox="0 0 32 28"
|
||||||
|
{...props}
|
||||||
|
className={clsx('h-4 w-4 fill-black dark:fill-white', props.className)}
|
||||||
|
>
|
||||||
|
<path d="M21.5758 9.75769L16 0L0 28H11.6255L21.5758 9.75769Z" />
|
||||||
|
<path d="M26.2381 17.9167L20.7382 28H32L26.2381 17.9167Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
34
components/label.tsx
Normal file
34
components/label.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import Price from './price';
|
||||||
|
|
||||||
|
const Label = ({
|
||||||
|
title,
|
||||||
|
amount,
|
||||||
|
currencyCode,
|
||||||
|
position = 'bottom'
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
amount: string;
|
||||||
|
currencyCode: string;
|
||||||
|
position?: 'bottom' | 'center';
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx('absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label', {
|
||||||
|
'lg:px-20 lg:pb-[35%]': position === 'center'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="flex items-center rounded-full border bg-white/70 p-1 text-xs font-semibold text-black backdrop-blur-md dark:border-neutral-800 dark:bg-black/70 dark:text-white">
|
||||||
|
<h3 className="mr-4 line-clamp-2 grow pl-2 leading-none tracking-tight">{title}</h3>
|
||||||
|
<Price
|
||||||
|
className="flex-none rounded-full bg-blue-600 p-2 text-white"
|
||||||
|
amount={amount}
|
||||||
|
currencyCode={currencyCode}
|
||||||
|
currencyCodeClassName="hidden @[275px]/label:inline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Label;
|
46
components/layout/footer-menu.tsx
Normal file
46
components/layout/footer-menu.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Menu } from 'lib/shopify/types';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function FooterMenuItem({ item }: { item: Menu }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [active, setActive] = useState(pathname === item.path);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActive(pathname === item.path);
|
||||||
|
}, [pathname, item.path]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={item.path}
|
||||||
|
className={clsx(
|
||||||
|
'block p-2 text-lg underline-offset-4 hover:text-black hover:underline md:inline-block md:text-sm dark:hover:text-neutral-300',
|
||||||
|
{
|
||||||
|
'text-black dark:text-neutral-300': active
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FooterMenu({ menu }: { menu: Menu[] }) {
|
||||||
|
if (!menu.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
{menu.map((item: Menu) => {
|
||||||
|
return <FooterMenuItem key={item.title} item={item} />;
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
71
components/layout/footer.tsx
Normal file
71
components/layout/footer.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import FooterMenu from 'components/layout/footer-menu';
|
||||||
|
import LogoSquare from 'components/logo-square';
|
||||||
|
import { getMenu } from 'lib/shopify';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
const { COMPANY_NAME, SITE_NAME } = process.env;
|
||||||
|
|
||||||
|
export default async function Footer() {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
|
||||||
|
const skeleton = 'w-full h-6 animate-pulse rounded-sm bg-neutral-200 dark:bg-neutral-700';
|
||||||
|
const menu = await getMenu('next-js-frontend-footer-menu');
|
||||||
|
const copyrightName = COMPANY_NAME || SITE_NAME || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm md:flex-row md:gap-12 md:px-4 min-[1320px]:px-0 dark:border-neutral-700">
|
||||||
|
<div>
|
||||||
|
<Link className="flex items-center gap-2 text-black md:pt-1 dark:text-white" href="/">
|
||||||
|
<LogoSquare size="sm" />
|
||||||
|
<span className="uppercase">{SITE_NAME}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-[188px] w-[200px] flex-col gap-2">
|
||||||
|
<div className={skeleton} />
|
||||||
|
<div className={skeleton} />
|
||||||
|
<div className={skeleton} />
|
||||||
|
<div className={skeleton} />
|
||||||
|
<div className={skeleton} />
|
||||||
|
<div className={skeleton} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FooterMenu menu={menu} />
|
||||||
|
</Suspense>
|
||||||
|
<div className="md:ml-auto">
|
||||||
|
<a
|
||||||
|
className="flex h-8 w-max flex-none items-center justify-center rounded-md border border-neutral-200 bg-white text-xs text-black dark:border-neutral-700 dark:bg-black dark:text-white"
|
||||||
|
aria-label="Deploy on Vercel"
|
||||||
|
href="https://vercel.com/templates/next.js/nextjs-commerce"
|
||||||
|
>
|
||||||
|
<span className="px-3">▲</span>
|
||||||
|
<hr className="h-full border-r border-neutral-200 dark:border-neutral-700" />
|
||||||
|
<span className="px-3">Deploy</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-neutral-200 py-6 text-sm dark:border-neutral-700">
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col items-center gap-1 px-4 md:flex-row md:gap-0 md:px-4 min-[1320px]:px-0">
|
||||||
|
<p>
|
||||||
|
© {copyrightDate} {copyrightName}
|
||||||
|
{copyrightName.length && !copyrightName.endsWith('.') ? '.' : ''} All rights reserved.
|
||||||
|
</p>
|
||||||
|
<hr className="mx-4 hidden h-4 w-[1px] border-l border-neutral-400 md:inline-block" />
|
||||||
|
<p>
|
||||||
|
<a href="https://github.com/vercel/commerce">View the source</a>
|
||||||
|
</p>
|
||||||
|
<p className="md:ml-auto">
|
||||||
|
<a href="https://vercel.com" className="text-black dark:text-white">
|
||||||
|
Created by ▲ Vercel
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
61
components/layout/navbar/index.tsx
Normal file
61
components/layout/navbar/index.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import CartModal from 'components/cart/modal';
|
||||||
|
import LogoSquare from 'components/logo-square';
|
||||||
|
import { getMenu } from 'lib/shopify';
|
||||||
|
import { Menu } from 'lib/shopify/types';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import MobileMenu from './mobile-menu';
|
||||||
|
import Search, { SearchSkeleton } from './search';
|
||||||
|
|
||||||
|
const { SITE_NAME } = process.env;
|
||||||
|
|
||||||
|
export async function Navbar() {
|
||||||
|
const menu = await getMenu('next-js-frontend-header-menu');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="relative flex items-center justify-between p-4 lg:px-6">
|
||||||
|
<div className="block flex-none md:hidden">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<MobileMenu menu={menu} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-center">
|
||||||
|
<div className="flex w-full md:w-1/3">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
prefetch={true}
|
||||||
|
className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6"
|
||||||
|
>
|
||||||
|
<LogoSquare />
|
||||||
|
<div className="ml-2 flex-none text-sm font-medium uppercase md:hidden lg:block">
|
||||||
|
{SITE_NAME}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
{menu.length ? (
|
||||||
|
<ul className="hidden gap-6 text-sm md:flex md:items-center">
|
||||||
|
{menu.map((item: Menu) => (
|
||||||
|
<li key={item.title}>
|
||||||
|
<Link
|
||||||
|
href={item.path}
|
||||||
|
prefetch={true}
|
||||||
|
className="text-neutral-500 underline-offset-4 hover:text-black hover:underline dark:text-neutral-400 dark:hover:text-neutral-300"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="hidden justify-center md:flex md:w-1/3">
|
||||||
|
<Suspense fallback={<SearchSkeleton />}>
|
||||||
|
<Search />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end md:w-1/3">
|
||||||
|
<CartModal />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
100
components/layout/navbar/mobile-menu.tsx
Normal file
100
components/layout/navbar/mobile-menu.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Dialog, Transition } from '@headlessui/react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
import { Fragment, Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { Menu } from 'lib/shopify/types';
|
||||||
|
import Search, { SearchSkeleton } from './search';
|
||||||
|
|
||||||
|
export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const openMobileMenu = () => setIsOpen(true);
|
||||||
|
const closeMobileMenu = () => setIsOpen(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (window.innerWidth > 768) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}, [pathname, searchParams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={openMobileMenu}
|
||||||
|
aria-label="Open mobile menu"
|
||||||
|
className="flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors md:hidden dark:border-neutral-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<Bars3Icon className="h-4" />
|
||||||
|
</button>
|
||||||
|
<Transition show={isOpen}>
|
||||||
|
<Dialog onClose={closeMobileMenu} className="relative z-50">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition-all ease-in-out duration-300"
|
||||||
|
enterFrom="opacity-0 backdrop-blur-none"
|
||||||
|
enterTo="opacity-100 backdrop-blur-[.5px]"
|
||||||
|
leave="transition-all ease-in-out duration-200"
|
||||||
|
leaveFrom="opacity-100 backdrop-blur-[.5px]"
|
||||||
|
leaveTo="opacity-0 backdrop-blur-none"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
|
||||||
|
</Transition.Child>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition-all ease-in-out duration-300"
|
||||||
|
enterFrom="translate-x-[-100%]"
|
||||||
|
enterTo="translate-x-0"
|
||||||
|
leave="transition-all ease-in-out duration-200"
|
||||||
|
leaveFrom="translate-x-0"
|
||||||
|
leaveTo="translate-x-[-100%]"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="fixed bottom-0 left-0 right-0 top-0 flex h-full w-full flex-col bg-white pb-6 dark:bg-black">
|
||||||
|
<div className="p-4">
|
||||||
|
<button
|
||||||
|
className="mb-4 flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white"
|
||||||
|
onClick={closeMobileMenu}
|
||||||
|
aria-label="Close mobile menu"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mb-4 w-full">
|
||||||
|
<Suspense fallback={<SearchSkeleton />}>
|
||||||
|
<Search />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
{menu.length ? (
|
||||||
|
<ul className="flex w-full flex-col">
|
||||||
|
{menu.map((item: Menu) => (
|
||||||
|
<li
|
||||||
|
className="py-2 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white"
|
||||||
|
key={item.title}
|
||||||
|
>
|
||||||
|
<Link href={item.path} prefetch={true} onClick={closeMobileMenu}>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
40
components/layout/navbar/search.tsx
Normal file
40
components/layout/navbar/search.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||||
|
import Form from 'next/form';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Search() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form action="/search" className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
|
||||||
|
<input
|
||||||
|
key={searchParams?.get('q')}
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
placeholder="Search for products..."
|
||||||
|
autoComplete="off"
|
||||||
|
defaultValue={searchParams?.get('q') || ''}
|
||||||
|
className="text-md w-full rounded-lg border bg-white px-4 py-2 text-black placeholder:text-neutral-500 md:text-sm dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
|
||||||
|
<MagnifyingGlassIcon className="h-4" />
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchSkeleton() {
|
||||||
|
return (
|
||||||
|
<form className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
|
||||||
|
<input
|
||||||
|
placeholder="Search for products..."
|
||||||
|
className="w-full rounded-lg border bg-white px-4 py-2 text-sm text-black placeholder:text-neutral-500 dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
|
||||||
|
<MagnifyingGlassIcon className="h-4" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
32
components/layout/product-grid-items.tsx
Normal file
32
components/layout/product-grid-items.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import Grid from 'components/grid';
|
||||||
|
import { GridTileImage } from 'components/grid/tile';
|
||||||
|
import { Product } from 'lib/shopify/types';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function ProductGridItems({ products }: { products: Product[] }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{products.map((product) => (
|
||||||
|
<Grid.Item key={product.handle} className="animate-fadeIn">
|
||||||
|
<Link
|
||||||
|
className="relative inline-block h-full w-full"
|
||||||
|
href={`/product/${product.handle}`}
|
||||||
|
prefetch={true}
|
||||||
|
>
|
||||||
|
<GridTileImage
|
||||||
|
alt={product.title}
|
||||||
|
label={{
|
||||||
|
title: product.title,
|
||||||
|
amount: product.priceRange.maxVariantPrice.amount,
|
||||||
|
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||||
|
}}
|
||||||
|
src={product.featuredImage?.url}
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</Grid.Item>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
37
components/layout/search/collections.tsx
Normal file
37
components/layout/search/collections.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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-sm';
|
||||||
|
const activeAndTitles = 'bg-neutral-800 dark:bg-neutral-300';
|
||||||
|
const items = 'bg-neutral-400 dark:bg-neutral-700';
|
||||||
|
|
||||||
|
export default function Collections() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="col-span-2 hidden h-[400px] w-full flex-none py-4 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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CollectionList />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
64
components/layout/search/filter/dropdown.tsx
Normal file
64
components/layout/search/filter/dropdown.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
|
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-sm border border-black/30 px-4 py-2 text-sm dark:border-white/30"
|
||||||
|
>
|
||||||
|
<div>{active}</div>
|
||||||
|
<ChevronDownIcon className="h-4" />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
41
components/layout/search/filter/index.tsx
Normal file
41
components/layout/search/filter/index.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { SortFilterItem } from 'lib/constants';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
{list.map((item: ListItem, i) => (
|
||||||
|
<FilterItem key={i} item={item} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilterList({ list, title }: { list: ListItem[]; title?: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<nav>
|
||||||
|
{title ? (
|
||||||
|
<h3 className="hidden text-xs text-neutral-500 md:block dark:text-neutral-400">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
) : null}
|
||||||
|
<ul className="hidden md:block">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<FilterItemList list={list} />
|
||||||
|
</Suspense>
|
||||||
|
</ul>
|
||||||
|
<ul className="md:hidden">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<FilterItemDropdown list={list} />
|
||||||
|
</Suspense>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
67
components/layout/search/filter/item.tsx
Normal file
67
components/layout/search/filter/item.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import type { SortFilterItem } from 'lib/constants';
|
||||||
|
import { createUrl } from 'lib/utils';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
import type { ListItem, PathFilterItem } from '.';
|
||||||
|
|
||||||
|
function PathFilterItem({ item }: { item: PathFilterItem }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const active = pathname === item.path;
|
||||||
|
const newParams = new URLSearchParams(searchParams.toString());
|
||||||
|
const DynamicTag = active ? 'p' : Link;
|
||||||
|
|
||||||
|
newParams.delete('q');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="mt-2 flex text-black dark:text-white" key={item.title}>
|
||||||
|
<DynamicTag
|
||||||
|
href={createUrl(item.path, newParams)}
|
||||||
|
className={clsx(
|
||||||
|
'w-full text-sm underline-offset-4 hover:underline dark:hover:text-neutral-100',
|
||||||
|
{
|
||||||
|
'underline underline-offset-4': active
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</DynamicTag>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortFilterItem({ item }: { item: SortFilterItem }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const active = searchParams.get('sort') === item.slug;
|
||||||
|
const q = searchParams.get('q');
|
||||||
|
const href = createUrl(
|
||||||
|
pathname,
|
||||||
|
new URLSearchParams({
|
||||||
|
...(q && { q }),
|
||||||
|
...(item.slug && item.slug.length && { sort: item.slug })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const DynamicTag = active ? 'p' : Link;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="mt-2 flex text-sm text-black dark:text-white" key={item.title}>
|
||||||
|
<DynamicTag
|
||||||
|
prefetch={!active ? false : undefined}
|
||||||
|
href={href}
|
||||||
|
className={clsx('w-full hover:underline hover:underline-offset-4', {
|
||||||
|
'underline underline-offset-4': active
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</DynamicTag>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterItem({ item }: { item: ListItem }) {
|
||||||
|
return 'path' in item ? <PathFilterItem item={item} /> : <SortFilterItem item={item} />;
|
||||||
|
}
|
15
components/loading-dots.tsx
Normal file
15
components/loading-dots.tsx
Normal 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;
|
23
components/logo-square.tsx
Normal file
23
components/logo-square.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import LogoIcon from './icons/logo';
|
||||||
|
|
||||||
|
export default function LogoSquare({ size }: { size?: 'sm' | undefined }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-none items-center justify-center border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-black',
|
||||||
|
{
|
||||||
|
'h-[40px] w-[40px] rounded-xl': !size,
|
||||||
|
'h-[30px] w-[30px] rounded-lg': size === 'sm'
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LogoIcon
|
||||||
|
className={clsx({
|
||||||
|
'h-[16px] w-[16px]': !size,
|
||||||
|
'h-[10px] w-[10px]': size === 'sm'
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
45
components/opengraph-image.tsx
Normal file
45
components/opengraph-image.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { ImageResponse } from 'next/og';
|
||||||
|
import LogoIcon from './icons/logo';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function OpengraphImage(
|
||||||
|
props?: Props
|
||||||
|
): Promise<ImageResponse> {
|
||||||
|
const { title } = {
|
||||||
|
...{
|
||||||
|
title: process.env.SITE_NAME
|
||||||
|
},
|
||||||
|
...props
|
||||||
|
};
|
||||||
|
|
||||||
|
const file = await readFile(join(process.cwd(), './fonts/Inter-Bold.ttf'));
|
||||||
|
const font = Uint8Array.from(file).buffer;
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div tw="flex h-full w-full flex-col items-center justify-center bg-black">
|
||||||
|
<div tw="flex flex-none items-center justify-center border border-neutral-700 h-[160px] w-[160px] rounded-3xl">
|
||||||
|
<LogoIcon width="64" height="58" fill="white" />
|
||||||
|
</div>
|
||||||
|
<p tw="mt-12 text-6xl font-bold text-white">{title}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
fonts: [
|
||||||
|
{
|
||||||
|
name: 'Inter',
|
||||||
|
data: font,
|
||||||
|
style: 'normal',
|
||||||
|
weight: 700
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
24
components/price.tsx
Normal file
24
components/price.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
const Price = ({
|
||||||
|
amount,
|
||||||
|
className,
|
||||||
|
currencyCode = 'USD',
|
||||||
|
currencyCodeClassName
|
||||||
|
}: {
|
||||||
|
amount: string;
|
||||||
|
className?: string;
|
||||||
|
currencyCode: string;
|
||||||
|
currencyCodeClassName?: string;
|
||||||
|
} & React.ComponentProps<'p'>) => (
|
||||||
|
<p suppressHydrationWarning={true} className={className}>
|
||||||
|
{`${new Intl.NumberFormat(undefined, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currencyCode,
|
||||||
|
currencyDisplay: 'narrowSymbol'
|
||||||
|
}).format(parseFloat(amount))}`}
|
||||||
|
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{`${currencyCode}`}</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Price;
|
92
components/product/gallery.tsx
Normal file
92
components/product/gallery.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { GridTileImage } from 'components/grid/tile';
|
||||||
|
import { useProduct, useUpdateURL } from 'components/product/product-context';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
|
||||||
|
const { state, updateImage } = useProduct();
|
||||||
|
const updateURL = useUpdateURL();
|
||||||
|
const imageIndex = state.image ? parseInt(state.image) : 0;
|
||||||
|
|
||||||
|
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
|
||||||
|
const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
|
||||||
|
|
||||||
|
const buttonClassName =
|
||||||
|
'h-full px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white flex items-center justify-center';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden">
|
||||||
|
{images[imageIndex] && (
|
||||||
|
<Image
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 1024px) 66vw, 100vw"
|
||||||
|
alt={images[imageIndex]?.altText as string}
|
||||||
|
src={images[imageIndex]?.src as string}
|
||||||
|
priority={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{images.length > 1 ? (
|
||||||
|
<div className="absolute bottom-[15%] flex w-full justify-center">
|
||||||
|
<div className="mx-auto flex h-11 items-center rounded-full border border-white bg-neutral-50/80 text-neutral-500 backdrop-blur-sm dark:border-black dark:bg-neutral-900/80">
|
||||||
|
<button
|
||||||
|
formAction={() => {
|
||||||
|
const newState = updateImage(previousImageIndex.toString());
|
||||||
|
updateURL(newState);
|
||||||
|
}}
|
||||||
|
aria-label="Previous product image"
|
||||||
|
className={buttonClassName}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="h-5" />
|
||||||
|
</button>
|
||||||
|
<div className="mx-1 h-6 w-px bg-neutral-500"></div>
|
||||||
|
<button
|
||||||
|
formAction={() => {
|
||||||
|
const newState = updateImage(nextImageIndex.toString());
|
||||||
|
updateURL(newState);
|
||||||
|
}}
|
||||||
|
aria-label="Next product image"
|
||||||
|
className={buttonClassName}
|
||||||
|
>
|
||||||
|
<ArrowRightIcon className="h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{images.length > 1 ? (
|
||||||
|
<ul className="my-12 flex items-center flex-wrap justify-center gap-2 overflow-auto py-1 lg:mb-0">
|
||||||
|
{images.map((image, index) => {
|
||||||
|
const isActive = index === imageIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={image.src} className="h-20 w-20">
|
||||||
|
<button
|
||||||
|
formAction={() => {
|
||||||
|
const newState = updateImage(index.toString());
|
||||||
|
updateURL(newState);
|
||||||
|
}}
|
||||||
|
aria-label="Select product image"
|
||||||
|
className="h-full w-full"
|
||||||
|
>
|
||||||
|
<GridTileImage
|
||||||
|
alt={image.altText}
|
||||||
|
src={image.src}
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
active={isActive}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
81
components/product/product-context.tsx
Normal file
81
components/product/product-context.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import React, { createContext, useContext, useMemo, useOptimistic } from 'react';
|
||||||
|
|
||||||
|
type ProductState = {
|
||||||
|
[key: string]: string;
|
||||||
|
} & {
|
||||||
|
image?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProductContextType = {
|
||||||
|
state: ProductState;
|
||||||
|
updateOption: (name: string, value: string) => ProductState;
|
||||||
|
updateImage: (index: string) => ProductState;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductContext = createContext<ProductContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function ProductProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const getInitialState = () => {
|
||||||
|
const params: ProductState = {};
|
||||||
|
for (const [key, value] of searchParams.entries()) {
|
||||||
|
params[key] = value;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [state, setOptimisticState] = useOptimistic(
|
||||||
|
getInitialState(),
|
||||||
|
(prevState: ProductState, update: ProductState) => ({
|
||||||
|
...prevState,
|
||||||
|
...update
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateOption = (name: string, value: string) => {
|
||||||
|
const newState = { [name]: value };
|
||||||
|
setOptimisticState(newState);
|
||||||
|
return { ...state, ...newState };
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateImage = (index: string) => {
|
||||||
|
const newState = { image: index };
|
||||||
|
setOptimisticState(newState);
|
||||||
|
return { ...state, ...newState };
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
updateOption,
|
||||||
|
updateImage
|
||||||
|
}),
|
||||||
|
[state]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ProductContext.Provider value={value}>{children}</ProductContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProduct() {
|
||||||
|
const context = useContext(ProductContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useProduct must be used within a ProductProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateURL() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (state: ProductState) => {
|
||||||
|
const newParams = new URLSearchParams(window.location.search);
|
||||||
|
Object.entries(state).forEach(([key, value]) => {
|
||||||
|
newParams.set(key, value);
|
||||||
|
});
|
||||||
|
router.push(`?${newParams.toString()}`, { scroll: false });
|
||||||
|
};
|
||||||
|
}
|
29
components/product/product-description.tsx
Normal file
29
components/product/product-description.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { AddToCart } from 'components/cart/add-to-cart';
|
||||||
|
import Price from 'components/price';
|
||||||
|
import Prose from 'components/prose';
|
||||||
|
import { Product } from 'lib/shopify/types';
|
||||||
|
import { VariantSelector } from './variant-selector';
|
||||||
|
|
||||||
|
export function ProductDescription({ product }: { product: Product }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700">
|
||||||
|
<h1 className="mb-2 text-5xl font-medium">{product.title}</h1>
|
||||||
|
<div className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white">
|
||||||
|
<Price
|
||||||
|
amount={product.priceRange.maxVariantPrice.amount}
|
||||||
|
currencyCode={product.priceRange.maxVariantPrice.currencyCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VariantSelector options={product.options} variants={product.variants} />
|
||||||
|
{product.descriptionHtml ? (
|
||||||
|
<Prose
|
||||||
|
className="mb-6 text-sm leading-tight dark:text-white/[60%]"
|
||||||
|
html={product.descriptionHtml}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AddToCart product={product} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
93
components/product/variant-selector.tsx
Normal file
93
components/product/variant-selector.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useProduct, useUpdateURL } from 'components/product/product-context';
|
||||||
|
import { ProductOption, ProductVariant } from 'lib/shopify/types';
|
||||||
|
|
||||||
|
type Combination = {
|
||||||
|
id: string;
|
||||||
|
availableForSale: boolean;
|
||||||
|
[key: string]: string | boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VariantSelector({
|
||||||
|
options,
|
||||||
|
variants
|
||||||
|
}: {
|
||||||
|
options: ProductOption[];
|
||||||
|
variants: ProductVariant[];
|
||||||
|
}) {
|
||||||
|
const { state, updateOption } = useProduct();
|
||||||
|
const updateURL = useUpdateURL();
|
||||||
|
const hasNoOptionsOrJustOneOption =
|
||||||
|
!options.length || (options.length === 1 && options[0]?.values.length === 1);
|
||||||
|
|
||||||
|
if (hasNoOptionsOrJustOneOption) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinations: Combination[] = variants.map((variant) => ({
|
||||||
|
id: variant.id,
|
||||||
|
availableForSale: variant.availableForSale,
|
||||||
|
...variant.selectedOptions.reduce(
|
||||||
|
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return options.map((option) => (
|
||||||
|
<form key={option.id}>
|
||||||
|
<dl className="mb-8">
|
||||||
|
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
|
||||||
|
<dd className="flex flex-wrap gap-3">
|
||||||
|
{option.values.map((value) => {
|
||||||
|
const optionNameLowerCase = option.name.toLowerCase();
|
||||||
|
|
||||||
|
// Base option params on current selectedOptions so we can preserve any other param state.
|
||||||
|
const optionParams = { ...state, [optionNameLowerCase]: value };
|
||||||
|
|
||||||
|
// Filter out invalid options and check if the option combination is available for sale.
|
||||||
|
const filtered = Object.entries(optionParams).filter(([key, value]) =>
|
||||||
|
options.find(
|
||||||
|
(option) => option.name.toLowerCase() === key && option.values.includes(value)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const isAvailableForSale = combinations.find((combination) =>
|
||||||
|
filtered.every(
|
||||||
|
([key, value]) => combination[key] === value && combination.availableForSale
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// The option is active if it's in the selected options.
|
||||||
|
const isActive = state[optionNameLowerCase] === value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
formAction={() => {
|
||||||
|
const newState = updateOption(optionNameLowerCase, value);
|
||||||
|
updateURL(newState);
|
||||||
|
}}
|
||||||
|
key={value}
|
||||||
|
aria-disabled={!isAvailableForSale}
|
||||||
|
disabled={!isAvailableForSale}
|
||||||
|
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
|
||||||
|
className={clsx(
|
||||||
|
'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900',
|
||||||
|
{
|
||||||
|
'cursor-default ring-2 ring-blue-600': isActive,
|
||||||
|
'ring-1 ring-transparent transition duration-300 ease-in-out hover:ring-blue-600':
|
||||||
|
!isActive && isAvailableForSale,
|
||||||
|
'relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 dark:before:bg-neutral-700':
|
||||||
|
!isAvailableForSale
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</form>
|
||||||
|
));
|
||||||
|
}
|
15
components/prose.tsx
Normal file
15
components/prose.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
const Prose = ({ html, className }: { html: string; className?: string }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline prose-a:hover:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Prose;
|
35
components/welcome-toast.tsx
Normal file
35
components/welcome-toast.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export function WelcomeToast() {
|
||||||
|
useEffect(() => {
|
||||||
|
// ignore if screen height is too small
|
||||||
|
if (window.innerHeight < 650) return;
|
||||||
|
if (!document.cookie.includes('welcome-toast=2')) {
|
||||||
|
toast('🛍️ Welcome to Next.js Commerce!', {
|
||||||
|
id: 'welcome-toast',
|
||||||
|
duration: Infinity,
|
||||||
|
onDismiss: () => {
|
||||||
|
document.cookie = 'welcome-toast=2; max-age=31536000; path=/';
|
||||||
|
},
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
This is a high-performance, SSR storefront powered by Shopify, Next.js, and Vercel.{' '}
|
||||||
|
<a
|
||||||
|
href="https://vercel.com/templates/next.js/nextjs-commerce"
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Deploy your own
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
BIN
fonts/Inter-Bold.ttf
Normal file
BIN
fonts/Inter-Bold.ttf
Normal file
Binary file not shown.
31
lib/constants.ts
Normal file
31
lib/constants.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
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 TAGS = {
|
||||||
|
collections: 'collections',
|
||||||
|
products: 'products',
|
||||||
|
cart: 'cart'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
||||||
|
export const DEFAULT_OPTION = 'Default Title';
|
||||||
|
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';
|
53
lib/shopify/fragments/cart.ts
Normal file
53
lib/shopify/fragments/cart.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
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
|
||||||
|
selectedOptions {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
product {
|
||||||
|
...product
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalQuantity
|
||||||
|
}
|
||||||
|
${productFragment}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default cartFragment;
|
10
lib/shopify/fragments/image.ts
Normal file
10
lib/shopify/fragments/image.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
const imageFragment = /* GraphQL */ `
|
||||||
|
fragment image on Image {
|
||||||
|
url
|
||||||
|
altText
|
||||||
|
width
|
||||||
|
height
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default imageFragment;
|
64
lib/shopify/fragments/product.ts
Normal file
64
lib/shopify/fragments/product.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
${imageFragment}
|
||||||
|
${seoFragment}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default productFragment;
|
8
lib/shopify/fragments/seo.ts
Normal file
8
lib/shopify/fragments/seo.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
const seoFragment = /* GraphQL */ `
|
||||||
|
fragment seo on SEO {
|
||||||
|
description
|
||||||
|
title
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default seoFragment;
|
501
lib/shopify/index.ts
Normal file
501
lib/shopify/index.ts
Normal file
@ -0,0 +1,501 @@
|
|||||||
|
import {
|
||||||
|
HIDDEN_PRODUCT_TAG,
|
||||||
|
SHOPIFY_GRAPHQL_API_ENDPOINT,
|
||||||
|
TAGS
|
||||||
|
} from 'lib/constants';
|
||||||
|
import { isShopifyError } from 'lib/type-guards';
|
||||||
|
import { ensureStartsWith } from 'lib/utils';
|
||||||
|
import {
|
||||||
|
revalidateTag,
|
||||||
|
unstable_cacheTag as cacheTag,
|
||||||
|
unstable_cacheLife as cacheLife
|
||||||
|
} from 'next/cache';
|
||||||
|
import { cookies, headers } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import {
|
||||||
|
addToCartMutation,
|
||||||
|
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, getPagesQuery } from './queries/page';
|
||||||
|
import {
|
||||||
|
getProductQuery,
|
||||||
|
getProductRecommendationsQuery,
|
||||||
|
getProductsQuery
|
||||||
|
} from './queries/product';
|
||||||
|
import {
|
||||||
|
Cart,
|
||||||
|
Collection,
|
||||||
|
Connection,
|
||||||
|
Image,
|
||||||
|
Menu,
|
||||||
|
Page,
|
||||||
|
Product,
|
||||||
|
ShopifyAddToCartOperation,
|
||||||
|
ShopifyCart,
|
||||||
|
ShopifyCartOperation,
|
||||||
|
ShopifyCollection,
|
||||||
|
ShopifyCollectionOperation,
|
||||||
|
ShopifyCollectionProductsOperation,
|
||||||
|
ShopifyCollectionsOperation,
|
||||||
|
ShopifyCreateCartOperation,
|
||||||
|
ShopifyMenuOperation,
|
||||||
|
ShopifyPageOperation,
|
||||||
|
ShopifyPagesOperation,
|
||||||
|
ShopifyProduct,
|
||||||
|
ShopifyProductOperation,
|
||||||
|
ShopifyProductRecommendationsOperation,
|
||||||
|
ShopifyProductsOperation,
|
||||||
|
ShopifyRemoveFromCartOperation,
|
||||||
|
ShopifyUpdateCartOperation
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
|
||||||
|
: '';
|
||||||
|
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
|
||||||
|
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
|
||||||
|
|
||||||
|
type ExtractVariables<T> = T extends { variables: object }
|
||||||
|
? T['variables']
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export async function shopifyFetch<T>({
|
||||||
|
headers,
|
||||||
|
query,
|
||||||
|
variables
|
||||||
|
}: {
|
||||||
|
headers?: HeadersInit;
|
||||||
|
query: string;
|
||||||
|
variables?: ExtractVariables<T>;
|
||||||
|
}): 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 })
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await result.json();
|
||||||
|
|
||||||
|
if (body.errors) {
|
||||||
|
throw body.errors[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
body
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (isShopifyError(e)) {
|
||||||
|
throw {
|
||||||
|
cause: e.cause?.toString() || 'unknown',
|
||||||
|
status: e.status || 500,
|
||||||
|
message: e.message,
|
||||||
|
query
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw {
|
||||||
|
error: e,
|
||||||
|
query
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeEdgesAndNodes = <T>(array: Connection<T>): T[] => {
|
||||||
|
return array.edges.map((edge) => edge?.node);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reshapeCart = (cart: ShopifyCart): Cart => {
|
||||||
|
if (!cart.cost?.totalTaxAmount) {
|
||||||
|
cart.cost.totalTaxAmount = {
|
||||||
|
amount: '0.0',
|
||||||
|
currencyCode: cart.cost.totalAmount.currencyCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 reshapeImages = (images: Connection<Image>, productTitle: string) => {
|
||||||
|
const flattened = removeEdgesAndNodes(images);
|
||||||
|
|
||||||
|
return flattened.map((image) => {
|
||||||
|
const filename = image.url.match(/.*\/(.*)\..*/)?.[1];
|
||||||
|
return {
|
||||||
|
...image,
|
||||||
|
altText: image.altText || `${productTitle} - ${filename}`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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: reshapeImages(images, product.title),
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
return reshapeCart(res.body.data.cartCreate.cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addToCart(
|
||||||
|
lines: { merchandiseId: string; quantity: number }[]
|
||||||
|
): Promise<Cart> {
|
||||||
|
const cartId = (await cookies()).get('cartId')?.value!;
|
||||||
|
const res = await shopifyFetch<ShopifyAddToCartOperation>({
|
||||||
|
query: addToCartMutation,
|
||||||
|
variables: {
|
||||||
|
cartId,
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return reshapeCart(res.body.data.cartLinesAdd.cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFromCart(lineIds: string[]): Promise<Cart> {
|
||||||
|
const cartId = (await cookies()).get('cartId')?.value!;
|
||||||
|
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
|
||||||
|
query: removeFromCartMutation,
|
||||||
|
variables: {
|
||||||
|
cartId,
|
||||||
|
lineIds
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return reshapeCart(res.body.data.cartLinesRemove.cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCart(
|
||||||
|
lines: { id: string; merchandiseId: string; quantity: number }[]
|
||||||
|
): Promise<Cart> {
|
||||||
|
const cartId = (await cookies()).get('cartId')?.value!;
|
||||||
|
const res = await shopifyFetch<ShopifyUpdateCartOperation>({
|
||||||
|
query: editCartItemsMutation,
|
||||||
|
variables: {
|
||||||
|
cartId,
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return reshapeCart(res.body.data.cartLinesUpdate.cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCart(): Promise<Cart | undefined> {
|
||||||
|
const cartId = (await cookies()).get('cartId')?.value;
|
||||||
|
|
||||||
|
if (!cartId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await shopifyFetch<ShopifyCartOperation>({
|
||||||
|
query: getCartQuery,
|
||||||
|
variables: { cartId }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Old carts becomes `null` when you checkout.
|
||||||
|
if (!res.body.data.cart) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reshapeCart(res.body.data.cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCollection(
|
||||||
|
handle: string
|
||||||
|
): Promise<Collection | undefined> {
|
||||||
|
'use cache';
|
||||||
|
cacheTag(TAGS.collections);
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
|
const res = await shopifyFetch<ShopifyCollectionOperation>({
|
||||||
|
query: getCollectionQuery,
|
||||||
|
variables: {
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return reshapeCollection(res.body.data.collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCollectionProducts({
|
||||||
|
collection,
|
||||||
|
reverse,
|
||||||
|
sortKey
|
||||||
|
}: {
|
||||||
|
collection: string;
|
||||||
|
reverse?: boolean;
|
||||||
|
sortKey?: string;
|
||||||
|
}): Promise<Product[]> {
|
||||||
|
'use cache';
|
||||||
|
cacheTag(TAGS.collections, TAGS.products);
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
|
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
|
||||||
|
query: getCollectionProductsQuery,
|
||||||
|
variables: {
|
||||||
|
handle: collection,
|
||||||
|
reverse,
|
||||||
|
sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.body.data.collection) {
|
||||||
|
console.log(`No collection found for \`${collection}\``);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return reshapeProducts(
|
||||||
|
removeEdgesAndNodes(res.body.data.collection.products)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCollections(): Promise<Collection[]> {
|
||||||
|
'use cache';
|
||||||
|
cacheTag(TAGS.collections);
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
|
const res = await shopifyFetch<ShopifyCollectionsOperation>({
|
||||||
|
query: getCollectionsQuery
|
||||||
|
});
|
||||||
|
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
|
||||||
|
const collections = [
|
||||||
|
{
|
||||||
|
handle: '',
|
||||||
|
title: 'All',
|
||||||
|
description: 'All products',
|
||||||
|
seo: {
|
||||||
|
title: 'All',
|
||||||
|
description: 'All products'
|
||||||
|
},
|
||||||
|
path: '/search',
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
},
|
||||||
|
// 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[]> {
|
||||||
|
'use cache';
|
||||||
|
cacheTag(TAGS.collections);
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
|
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 getPages(): Promise<Page[]> {
|
||||||
|
const res = await shopifyFetch<ShopifyPagesOperation>({
|
||||||
|
query: getPagesQuery
|
||||||
|
});
|
||||||
|
|
||||||
|
return removeEdgesAndNodes(res.body.data.pages);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProduct(handle: string): Promise<Product | undefined> {
|
||||||
|
'use cache';
|
||||||
|
cacheTag(TAGS.products);
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
|
const res = await shopifyFetch<ShopifyProductOperation>({
|
||||||
|
query: getProductQuery,
|
||||||
|
variables: {
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return reshapeProduct(res.body.data.product, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProductRecommendations(
|
||||||
|
productId: string
|
||||||
|
): Promise<Product[]> {
|
||||||
|
'use cache';
|
||||||
|
cacheTag(TAGS.products);
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
|
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[]> {
|
||||||
|
'use cache';
|
||||||
|
cacheTag(TAGS.products);
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
|
const res = await shopifyFetch<ShopifyProductsOperation>({
|
||||||
|
query: getProductsQuery,
|
||||||
|
variables: {
|
||||||
|
query,
|
||||||
|
reverse,
|
||||||
|
sortKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
|
||||||
|
export async function revalidate(req: NextRequest): Promise<NextResponse> {
|
||||||
|
// We always need to respond with a 200 status code to Shopify,
|
||||||
|
// otherwise it will continue to retry the request.
|
||||||
|
const collectionWebhooks = [
|
||||||
|
'collections/create',
|
||||||
|
'collections/delete',
|
||||||
|
'collections/update'
|
||||||
|
];
|
||||||
|
const productWebhooks = [
|
||||||
|
'products/create',
|
||||||
|
'products/delete',
|
||||||
|
'products/update'
|
||||||
|
];
|
||||||
|
const topic = (await headers()).get('x-shopify-topic') || 'unknown';
|
||||||
|
const secret = req.nextUrl.searchParams.get('secret');
|
||||||
|
const isCollectionUpdate = collectionWebhooks.includes(topic);
|
||||||
|
const isProductUpdate = productWebhooks.includes(topic);
|
||||||
|
|
||||||
|
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
|
||||||
|
console.error('Invalid revalidation secret.');
|
||||||
|
return NextResponse.json({ status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCollectionUpdate && !isProductUpdate) {
|
||||||
|
// We don't need to revalidate anything for any other topics.
|
||||||
|
return NextResponse.json({ status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCollectionUpdate) {
|
||||||
|
revalidateTag(TAGS.collections);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProductUpdate) {
|
||||||
|
revalidateTag(TAGS.products);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
|
||||||
|
}
|
45
lib/shopify/mutations/cart.ts
Normal file
45
lib/shopify/mutations/cart.ts
Normal 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}
|
||||||
|
`;
|
10
lib/shopify/queries/cart.ts
Normal file
10
lib/shopify/queries/cart.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import cartFragment from '../fragments/cart';
|
||||||
|
|
||||||
|
export const getCartQuery = /* GraphQL */ `
|
||||||
|
query getCart($cartId: ID!) {
|
||||||
|
cart(id: $cartId) {
|
||||||
|
...cart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${cartFragment}
|
||||||
|
`;
|
56
lib/shopify/queries/collection.ts
Normal file
56
lib/shopify/queries/collection.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import productFragment from '../fragments/product';
|
||||||
|
import seoFragment from '../fragments/seo';
|
||||||
|
|
||||||
|
const collectionFragment = /* GraphQL */ `
|
||||||
|
fragment collection on Collection {
|
||||||
|
handle
|
||||||
|
title
|
||||||
|
description
|
||||||
|
seo {
|
||||||
|
...seo
|
||||||
|
}
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
${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!
|
||||||
|
$sortKey: ProductCollectionSortKeys
|
||||||
|
$reverse: Boolean
|
||||||
|
) {
|
||||||
|
collection(handle: $handle) {
|
||||||
|
products(sortKey: $sortKey, reverse: $reverse, first: 100) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
...product
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${productFragment}
|
||||||
|
`;
|
10
lib/shopify/queries/menu.ts
Normal file
10
lib/shopify/queries/menu.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export const getMenuQuery = /* GraphQL */ `
|
||||||
|
query getMenu($handle: String!) {
|
||||||
|
menu(handle: $handle) {
|
||||||
|
items {
|
||||||
|
title
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
41
lib/shopify/queries/page.ts
Normal file
41
lib/shopify/queries/page.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import seoFragment from '../fragments/seo';
|
||||||
|
|
||||||
|
const pageFragment = /* GraphQL */ `
|
||||||
|
fragment page on Page {
|
||||||
|
... on Page {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
handle
|
||||||
|
body
|
||||||
|
bodySummary
|
||||||
|
seo {
|
||||||
|
...seo
|
||||||
|
}
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${seoFragment}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const getPageQuery = /* GraphQL */ `
|
||||||
|
query getPage($handle: String!) {
|
||||||
|
pageByHandle(handle: $handle) {
|
||||||
|
...page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${pageFragment}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const getPagesQuery = /* GraphQL */ `
|
||||||
|
query getPages {
|
||||||
|
pages(first: 100) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
...page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${pageFragment}
|
||||||
|
`;
|
32
lib/shopify/queries/product.ts
Normal file
32
lib/shopify/queries/product.ts
Normal 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}
|
||||||
|
`;
|
272
lib/shopify/types.ts
Normal file
272
lib/shopify/types.ts
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
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 CartProduct = {
|
||||||
|
id: string;
|
||||||
|
handle: string;
|
||||||
|
title: string;
|
||||||
|
featuredImage: Image;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CartItem = {
|
||||||
|
id: string | undefined;
|
||||||
|
quantity: number;
|
||||||
|
cost: {
|
||||||
|
totalAmount: Money;
|
||||||
|
};
|
||||||
|
merchandise: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
selectedOptions: {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
product: CartProduct;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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 | undefined;
|
||||||
|
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;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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[];
|
||||||
|
updatedAt: 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;
|
||||||
|
reverse?: boolean;
|
||||||
|
sortKey?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
27
lib/type-guards.ts
Normal file
27
lib/type-guards.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
export interface ShopifyErrorLike {
|
||||||
|
status: number;
|
||||||
|
message: Error;
|
||||||
|
cause?: 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);
|
||||||
|
}
|
51
lib/utils.ts
Normal file
51
lib/utils.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { ReadonlyURLSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
export const baseUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL
|
||||||
|
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
|
||||||
|
: 'http://localhost:3000';
|
||||||
|
|
||||||
|
export const createUrl = (
|
||||||
|
pathname: string,
|
||||||
|
params: URLSearchParams | ReadonlyURLSearchParams
|
||||||
|
) => {
|
||||||
|
const paramsString = params.toString();
|
||||||
|
const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;
|
||||||
|
|
||||||
|
return `${pathname}${queryString}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
|
||||||
|
stringToCheck.startsWith(startsWith)
|
||||||
|
? stringToCheck
|
||||||
|
: `${startsWith}${stringToCheck}`;
|
||||||
|
|
||||||
|
export const validateEnvironmentVariables = () => {
|
||||||
|
const requiredEnvironmentVariables = [
|
||||||
|
'SHOPIFY_STORE_DOMAIN',
|
||||||
|
'SHOPIFY_STOREFRONT_ACCESS_TOKEN'
|
||||||
|
];
|
||||||
|
const missingEnvironmentVariables = [] as string[];
|
||||||
|
|
||||||
|
requiredEnvironmentVariables.forEach((envVar) => {
|
||||||
|
if (!process.env[envVar]) {
|
||||||
|
missingEnvironmentVariables.push(envVar);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (missingEnvironmentVariables.length) {
|
||||||
|
throw new Error(
|
||||||
|
`The following environment variables are missing. Your site will not work without them. Read more: https://vercel.com/docs/integrations/shopify#configure-environment-variables\n\n${missingEnvironmentVariables.join(
|
||||||
|
'\n'
|
||||||
|
)}\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.env.SHOPIFY_STORE_DOMAIN?.includes('[') ||
|
||||||
|
process.env.SHOPIFY_STORE_DOMAIN?.includes(']')
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Your `SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
@ -1,6 +1,6 @@
|
|||||||
The MIT License (MIT)
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (c) 2020 Vercel, Inc.
|
Copyright (c) 2025 Vercel, Inc.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
17
next.config.ts
Normal file
17
next.config.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export default {
|
||||||
|
experimental: {
|
||||||
|
ppr: true,
|
||||||
|
inlineCss: true,
|
||||||
|
useCache: true
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
formats: ['image/avif', 'image/webp'],
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'cdn.shopify.com',
|
||||||
|
pathname: '/s/files/**'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
43
package.json
43
package.json
@ -1,23 +1,34 @@
|
|||||||
{
|
{
|
||||||
"name": "commerce",
|
|
||||||
"license": "MIT",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build --filter=next-commerce...",
|
"dev": "next dev --turbopack",
|
||||||
"dev": "turbo run dev",
|
"build": "next build",
|
||||||
"start": "turbo run start",
|
"start": "next start",
|
||||||
"types": "turbo run types",
|
"prettier": "prettier --write --ignore-unknown .",
|
||||||
"prettier-fix": "prettier --write ."
|
"prettier:check": "prettier --check --ignore-unknown .",
|
||||||
|
"test": "pnpm prettier:check"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.2.0",
|
||||||
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"geist": "^1.3.1",
|
||||||
|
"next": "15.3.0-canary.13",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0",
|
||||||
|
"sonner": "^2.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"husky": "^8.0.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"prettier": "^2.7.1",
|
"@tailwindcss/postcss": "^4.0.14",
|
||||||
"turbo": "^1.4.6"
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
},
|
"@types/node": "22.13.10",
|
||||||
"husky": {
|
"@types/react": "19.0.12",
|
||||||
"hooks": {
|
"@types/react-dom": "19.0.4",
|
||||||
"pre-commit": "turbo run lint"
|
"postcss": "^8.5.3",
|
||||||
|
"prettier": "3.5.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
"tailwindcss": "^4.0.14",
|
||||||
|
"typescript": "5.8.2"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"packageManager": "pnpm@7.5.0"
|
|
||||||
}
|
}
|
||||||
|
@ -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=
|
|
@ -1,2 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false
|
|
||||||
}
|
|
@ -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:
|
|
||||||
|
|
||||||
[](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>
|
|
@ -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"]
|
|
||||||
}
|
|
||||||
}
|
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
2064
packages/bigcommerce/schema.d.ts
vendored
2064
packages/bigcommerce/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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()
|
|
File diff suppressed because it is too large
Load Diff
@ -1,329 +0,0 @@
|
|||||||
/**
|
|
||||||
* This file was auto-generated by swagger-to-ts.
|
|
||||||
* Do not make direct changes to the file.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface definitions {
|
|
||||||
blogPost_Full: {
|
|
||||||
/**
|
|
||||||
* ID of this blog post. (READ-ONLY)
|
|
||||||
*/
|
|
||||||
id?: number
|
|
||||||
} & definitions['blogPost_Base']
|
|
||||||
addresses: {
|
|
||||||
/**
|
|
||||||
* Full URL of where the resource is located.
|
|
||||||
*/
|
|
||||||
url?: string
|
|
||||||
/**
|
|
||||||
* Resource being accessed.
|
|
||||||
*/
|
|
||||||
resource?: string
|
|
||||||
}
|
|
||||||
formField: {
|
|
||||||
/**
|
|
||||||
* Name of the form field
|
|
||||||
*/
|
|
||||||
name?: string
|
|
||||||
/**
|
|
||||||
* Value of the form field
|
|
||||||
*/
|
|
||||||
value?: string
|
|
||||||
}
|
|
||||||
page_Full: {
|
|
||||||
/**
|
|
||||||
* ID of the page.
|
|
||||||
*/
|
|
||||||
id?: number
|
|
||||||
} & definitions['page_Base']
|
|
||||||
redirect: {
|
|
||||||
/**
|
|
||||||
* Numeric ID of the redirect.
|
|
||||||
*/
|
|
||||||
id?: number
|
|
||||||
/**
|
|
||||||
* The path from which to redirect.
|
|
||||||
*/
|
|
||||||
path: string
|
|
||||||
forward: definitions['forward']
|
|
||||||
/**
|
|
||||||
* URL of the redirect. READ-ONLY
|
|
||||||
*/
|
|
||||||
url?: string
|
|
||||||
}
|
|
||||||
forward: {
|
|
||||||
/**
|
|
||||||
* The type of redirect. If it is a `manual` redirect then type will always be manual. Dynamic redirects will have the type of the page. Such as product or category.
|
|
||||||
*/
|
|
||||||
type?: string
|
|
||||||
/**
|
|
||||||
* Reference of the redirect. Dynamic redirects will have the category or product number. Manual redirects will have the url that is being directed to.
|
|
||||||
*/
|
|
||||||
ref?: number
|
|
||||||
}
|
|
||||||
customer_Full: {
|
|
||||||
/**
|
|
||||||
* Unique numeric ID of this customer. This is a READ-ONLY field; do not set or modify its value in a POST or PUT request.
|
|
||||||
*/
|
|
||||||
id?: number
|
|
||||||
/**
|
|
||||||
* Not returned in any responses, but accepts up to two fields allowing you to set the customer’s password. If a password is not supplied, it is generated automatically. For further information about using this object, please see the Customers resource documentation.
|
|
||||||
*/
|
|
||||||
_authentication?: {
|
|
||||||
force_reset?: string
|
|
||||||
password?: string
|
|
||||||
password_confirmation?: string
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* The name of the company for which the customer works.
|
|
||||||
*/
|
|
||||||
company?: string
|
|
||||||
/**
|
|
||||||
* First name of the customer.
|
|
||||||
*/
|
|
||||||
first_name: string
|
|
||||||
/**
|
|
||||||
* Last name of the customer.
|
|
||||||
*/
|
|
||||||
last_name: string
|
|
||||||
/**
|
|
||||||
* Email address of the customer.
|
|
||||||
*/
|
|
||||||
email: string
|
|
||||||
/**
|
|
||||||
* Phone number of the customer.
|
|
||||||
*/
|
|
||||||
phone?: string
|
|
||||||
/**
|
|
||||||
* Date on which the customer registered from the storefront or was created in the control panel. This is a READ-ONLY field; do not set or modify its value in a POST or PUT request.
|
|
||||||
*/
|
|
||||||
date_created?: string
|
|
||||||
/**
|
|
||||||
* Date on which the customer updated their details in the storefront or was updated in the control panel. This is a READ-ONLY field; do not set or modify its value in a POST or PUT request.
|
|
||||||
*/
|
|
||||||
date_modified?: string
|
|
||||||
/**
|
|
||||||
* The amount of credit the customer has. (Float, Float as String, Integer)
|
|
||||||
*/
|
|
||||||
store_credit?: string
|
|
||||||
/**
|
|
||||||
* The customer’s IP address when they signed up.
|
|
||||||
*/
|
|
||||||
registration_ip_address?: string
|
|
||||||
/**
|
|
||||||
* The group to which the customer belongs.
|
|
||||||
*/
|
|
||||||
customer_group_id?: number
|
|
||||||
/**
|
|
||||||
* Store-owner notes on the customer.
|
|
||||||
*/
|
|
||||||
notes?: string
|
|
||||||
/**
|
|
||||||
* Used to identify customers who fall into special sales-tax categories – in particular, those who are fully or partially exempt from paying sales tax. Can be blank, or can contain a single AvaTax code. (The codes are case-sensitive.) Stores that subscribe to BigCommerce’s Avalara Premium integration will use this code to determine how/whether to apply sales tax. Does not affect sales-tax calculations for stores that do not subscribe to Avalara Premium.
|
|
||||||
*/
|
|
||||||
tax_exempt_category?: string
|
|
||||||
/**
|
|
||||||
* Records whether the customer would like to receive marketing content from this store. READ-ONLY.This is a READ-ONLY field; do not set or modify its value in a POST or PUT request.
|
|
||||||
*/
|
|
||||||
accepts_marketing?: boolean
|
|
||||||
addresses?: definitions['addresses']
|
|
||||||
/**
|
|
||||||
* Array of custom fields. This is a READ-ONLY field; do not set or modify its value in a POST or PUT request.
|
|
||||||
*/
|
|
||||||
form_fields?: definitions['formField'][]
|
|
||||||
/**
|
|
||||||
* Force a password change on next login.
|
|
||||||
*/
|
|
||||||
reset_pass_on_login?: boolean
|
|
||||||
}
|
|
||||||
categoryAccessLevel: {
|
|
||||||
/**
|
|
||||||
* + `all` - Customers can access all categories
|
|
||||||
* + `specific` - Customers can access a specific list of categories
|
|
||||||
* + `none` - Customers are prevented from viewing any of the categories in this group.
|
|
||||||
*/
|
|
||||||
type?: 'all' | 'specific' | 'none'
|
|
||||||
/**
|
|
||||||
* Is an array of category IDs and should be supplied only if `type` is specific.
|
|
||||||
*/
|
|
||||||
categories?: string[]
|
|
||||||
}
|
|
||||||
timeZone: {
|
|
||||||
/**
|
|
||||||
* a string identifying the time zone, in the format: <Continent-name>/<City-name>.
|
|
||||||
*/
|
|
||||||
name?: string
|
|
||||||
/**
|
|
||||||
* a negative or positive number, identifying the offset from UTC/GMT, in seconds, during winter/standard time.
|
|
||||||
*/
|
|
||||||
raw_offset?: number
|
|
||||||
/**
|
|
||||||
* "-/+" offset from UTC/GMT, in seconds, during summer/daylight saving time.
|
|
||||||
*/
|
|
||||||
dst_offset?: number
|
|
||||||
/**
|
|
||||||
* a boolean indicating whether this time zone observes daylight saving time.
|
|
||||||
*/
|
|
||||||
dst_correction?: boolean
|
|
||||||
date_format?: definitions['dateFormat']
|
|
||||||
}
|
|
||||||
count_Response: { count?: number }
|
|
||||||
dateFormat: {
|
|
||||||
/**
|
|
||||||
* string that defines dates’ display format, in the pattern: M jS Y
|
|
||||||
*/
|
|
||||||
display?: string
|
|
||||||
/**
|
|
||||||
* string that defines the CSV export format for orders, customers, and products, in the pattern: M jS Y
|
|
||||||
*/
|
|
||||||
export?: string
|
|
||||||
/**
|
|
||||||
* string that defines dates’ extended-display format, in the pattern: M jS Y @ g:i A.
|
|
||||||
*/
|
|
||||||
extended_display?: string
|
|
||||||
}
|
|
||||||
blogTags: { tag?: string; post_ids?: number[] }[]
|
|
||||||
blogPost_Base: {
|
|
||||||
/**
|
|
||||||
* Title of this blog post.
|
|
||||||
*/
|
|
||||||
title: string
|
|
||||||
/**
|
|
||||||
* URL for the public blog post.
|
|
||||||
*/
|
|
||||||
url?: string
|
|
||||||
/**
|
|
||||||
* URL to preview the blog post. (READ-ONLY)
|
|
||||||
*/
|
|
||||||
preview_url?: string
|
|
||||||
/**
|
|
||||||
* Text body of the blog post.
|
|
||||||
*/
|
|
||||||
body: string
|
|
||||||
/**
|
|
||||||
* Tags to characterize the blog post.
|
|
||||||
*/
|
|
||||||
tags?: string[]
|
|
||||||
/**
|
|
||||||
* Summary of the blog post. (READ-ONLY)
|
|
||||||
*/
|
|
||||||
summary?: string
|
|
||||||
/**
|
|
||||||
* Whether the blog post is published.
|
|
||||||
*/
|
|
||||||
is_published?: boolean
|
|
||||||
published_date?: definitions['publishedDate']
|
|
||||||
/**
|
|
||||||
* Published date in `ISO 8601` format.
|
|
||||||
*/
|
|
||||||
published_date_iso8601?: string
|
|
||||||
/**
|
|
||||||
* Description text for this blog post’s `<meta/>` element.
|
|
||||||
*/
|
|
||||||
meta_description?: string
|
|
||||||
/**
|
|
||||||
* Keywords for this blog post’s `<meta/>` element.
|
|
||||||
*/
|
|
||||||
meta_keywords?: string
|
|
||||||
/**
|
|
||||||
* Name of the blog post’s author.
|
|
||||||
*/
|
|
||||||
author?: string
|
|
||||||
/**
|
|
||||||
* Local path to a thumbnail uploaded to `product_images/` via [WebDav](https://support.bigcommerce.com/s/article/File-Access-WebDAV).
|
|
||||||
*/
|
|
||||||
thumbnail_path?: string
|
|
||||||
}
|
|
||||||
publishedDate: { timezone_type?: string; date?: string; timezone?: string }
|
|
||||||
/**
|
|
||||||
* Not returned in any responses, but accepts up to two fields allowing you to set the customer’s password. If a password is not supplied, it is generated automatically. For further information about using this object, please see the Customers resource documentation.
|
|
||||||
*/
|
|
||||||
authentication: {
|
|
||||||
force_reset?: string
|
|
||||||
password?: string
|
|
||||||
password_confirmation?: string
|
|
||||||
}
|
|
||||||
customer_Base: { [key: string]: any }
|
|
||||||
page_Base: {
|
|
||||||
/**
|
|
||||||
* ID of any parent Web page.
|
|
||||||
*/
|
|
||||||
parent_id?: number
|
|
||||||
/**
|
|
||||||
* `page`: free-text page
|
|
||||||
* `link`: link to another web address
|
|
||||||
* `rss_feed`: syndicated content from an RSS feed
|
|
||||||
* `contact_form`: When the store's contact form is used.
|
|
||||||
*/
|
|
||||||
type: 'page' | 'rss_feed' | 'contact_form' | 'raw' | 'link'
|
|
||||||
/**
|
|
||||||
* Where the page’s type is a contact form: object whose members are the fields enabled (in the control panel) for storefront display. Possible members are:`fullname`: full name of the customer submitting the form; `phone`: customer’s phone number, as submitted on the form; `companyname`: customer’s submitted company name; `orderno`: customer’s submitted order number; `rma`: customer’s submitted RMA (Return Merchandise Authorization) number.
|
|
||||||
*/
|
|
||||||
contact_fields?: string
|
|
||||||
/**
|
|
||||||
* Where the page’s type is a contact form: email address that receives messages sent via the form.
|
|
||||||
*/
|
|
||||||
email?: string
|
|
||||||
/**
|
|
||||||
* Page name, as displayed on the storefront.
|
|
||||||
*/
|
|
||||||
name: string
|
|
||||||
/**
|
|
||||||
* Relative URL on the storefront for this page.
|
|
||||||
*/
|
|
||||||
url?: string
|
|
||||||
/**
|
|
||||||
* Description contained within this page’s `<meta/>` element.
|
|
||||||
*/
|
|
||||||
meta_description?: string
|
|
||||||
/**
|
|
||||||
* HTML or variable that populates this page’s `<body>` element, in default/desktop view. Required in POST if page type is `raw`.
|
|
||||||
*/
|
|
||||||
body: string
|
|
||||||
/**
|
|
||||||
* HTML to use for this page's body when viewed in the mobile template (deprecated).
|
|
||||||
*/
|
|
||||||
mobile_body?: string
|
|
||||||
/**
|
|
||||||
* If true, this page has a mobile version.
|
|
||||||
*/
|
|
||||||
has_mobile_version?: boolean
|
|
||||||
/**
|
|
||||||
* If true, this page appears in the storefront’s navigation menu.
|
|
||||||
*/
|
|
||||||
is_visible?: boolean
|
|
||||||
/**
|
|
||||||
* If true, this page is the storefront’s home page.
|
|
||||||
*/
|
|
||||||
is_homepage?: boolean
|
|
||||||
/**
|
|
||||||
* Text specified for this page’s `<title>` element. (If empty, the value of the name property is used.)
|
|
||||||
*/
|
|
||||||
meta_title?: string
|
|
||||||
/**
|
|
||||||
* Layout template for this page. This field is writable only for stores with a Blueprint theme applied.
|
|
||||||
*/
|
|
||||||
layout_file?: string
|
|
||||||
/**
|
|
||||||
* Order in which this page should display on the storefront. (Lower integers specify earlier display.)
|
|
||||||
*/
|
|
||||||
sort_order?: number
|
|
||||||
/**
|
|
||||||
* Comma-separated list of keywords that shoppers can use to locate this page when searching the store.
|
|
||||||
*/
|
|
||||||
search_keywords?: string
|
|
||||||
/**
|
|
||||||
* Comma-separated list of SEO-relevant keywords to include in the page’s `<meta/>` element.
|
|
||||||
*/
|
|
||||||
meta_keywords?: string
|
|
||||||
/**
|
|
||||||
* If page type is `rss_feed` the n this field is visisble. Required in POST required for `rss page` type.
|
|
||||||
*/
|
|
||||||
feed: string
|
|
||||||
/**
|
|
||||||
* If page type is `link` this field is returned. Required in POST to create a `link` page.
|
|
||||||
*/
|
|
||||||
link: string
|
|
||||||
content_type?: 'application/json' | 'text/javascript' | 'text/html'
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,142 +0,0 @@
|
|||||||
/**
|
|
||||||
* This file was auto-generated by swagger-to-ts.
|
|
||||||
* Do not make direct changes to the file.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface definitions {
|
|
||||||
wishlist_Post: {
|
|
||||||
/**
|
|
||||||
* The customer id.
|
|
||||||
*/
|
|
||||||
customer_id: number
|
|
||||||
/**
|
|
||||||
* Whether the wishlist is available to the public.
|
|
||||||
*/
|
|
||||||
is_public?: boolean
|
|
||||||
/**
|
|
||||||
* The title of the wishlist.
|
|
||||||
*/
|
|
||||||
name?: string
|
|
||||||
/**
|
|
||||||
* Array of Wishlist items.
|
|
||||||
*/
|
|
||||||
items?: {
|
|
||||||
/**
|
|
||||||
* The ID of the product.
|
|
||||||
*/
|
|
||||||
product_id?: number
|
|
||||||
/**
|
|
||||||
* The variant ID of the product.
|
|
||||||
*/
|
|
||||||
variant_id?: number
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
wishlist_Put: {
|
|
||||||
/**
|
|
||||||
* The customer id.
|
|
||||||
*/
|
|
||||||
customer_id: number
|
|
||||||
/**
|
|
||||||
* Whether the wishlist is available to the public.
|
|
||||||
*/
|
|
||||||
is_public?: boolean
|
|
||||||
/**
|
|
||||||
* The title of the wishlist.
|
|
||||||
*/
|
|
||||||
name?: string
|
|
||||||
/**
|
|
||||||
* Array of Wishlist items.
|
|
||||||
*/
|
|
||||||
items?: {
|
|
||||||
/**
|
|
||||||
* The ID of the item
|
|
||||||
*/
|
|
||||||
id?: number
|
|
||||||
/**
|
|
||||||
* The ID of the product.
|
|
||||||
*/
|
|
||||||
product_id?: number
|
|
||||||
/**
|
|
||||||
* The variant ID of the item.
|
|
||||||
*/
|
|
||||||
variant_id?: number
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
wishlist_Full: {
|
|
||||||
/**
|
|
||||||
* Wishlist ID, provided after creating a wishlist with a POST.
|
|
||||||
*/
|
|
||||||
id?: number
|
|
||||||
/**
|
|
||||||
* The ID the customer to which the wishlist belongs.
|
|
||||||
*/
|
|
||||||
customer_id?: number
|
|
||||||
/**
|
|
||||||
* The Wishlist's name.
|
|
||||||
*/
|
|
||||||
name?: string
|
|
||||||
/**
|
|
||||||
* Whether the Wishlist is available to the public.
|
|
||||||
*/
|
|
||||||
is_public?: boolean
|
|
||||||
/**
|
|
||||||
* The token of the Wishlist. This is created internally within BigCommerce. The Wishlist ID is to be used for external apps. Read-Only
|
|
||||||
*/
|
|
||||||
token?: string
|
|
||||||
/**
|
|
||||||
* Array of Wishlist items
|
|
||||||
*/
|
|
||||||
items?: definitions['wishlistItem_Full'][]
|
|
||||||
}
|
|
||||||
wishlistItem_Full: {
|
|
||||||
/**
|
|
||||||
* The ID of the item
|
|
||||||
*/
|
|
||||||
id?: number
|
|
||||||
/**
|
|
||||||
* The ID of the product.
|
|
||||||
*/
|
|
||||||
product_id?: number
|
|
||||||
/**
|
|
||||||
* The variant ID of the item.
|
|
||||||
*/
|
|
||||||
variant_id?: number
|
|
||||||
}
|
|
||||||
wishlistItem_Post: {
|
|
||||||
/**
|
|
||||||
* The ID of the product.
|
|
||||||
*/
|
|
||||||
product_id?: number
|
|
||||||
/**
|
|
||||||
* The variant ID of the product.
|
|
||||||
*/
|
|
||||||
variant_id?: number
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Data about the response, including pagination and collection totals.
|
|
||||||
*/
|
|
||||||
pagination: {
|
|
||||||
/**
|
|
||||||
* Total number of items in the result set.
|
|
||||||
*/
|
|
||||||
total?: number
|
|
||||||
/**
|
|
||||||
* Total number of items in the collection response.
|
|
||||||
*/
|
|
||||||
count?: number
|
|
||||||
/**
|
|
||||||
* The amount of items returned in the collection per page, controlled by the limit parameter.
|
|
||||||
*/
|
|
||||||
per_page?: number
|
|
||||||
/**
|
|
||||||
* The page you are currently on within the collection.
|
|
||||||
*/
|
|
||||||
current_page?: number
|
|
||||||
/**
|
|
||||||
* The total number of pages in the collection.
|
|
||||||
*/
|
|
||||||
total_pages?: number
|
|
||||||
}
|
|
||||||
error: { status?: number; title?: string; type?: string }
|
|
||||||
metaCollection: { pagination?: definitions['pagination'] }
|
|
||||||
}
|
|
@ -1,44 +0,0 @@
|
|||||||
import type { CartEndpoint } from '.'
|
|
||||||
import type { BigcommerceCart } from '../../../types'
|
|
||||||
|
|
||||||
import { normalizeCart } from '../../../lib/normalize'
|
|
||||||
import { parseCartItem } from '../../utils/parse-item'
|
|
||||||
import getCartCookie from '../../utils/get-cart-cookie'
|
|
||||||
|
|
||||||
const addItem: CartEndpoint['handlers']['addItem'] = async ({
|
|
||||||
body: { cartId, item },
|
|
||||||
config,
|
|
||||||
}) => {
|
|
||||||
const options = {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
line_items: [parseCartItem(item)],
|
|
||||||
...(!cartId && config.storeChannelId
|
|
||||||
? { channel_id: config.storeChannelId }
|
|
||||||
: {}),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = cartId
|
|
||||||
? await config.storeApiFetch<{ data: BigcommerceCart }>(
|
|
||||||
`/v3/carts/${cartId}/items?include=line_items.physical_items.options,line_items.digital_items.options`,
|
|
||||||
options
|
|
||||||
)
|
|
||||||
: await config.storeApiFetch<{ data: BigcommerceCart }>(
|
|
||||||
'/v3/carts?include=line_items.physical_items.options,line_items.digital_items.options',
|
|
||||||
options
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: normalizeCart(data),
|
|
||||||
headers: {
|
|
||||||
'Set-Cookie': getCartCookie(
|
|
||||||
config.cartCookie,
|
|
||||||
data.id,
|
|
||||||
config.cartCookieMaxAge
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default addItem
|
|
@ -1,41 +0,0 @@
|
|||||||
import type { CartEndpoint } from '.'
|
|
||||||
import type { BigcommerceCart } from '../../../types'
|
|
||||||
|
|
||||||
import getCartCookie from '../../utils/get-cart-cookie'
|
|
||||||
|
|
||||||
import { normalizeCart } from '../../../lib/normalize'
|
|
||||||
import { BigcommerceApiError } from '../../utils/errors'
|
|
||||||
|
|
||||||
// Return current cart info
|
|
||||||
const getCart: CartEndpoint['handlers']['getCart'] = async ({
|
|
||||||
body: { cartId },
|
|
||||||
config,
|
|
||||||
}) => {
|
|
||||||
if (cartId) {
|
|
||||||
try {
|
|
||||||
const result = await config.storeApiFetch<{
|
|
||||||
data?: BigcommerceCart
|
|
||||||
} | null>(
|
|
||||||
`/v3/carts/${cartId}?include=line_items.physical_items.options,line_items.digital_items.options`
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: result?.data ? normalizeCart(result.data) : null,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof BigcommerceApiError && error.status === 404) {
|
|
||||||
return {
|
|
||||||
headers: { 'Set-Cookie': getCartCookie(config.cartCookie) },
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default getCart
|
|
@ -1,26 +0,0 @@
|
|||||||
import { type GetAPISchema, createEndpoint } from '@vercel/commerce/api'
|
|
||||||
import cartEndpoint from '@vercel/commerce/api/endpoints/cart'
|
|
||||||
import type { CartSchema } from '@vercel/commerce/types/cart'
|
|
||||||
import type { BigcommerceAPI } from '../..'
|
|
||||||
import getCart from './get-cart'
|
|
||||||
import addItem from './add-item'
|
|
||||||
import updateItem from './update-item'
|
|
||||||
import removeItem from './remove-item'
|
|
||||||
|
|
||||||
export type CartAPI = GetAPISchema<BigcommerceAPI, CartSchema>
|
|
||||||
|
|
||||||
export type CartEndpoint = CartAPI['endpoint']
|
|
||||||
|
|
||||||
export const handlers: CartEndpoint['handlers'] = {
|
|
||||||
getCart,
|
|
||||||
addItem,
|
|
||||||
updateItem,
|
|
||||||
removeItem,
|
|
||||||
}
|
|
||||||
|
|
||||||
const cartApi = createEndpoint<CartAPI>({
|
|
||||||
handler: cartEndpoint,
|
|
||||||
handlers,
|
|
||||||
})
|
|
||||||
|
|
||||||
export default cartApi
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user