mirror of
https://github.com/vercel/commerce.git
synced 2025-04-21 10:27:51 +00:00
Compare commits
No commits in common. "main" and "v1" have entirely different histories.
23
.editorconfig
Normal file
23
.editorconfig
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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
|
@ -1,5 +0,0 @@
|
|||||||
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
Normal file
55
.github/ISSUE_TEMPLATE/1.core_bug_report.yml
vendored
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
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
Normal file
59
.github/ISSUE_TEMPLATE/2.provider_bug_report.yml
vendored
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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
Normal file
28
.github/ISSUE_TEMPLATE/3.feature_request.yml
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
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
Normal file
18
.github/ISSUE_TEMPLATE/4.docs_request.yml
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
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
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
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.example
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# typescript
|
# Turborepo
|
||||||
*.tsbuildinfo
|
.turbo
|
||||||
next-env.d.ts
|
|
||||||
.env*.local
|
|
||||||
|
5
.prettierignore
Normal file
5
.prettierignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Every package defines its prettier config
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.next
|
||||||
|
public
|
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false
|
||||||
|
}
|
8
.vscode/extensions.json
vendored
Normal file
8
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"csstools.postcss",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"ms-vscode.vscode-typescript-next"
|
||||||
|
]
|
||||||
|
}
|
28
.vscode/launch.json
vendored
28
.vscode/launch.json
vendored
@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"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,9 +1,4 @@
|
|||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
"editor.formatOnSave": true
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll": "explicit",
|
|
||||||
"source.organizeImports": "explicit",
|
|
||||||
"source.sortMembers": "explicit"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
216
README.md
216
README.md
@ -1,75 +1,199 @@
|
|||||||
[](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)
|
[](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)
|
||||||
|
|
||||||
# Next.js Commerce
|
# Next.js Commerce
|
||||||
|
|
||||||
A high-performance, server-rendered Next.js App Router ecommerce application.
|
The all-in-one starter kit for high-performance e-commerce sites. With a few clicks, Next.js developers can clone, deploy and fully customize their own store.
|
||||||
|
Start right now at [nextjs.org/commerce](https://nextjs.org/commerce)
|
||||||
|
|
||||||
This template uses React Server Components, Server Actions, `Suspense`, `useOptimistic`, and more.
|
Demo live at: [demo.vercel.store](https://demo.vercel.store/)
|
||||||
|
|
||||||
<h3 id="v1-note"></h3>
|
- Shopify Demo: https://shopify.vercel.store/
|
||||||
|
- Swell Demo: https://swell.vercel.store/
|
||||||
|
- BigCommerce Demo: https://bigcommerce.vercel.store/
|
||||||
|
- Vendure Demo: https://vendure.vercel.store
|
||||||
|
- Saleor Demo: https://saleor.vercel.store/
|
||||||
|
- Ordercloud Demo: https://ordercloud.vercel.store/
|
||||||
|
- Spree Demo: https://spree.vercel.store/
|
||||||
|
- Kibo Commerce Demo: https://kibocommerce.vercel.store/
|
||||||
|
- Commerce.js Demo: https://commercejs.vercel.store/
|
||||||
|
- SalesForce Cloud Commerce Demo: https://salesforce-cloud-commerce.vercel.store/
|
||||||
|
|
||||||
> 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).
|
## Run minimal version locally
|
||||||
|
|
||||||
## Providers
|
> 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
|
||||||
|
|
||||||
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).
|
```bash
|
||||||
|
pnpm install & pnpm build # run these commands in the root folder of the mono repo
|
||||||
|
pnpm dev # run this command in the site folder
|
||||||
|
```
|
||||||
|
|
||||||
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.
|
> If you encounter any problems while installing and running for the first time, please see the Troubleshoot section
|
||||||
|
|
||||||
- Shopify (this repository)
|
## Features
|
||||||
- [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/))
|
|
||||||
|
|
||||||
> 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).
|
- Performant by default
|
||||||
|
- SEO Ready
|
||||||
|
- Internationalization
|
||||||
|
- Responsive
|
||||||
|
- UI Components
|
||||||
|
- Theming
|
||||||
|
- Standardized Data Hooks
|
||||||
|
- Integrations - Integrate seamlessly with the most common ecommerce platforms.
|
||||||
|
- Dark Mode Support
|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
Integrations enable upgraded or additional functionality for Next.js Commerce
|
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.
|
||||||
|
|
||||||
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
|
## Considerations
|
||||||
|
|
||||||
- Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
|
- `packages/commerce` contains all types, helpers and functions to be used as a base to build a new **provider**.
|
||||||
- Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
|
- **Providers** live under `packages`'s root folder and they will extend Next.js Commerce types and functionality (`packages/commerce`).
|
||||||
|
- We have a **Features API** to ensure feature parity between the UI and the Provider. The UI should update accordingly and no extra code should be bundled. All extra configuration for features will live under `features` in `commerce.config.json` and if needed it can also be accessed programmatically.
|
||||||
|
- Each **provider** should add its corresponding `next.config.js` and `commerce.config.json` adding specific data related to the provider. For example in the case of BigCommerce, the images CDN and additional API routes.
|
||||||
|
|
||||||
- [React Bricks](https://github.com/ReactBricks/nextjs-commerce-rb) ([Demo](https://nextjs-commerce.reactbricks.com/))
|
## Configuration
|
||||||
- Edit pages, product details, and footer content visually using [React Bricks](https://www.reactbricks.com) visual headless CMS.
|
|
||||||
|
|
||||||
## Running locally
|
### How to change providers
|
||||||
|
|
||||||
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.
|
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).
|
||||||
|
|
||||||
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify store.
|
The setup for Shopify would look like this for example:
|
||||||
|
|
||||||
1. Install Vercel CLI: `npm i -g vercel`
|
```
|
||||||
2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
|
COMMERCE_PROVIDER=@vercel/commerce-shopify
|
||||||
3. Download your environment variables: `vercel env pull`
|
NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN=xxxxxxx.myshopify.com
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Your app should now be running on [localhost:3000](http://localhost:3000/).
|
### 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>
|
<details>
|
||||||
<summary>Expand if you work at Vercel and want to run locally and / or contribute</summary>
|
<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..
|
||||||
|
|
||||||
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
|
<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>
|
||||||
|
|
||||||
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.
|
<details>
|
||||||
|
<summary>When run locally I get `Error: Cannot find module '...@vercel/commerce/dist/config'`</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
commerce/site
|
||||||
|
❯ yarn dev
|
||||||
|
yarn run v1.22.17
|
||||||
|
$ next dev
|
||||||
|
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
|
||||||
|
info - Loaded env from /commerce/site/.env.local
|
||||||
|
error - Failed to load next.config.js, see more info here https://nextjs.org/docs/messages/next-config-error
|
||||||
|
Error: Cannot find module '/Users/dom/work/vercel/commerce/node_modules/@vercel/commerce/dist/config.cjs'
|
||||||
|
at createEsmNotFoundErr (node:internal/modules/cjs/loader:960:15)
|
||||||
|
at finalizeEsmResolution (node:internal/modules/cjs/loader:953:15)
|
||||||
|
at resolveExports (node:internal/modules/cjs/loader:482:14)
|
||||||
|
at Function.Module._findPath (node:internal/modules/cjs/loader:522:31)
|
||||||
|
at Function.Module._resolveFilename (node:internal/modules/cjs/loader:919:27)
|
||||||
|
at Function.mod._resolveFilename (/Users/dom/work/vercel/commerce/node_modules/next/dist/build/webpack/require-hook.js:179:28)
|
||||||
|
at Function.Module._load (node:internal/modules/cjs/loader:778:27)
|
||||||
|
at Module.require (node:internal/modules/cjs/loader:1005:19)
|
||||||
|
at require (node:internal/modules/cjs/helpers:102:18)
|
||||||
|
at Object.<anonymous> (/Users/dom/work/vercel/commerce/site/commerce-config.js:9:14) {
|
||||||
|
code: 'MODULE_NOT_FOUND',
|
||||||
|
path: '/Users/dom/work/vercel/commerce/node_modules/@vercel/commerce/package.json'
|
||||||
|
}
|
||||||
|
error Command failed with exit code 1.
|
||||||
|
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
|
||||||
|
```
|
||||||
|
|
||||||
|
The error usually occurs when running `pnpm dev` inside of the `/site/` folder after installing a fresh repository.
|
||||||
|
|
||||||
|
In order to fix this, run `pnpm build` in the monorepo root folder first.
|
||||||
|
|
||||||
|
> Using `pnpm dev` from the root is recommended for developing, which will run watch mode on all packages.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
@ -1,45 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
import { revalidate } from 'lib/shopify';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
||||||
return revalidate(req);
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
'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
BIN
app/favicon.ico
Binary file not shown.
Before Width: | Height: | Size: 15 KiB |
@ -1,32 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
@ -1,47 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
import OpengraphImage from 'components/opengraph-image';
|
|
||||||
|
|
||||||
export default async function Image() {
|
|
||||||
return await OpengraphImage();
|
|
||||||
}
|
|
21
app/page.tsx
21
app/page.tsx
@ -1,21 +0,0 @@
|
|||||||
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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,149 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
import { baseUrl } from 'lib/utils';
|
|
||||||
|
|
||||||
export default function robots() {
|
|
||||||
return {
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
userAgent: '*'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
sitemap: `${baseUrl}/sitemap.xml`,
|
|
||||||
host: baseUrl
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
@ -1,45 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
'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>;
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,18 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,38 +0,0 @@
|
|||||||
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}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,52 +0,0 @@
|
|||||||
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];
|
|
||||||
}
|
|
@ -1,40 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,106 +0,0 @@
|
|||||||
'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!);
|
|
||||||
}
|
|
@ -1,94 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,238 +0,0 @@
|
|||||||
'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]
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,38 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,256 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,21 +0,0 @@
|
|||||||
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;
|
|
@ -1,61 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,49 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,34 +0,0 @@
|
|||||||
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;
|
|
@ -1,46 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,71 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,100 +0,0 @@
|
|||||||
'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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,40 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,32 +0,0 @@
|
|||||||
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>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,37 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,64 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,41 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,67 +0,0 @@
|
|||||||
'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} />;
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
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;
|
|
@ -1,23 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,45 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
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;
|
|
@ -1,92 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,81 +0,0 @@
|
|||||||
'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 });
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,29 +0,0 @@
|
|||||||
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} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,93 +0,0 @@
|
|||||||
'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>
|
|
||||||
));
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
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;
|
|
@ -1,35 +0,0 @@
|
|||||||
'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;
|
|
||||||
}
|
|
Binary file not shown.
@ -1,31 +0,0 @@
|
|||||||
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';
|
|
@ -1,53 +0,0 @@
|
|||||||
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;
|
|
@ -1,10 +0,0 @@
|
|||||||
const imageFragment = /* GraphQL */ `
|
|
||||||
fragment image on Image {
|
|
||||||
url
|
|
||||||
altText
|
|
||||||
width
|
|
||||||
height
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default imageFragment;
|
|
@ -1,64 +0,0 @@
|
|||||||
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;
|
|
@ -1,8 +0,0 @@
|
|||||||
const seoFragment = /* GraphQL */ `
|
|
||||||
fragment seo on SEO {
|
|
||||||
description
|
|
||||||
title
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default seoFragment;
|
|
@ -1,501 +0,0 @@
|
|||||||
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() });
|
|
||||||
}
|
|
@ -1,45 +0,0 @@
|
|||||||
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}
|
|
||||||
`;
|
|
@ -1,10 +0,0 @@
|
|||||||
import cartFragment from '../fragments/cart';
|
|
||||||
|
|
||||||
export const getCartQuery = /* GraphQL */ `
|
|
||||||
query getCart($cartId: ID!) {
|
|
||||||
cart(id: $cartId) {
|
|
||||||
...cart
|
|
||||||
}
|
|
||||||
}
|
|
||||||
${cartFragment}
|
|
||||||
`;
|
|
@ -1,56 +0,0 @@
|
|||||||
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}
|
|
||||||
`;
|
|
@ -1,10 +0,0 @@
|
|||||||
export const getMenuQuery = /* GraphQL */ `
|
|
||||||
query getMenu($handle: String!) {
|
|
||||||
menu(handle: $handle) {
|
|
||||||
items {
|
|
||||||
title
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
@ -1,41 +0,0 @@
|
|||||||
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}
|
|
||||||
`;
|
|
@ -1,32 +0,0 @@
|
|||||||
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}
|
|
||||||
`;
|
|
@ -1,272 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,27 +0,0 @@
|
|||||||
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
51
lib/utils.ts
@ -1,51 +0,0 @@
|
|||||||
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) 2025 Vercel, Inc.
|
Copyright (c) 2020 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
|
||||||
|
@ -1,17 +0,0 @@
|
|||||||
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,34 +1,23 @@
|
|||||||
{
|
{
|
||||||
|
"name": "commerce",
|
||||||
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"build": "turbo run build --filter=next-commerce...",
|
||||||
"build": "next build",
|
"dev": "turbo run dev",
|
||||||
"start": "next start",
|
"start": "turbo run start",
|
||||||
"prettier": "prettier --write --ignore-unknown .",
|
"types": "turbo run types",
|
||||||
"prettier:check": "prettier --check --ignore-unknown .",
|
"prettier-fix": "prettier --write ."
|
||||||
"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": {
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"husky": "^8.0.1",
|
||||||
"@tailwindcss/postcss": "^4.0.14",
|
"prettier": "^2.7.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"turbo": "^1.4.6"
|
||||||
"@types/node": "22.13.10",
|
},
|
||||||
"@types/react": "19.0.12",
|
"husky": {
|
||||||
"@types/react-dom": "19.0.4",
|
"hooks": {
|
||||||
"postcss": "^8.5.3",
|
"pre-commit": "turbo run lint"
|
||||||
"prettier": "3.5.3",
|
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
|
||||||
"tailwindcss": "^4.0.14",
|
|
||||||
"typescript": "5.8.2"
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@7.5.0"
|
||||||
}
|
}
|
||||||
|
8
packages/bigcommerce/.env.template
Normal file
8
packages/bigcommerce/.env.template
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
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=
|
2
packages/bigcommerce/.prettierignore
Normal file
2
packages/bigcommerce/.prettierignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
6
packages/bigcommerce/.prettierrc
Normal file
6
packages/bigcommerce/.prettierrc
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false
|
||||||
|
}
|
59
packages/bigcommerce/README.md
Normal file
59
packages/bigcommerce/README.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# 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>
|
27
packages/bigcommerce/codegen.json
Normal file
27
packages/bigcommerce/codegen.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
|
}
|
90
packages/bigcommerce/package.json
Normal file
90
packages/bigcommerce/package.json
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"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
Normal file
2064
packages/bigcommerce/schema.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
2422
packages/bigcommerce/schema.graphql
Normal file
2422
packages/bigcommerce/schema.graphql
Normal file
File diff suppressed because it is too large
Load Diff
49
packages/bigcommerce/scripts/generate-definitions.js
Normal file
49
packages/bigcommerce/scripts/generate-definitions.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* 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()
|
2993
packages/bigcommerce/src/api/definitions/catalog.ts
Normal file
2993
packages/bigcommerce/src/api/definitions/catalog.ts
Normal file
File diff suppressed because it is too large
Load Diff
329
packages/bigcommerce/src/api/definitions/store-content.ts
Normal file
329
packages/bigcommerce/src/api/definitions/store-content.ts
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
/**
|
||||||
|
* 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'
|
||||||
|
}
|
||||||
|
}
|
142
packages/bigcommerce/src/api/definitions/wishlist.ts
Normal file
142
packages/bigcommerce/src/api/definitions/wishlist.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* 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'] }
|
||||||
|
}
|
44
packages/bigcommerce/src/api/endpoints/cart/add-item.ts
Normal file
44
packages/bigcommerce/src/api/endpoints/cart/add-item.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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
|
41
packages/bigcommerce/src/api/endpoints/cart/get-cart.ts
Normal file
41
packages/bigcommerce/src/api/endpoints/cart/get-cart.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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
|
26
packages/bigcommerce/src/api/endpoints/cart/index.ts
Normal file
26
packages/bigcommerce/src/api/endpoints/cart/index.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
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