Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ef2cce1f9 | ||
|
|
71c9cb96fa | ||
|
|
ee534492a0 | ||
|
|
36b28b4aab | ||
|
|
455a7327f3 | ||
|
|
10b1d4bbae | ||
|
|
45afbc548e | ||
|
|
7ae036b385 | ||
|
|
cd8f4c6b4c | ||
|
|
1449489c3c | ||
|
|
cccf6afdeb | ||
|
|
049d903a5b | ||
|
|
61b134a66c | ||
|
|
7dc7e6d6e4 | ||
|
|
586f9bfe56 | ||
|
|
6342808f94 | ||
|
|
69a68dd408 | ||
|
|
fa4c0fb8b8 | ||
|
|
29aaa8cac6 | ||
|
|
326f516138 | ||
|
|
51dab5aee5 | ||
|
|
37d7522d87 | ||
|
|
59fc2bc2e9 | ||
|
|
d918fcc895 | ||
|
|
1918c25f4a | ||
|
|
70dcfa9736 | ||
|
|
8c8240956a | ||
|
|
9678306b23 | ||
|
|
dd9d5497d9 | ||
|
|
585b3bbff8 | ||
|
|
7eb8816854 | ||
|
|
f67ab3c0b6 | ||
|
|
87c385fcd6 | ||
|
|
e4fcf19321 | ||
|
|
fecc60eb36 | ||
|
|
7de01c40b9 | ||
|
|
cb31b7141b | ||
|
|
30a080182c | ||
|
|
50b4e6cbc6 | ||
|
|
f5dade74fb | ||
|
|
a0c0d10fae | ||
|
|
a5e799b16e | ||
|
|
23d15496d1 | ||
|
|
3be4f4e6b5 | ||
|
|
86dca04eec | ||
|
|
9ea5671579 | ||
|
|
af21b29b73 | ||
|
|
c2b96d6e2f | ||
|
|
7cdecd322b | ||
|
|
a53ee3e3a0 | ||
|
|
ee900a48e8 | ||
|
|
e3785d0269 | ||
|
|
8ff670d7d6 | ||
|
|
7de3ae5583 | ||
|
|
acb4ff400b | ||
|
|
a677c17f78 | ||
|
|
67a192eba8 | ||
|
|
e9a26c2935 | ||
|
|
fd9450aecb |
@@ -1,23 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
tab_width = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.js]
|
||||
quote_type = single
|
||||
|
||||
[{*.c,*.cc,*.h,*.hh,*.cpp,*.hpp,*.m,*.mm,*.mpp,*.js,*.java,*.go,*.rs,*.php,*.ng,*.jsx,*.ts,*.d,*.cs,*.swift}]
|
||||
curly_bracket_next_line = false
|
||||
spaces_around_operators = true
|
||||
spaces_around_brackets = outside
|
||||
# close enough to 1TB
|
||||
indent_brace_style = K&R
|
||||
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
COMPANY_NAME="Vercel Inc."
|
||||
TWITTER_CREATOR="@vercel"
|
||||
TWITTER_SITE="https://nextjs.org/commerce"
|
||||
SITE_NAME="Next.js Commerce"
|
||||
SHOPIFY_REVALIDATION_SECRET=
|
||||
SHOPIFY_STOREFRONT_ACCESS_TOKEN=
|
||||
SHOPIFY_STORE_DOMAIN=
|
||||
23
.eslintrc.js
Normal file
23
.eslintrc.js
Normal file
@@ -0,0 +1,23 @@
|
||||
module.exports = {
|
||||
extends: ['next', 'prettier'],
|
||||
plugins: ['unicorn'],
|
||||
rules: {
|
||||
'no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
args: 'after-used',
|
||||
caughtErrors: 'none',
|
||||
ignoreRestSiblings: true,
|
||||
vars: 'all'
|
||||
}
|
||||
],
|
||||
'prefer-const': 'error',
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
'unicorn/filename-case': [
|
||||
'error',
|
||||
{
|
||||
case: 'kebabCase'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
55
.github/ISSUE_TEMPLATE/1.core_bug_report.yml
vendored
55
.github/ISSUE_TEMPLATE/1.core_bug_report.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: Core package Bug Report
|
||||
description: Create a bug report for the Next.js commerce core package
|
||||
labels: 'template: core bug'
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thanks for taking the time to file a bug report! Please fill out this form as completely as possible.
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Verify latest commit
|
||||
description: `main` is the latest version of Next.js Commerce.
|
||||
options:
|
||||
- label: I verified that the issue exists on `main`
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Provide environment information
|
||||
description: Please run `npx --no-install next info` in the root directory of your project and paste the results.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: What browser are you using? (if relevant)
|
||||
description: 'Please specify the exact version. For example: Chrome 100.0.4878.0'
|
||||
- type: input
|
||||
attributes:
|
||||
label: How are you deploying your application? (if relevant)
|
||||
description: 'For example: next start, next export, Vercel, Other platform'
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the Bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: To Reproduce
|
||||
description: Steps to reproduce the behavior, please provide a clear code snippets that always reproduces the issue or a GitHub repository. Screenshots can be provided in the issue body below.
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Before posting the issue go through the steps you've written down to make sure the steps provided are detailed and clear.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Contributors should be able to follow the steps provided in order to reproduce the bug.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: These steps are used to add integration tests to ensure the same issue does not happen again. Thanks in advance!
|
||||
59
.github/ISSUE_TEMPLATE/2.provider_bug_report.yml
vendored
59
.github/ISSUE_TEMPLATE/2.provider_bug_report.yml
vendored
@@ -1,59 +0,0 @@
|
||||
name: Provider package Bug Report
|
||||
description: Create a bug report for the Next.js commerce core package
|
||||
labels: 'template: provider bug'
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thanks for taking the time to file a bug report! Please fill out this form as completely as possible.
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Verify latest commit
|
||||
description: `main` is the latest version of Next.js Commerce.
|
||||
options:
|
||||
- label: I verified that the issue exists on `main`
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Provide environment information
|
||||
description: Please run `npx --no-install next info` in the root directory of your project and paste the results.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: What Provider are you using?
|
||||
description: 'Please specify the provider package name. For example: `bigcommerce`'
|
||||
- type: input
|
||||
attributes:
|
||||
label: What browser are you using? (if relevant)
|
||||
description: 'Please specify the exact version. For example: Chrome 100.0.4878.0'
|
||||
- type: input
|
||||
attributes:
|
||||
label: How are you deploying your application? (if relevant)
|
||||
description: 'For example: next start, next export, Vercel, Other platform'
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the Bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: To Reproduce
|
||||
description: Steps to reproduce the behavior, please provide a clear code snippets that always reproduces the issue or a GitHub repository. Screenshots can be provided in the issue body below.
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Before posting the issue go through the steps you've written down to make sure the steps provided are detailed and clear.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Contributors should be able to follow the steps provided in order to reproduce the bug.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: These steps are used to add integration tests to ensure the same issue does not happen again. Thanks in advance!
|
||||
28
.github/ISSUE_TEMPLATE/3.feature_request.yml
vendored
28
.github/ISSUE_TEMPLATE/3.feature_request.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Feature Request
|
||||
description: Create a feature request for the Next.js core
|
||||
labels: 'template: story'
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thanks for taking the time to file a feature request! Please fill out this form as completely as possible.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: 'Feature requests will be converted to the GitHub Discussions "Ideas" section.'
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the feature you'd like to request
|
||||
description: A clear and concise description of what you want and what your use case is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: true
|
||||
18
.github/ISSUE_TEMPLATE/4.docs_request.yml
vendored
18
.github/ISSUE_TEMPLATE/4.docs_request.yml
vendored
@@ -1,18 +0,0 @@
|
||||
name: 'Docs Request for an Update or Improvement'
|
||||
description: A request to update or improve Next.js Commerce documentation
|
||||
title: 'Docs: '
|
||||
labels:
|
||||
- 'template: documentation'
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What is the improvement or update you wish to see?
|
||||
description: 'Example: I would like to see more examples of how to use hooks.'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Is there any context that might help us understand?
|
||||
description: A clear description of any added context that might help us understand.
|
||||
validations:
|
||||
required: true
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
5
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Ask a question
|
||||
url: https://github.com/vercel/commerce/discussions
|
||||
about: Ask questions and discuss with other community members
|
||||
6
.github/dependabot.yml
vendored
Normal file
6
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
32
.github/workflows/test.yml
vendored
Normal file
32
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: test
|
||||
on: pull_request
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cancel running workflows
|
||||
uses: styfle/cancel-workflow-action@0.11.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Set node version
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
- name: Set pnpm version
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
version: 7
|
||||
- name: Cache node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
- name: Install dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm install
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
31
.gitignore
vendored
31
.gitignore
vendored
@@ -1,39 +1,38 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.pnpm-debug.log
|
||||
|
||||
# testing
|
||||
coverage
|
||||
/coverage
|
||||
.playwright
|
||||
|
||||
# next.js
|
||||
.next
|
||||
out
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
build
|
||||
dist
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.idea
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# Turborepo
|
||||
.turbo
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# Every package defines its prettier config
|
||||
node_modules
|
||||
dist
|
||||
.vercel
|
||||
.next
|
||||
public
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
8
.vscode/extensions.json
vendored
8
.vscode/extensions.json
vendored
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"csstools.postcss",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"ms-vscode.vscode-typescript-next"
|
||||
]
|
||||
}
|
||||
28
.vscode/launch.json
vendored
Normal file
28
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm dev"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm dev",
|
||||
"serverReadyAction": {
|
||||
"pattern": "started server on .+, url: (https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": true,
|
||||
"source.organizeImports": true,
|
||||
"source.sortMembers": true
|
||||
}
|
||||
}
|
||||
|
||||
430
README.md
430
README.md
@@ -1,199 +1,277 @@
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-description=An%20all-in-one%20starter%20kit%20for%20high-performance%20e-commerce%20sites.&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&integration-ids=oac_MuWZiE4jtmQ2ejZQaQ7ncuDT,oac_9HSKtXld74NG0srzdxSiBGty&skippable-integrations=1&root-directory=site&build-command=cd%20..%20%26%26%20yarn%20build)
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=COMPANY_NAME,SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STORE_DOMAIN,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SITE_NAME,TWITTER_CREATOR,TWITTER_SITE)
|
||||
|
||||
# Next.js Commerce
|
||||
|
||||
The all-in-one starter kit for high-performance e-commerce sites. With a few clicks, Next.js developers can clone, deploy and fully customize their own store.
|
||||
Start right now at [nextjs.org/commerce](https://nextjs.org/commerce)
|
||||
A Next.js 13 and App Router-ready ecommerce template featuring:
|
||||
|
||||
Demo live at: [demo.vercel.store](https://demo.vercel.store/)
|
||||
- Next.js App Router
|
||||
- Optimized for SEO using Next.js's Metadata
|
||||
- React Server Components (RSCs) and Suspense
|
||||
- Server Actions for mutations
|
||||
- Edge Runtime
|
||||
- New fetching and caching paradigms
|
||||
- Dynamic OG images
|
||||
- Styling with Tailwind CSS
|
||||
- Checkout and payments with Shopify
|
||||
- Automatic light/dark mode based on system settings
|
||||
|
||||
- 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/
|
||||
<h3 id="v1-note"></h3>
|
||||
|
||||
## Run minimal version locally
|
||||
> Note: Looking for Next.js Commerce v1? View the [code](https://github.com/vercel/commerce/tree/v1), [demo](https://commerce-v1.vercel.store), and [release notes](https://github.com/vercel/commerce/releases/tag/v1).
|
||||
|
||||
> To run a minimal version of Next.js Commerce you can start with the default local provider `@vercel/commerce-local` that has all features disabled (cart, auth) and uses static files for the backend
|
||||
## Providers
|
||||
|
||||
```bash
|
||||
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 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).
|
||||
|
||||
> If you encounter any problems while installing and running for the first time, please see the Troubleshoot section
|
||||
Vercel is more than happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged.
|
||||
|
||||
## Features
|
||||
- Shopify (this repository)
|
||||
- [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
|
||||
- [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
|
||||
- [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
|
||||
- [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
|
||||
|
||||
- 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
|
||||
## Running locally
|
||||
|
||||
## Integrations
|
||||
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.
|
||||
|
||||
Next.js Commerce integrates out-of-the-box with BigCommerce, Shopify, Swell, Saleor, Vendure, Spree and Commerce.js. We plan to support all major ecommerce backends.
|
||||
|
||||
## Considerations
|
||||
|
||||
- `packages/commerce` contains all types, helpers and functions to be used as a base to build a new **provider**.
|
||||
- **Providers** live under `packages`'s root folder and they will extend Next.js Commerce types and functionality (`packages/commerce`).
|
||||
- We have a **Features API** to ensure feature parity between the UI and the Provider. The UI should update accordingly and no extra code should be bundled. All extra configuration for features will live under `features` in `commerce.config.json` and if needed it can also be accessed programmatically.
|
||||
- Each **provider** should add its corresponding `next.config.js` and `commerce.config.json` adding specific data related to the provider. For example in the case of BigCommerce, the images CDN and additional API routes.
|
||||
|
||||
## Configuration
|
||||
|
||||
### How to change providers
|
||||
|
||||
Open `site/.env.local` and change the value of `COMMERCE_PROVIDER` to the provider you would like to use, then set the environment variables for that provider (use `site/.env.template` as the base).
|
||||
|
||||
The setup for Shopify would look like this for example:
|
||||
|
||||
```
|
||||
COMMERCE_PROVIDER=@vercel/commerce-shopify
|
||||
NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN=xxxxxxx.myshopify.com
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
Every provider defines the features that it supports under `packages/{provider}/src/commerce.config.json`
|
||||
|
||||
#### Features Available
|
||||
|
||||
The following features can be enabled or disabled. This means that the UI will remove all code related to the feature.
|
||||
For example: turning `cart` off will disable Cart capabilities.
|
||||
|
||||
- cart
|
||||
- search
|
||||
- wishlist
|
||||
- customerAuth
|
||||
- customCheckout
|
||||
|
||||
#### How to turn Features on and off
|
||||
|
||||
> NOTE: The selected provider should support the feature that you are toggling. (This means that you can't turn wishlist on if the provider doesn't support this functionality out of the box)
|
||||
|
||||
- Open `site/commerce.config.json`
|
||||
- You'll see a config file like this:
|
||||
```json
|
||||
{
|
||||
"features": {
|
||||
"wishlist": false,
|
||||
"customCheckout": true
|
||||
}
|
||||
}
|
||||
```
|
||||
- Turn `wishlist` on by setting `wishlist` to `true`.
|
||||
- Run the app and the wishlist functionality should be back on.
|
||||
|
||||
### How to create a new provider
|
||||
|
||||
Follow our docs for [Adding a new Commerce Provider](packages/commerce/new-provider.md).
|
||||
|
||||
If you succeeded building a provider, submit a PR with a valid demo and we'll review it asap.
|
||||
|
||||
## Contribute
|
||||
|
||||
Our commitment to Open Source can be found [here](https://vercel.com/oss).
|
||||
|
||||
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
2. Create a new branch `git checkout -b MY_BRANCH_NAME`
|
||||
3. Install the dependencies: `pnpm install`
|
||||
4. Build the packages: `pnpm build`
|
||||
5. Duplicate `site/.env.template` and rename it to `site/.env.local`
|
||||
6. Add proper store values to `site/.env.local`
|
||||
7. Run `cd site` & `pnpm dev` to watch for code changes
|
||||
8. Run `pnpm turbo run build` to check the build after your changes
|
||||
|
||||
## Work in progress
|
||||
|
||||
We're using Github Projects to keep track of issues in progress and todo's. Here is our [Board](https://github.com/vercel/commerce/projects/1)
|
||||
|
||||
People actively working on this project: @okbel, @lfades, @dominiksipowicz, @gbibeaul.
|
||||
|
||||
## Troubleshoot
|
||||
|
||||
<details>
|
||||
<summary>I already own a BigCommerce store. What should I do?</summary>
|
||||
<br>
|
||||
First thing you do is: <b>set your environment variables</b>
|
||||
<br>
|
||||
<br>
|
||||
.env.local
|
||||
|
||||
```sh
|
||||
BIGCOMMERCE_STOREFRONT_API_URL=<>
|
||||
BIGCOMMERCE_STOREFRONT_API_TOKEN=<>
|
||||
BIGCOMMERCE_STORE_API_URL=<>
|
||||
BIGCOMMERCE_STORE_API_TOKEN=<>
|
||||
BIGCOMMERCE_STORE_API_CLIENT_ID=<>
|
||||
BIGCOMMERCE_CHANNEL_ID=<>
|
||||
```
|
||||
|
||||
If your project was started with a "Deploy with Vercel" button, you can use Vercel's CLI to retrieve these credentials.
|
||||
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify store.
|
||||
|
||||
1. Install Vercel CLI: `npm i -g vercel`
|
||||
2. Link local instance with Vercel and Github accounts (creates .vercel file): `vercel link`
|
||||
3. Download your environment variables: `vercel env pull .env.local`
|
||||
|
||||
Next, you're free to customize the starter. More updates coming soon. Stay tuned..
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>BigCommerce shows a Coming Soon page and requests a Preview Code</summary>
|
||||
<br>
|
||||
After Email confirmation, Checkout should be manually enabled through BigCommerce platform. Look for "Review & test your store" section through BigCommerce's dashboard.
|
||||
<br>
|
||||
<br>
|
||||
BigCommerce team has been notified and they plan to add more details about this subject.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>When run locally I get `Error: Cannot find module '...@vercel/commerce/dist/config'`</summary>
|
||||
2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
|
||||
3. Download your environment variables: `vercel env pull`
|
||||
|
||||
```bash
|
||||
commerce/site
|
||||
❯ yarn dev
|
||||
yarn run v1.22.17
|
||||
$ next dev
|
||||
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
|
||||
info - Loaded env from /commerce/site/.env.local
|
||||
error - Failed to load next.config.js, see more info here https://nextjs.org/docs/messages/next-config-error
|
||||
Error: Cannot find module '/Users/dom/work/vercel/commerce/node_modules/@vercel/commerce/dist/config.cjs'
|
||||
at createEsmNotFoundErr (node:internal/modules/cjs/loader:960:15)
|
||||
at finalizeEsmResolution (node:internal/modules/cjs/loader:953:15)
|
||||
at resolveExports (node:internal/modules/cjs/loader:482:14)
|
||||
at Function.Module._findPath (node:internal/modules/cjs/loader:522:31)
|
||||
at Function.Module._resolveFilename (node:internal/modules/cjs/loader:919:27)
|
||||
at Function.mod._resolveFilename (/Users/dom/work/vercel/commerce/node_modules/next/dist/build/webpack/require-hook.js:179:28)
|
||||
at Function.Module._load (node:internal/modules/cjs/loader:778:27)
|
||||
at Module.require (node:internal/modules/cjs/loader:1005:19)
|
||||
at require (node:internal/modules/cjs/helpers:102:18)
|
||||
at Object.<anonymous> (/Users/dom/work/vercel/commerce/site/commerce-config.js:9:14) {
|
||||
code: 'MODULE_NOT_FOUND',
|
||||
path: '/Users/dom/work/vercel/commerce/node_modules/@vercel/commerce/package.json'
|
||||
}
|
||||
error Command failed with exit code 1.
|
||||
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The error usually occurs when running `pnpm dev` inside of the `/site/` folder after installing a fresh repository.
|
||||
Your app should now be running on [localhost:3000](http://localhost:3000/).
|
||||
|
||||
In order to fix this, run `pnpm build` in the monorepo root folder first.
|
||||
<details>
|
||||
<summary>Expand if you work at Vercel and want to run locally and / or contribute</summary>
|
||||
|
||||
> Using `pnpm dev` from the root is recommended for developing, which will run watch mode on all packages.
|
||||
1. Run `vc link`.
|
||||
1. Select the `Vercel Solutions` scope.
|
||||
1. Connect to the existing `commerce-shopify` project.
|
||||
1. Run `vc env pull` to get environment variables.
|
||||
1. Run `pmpm dev` to ensure everything is working correctly.
|
||||
</details>
|
||||
|
||||
## How to configure your Shopify store for Next.js Commerce
|
||||
|
||||
Next.js Commerce requires a [paid Shopify plan](https://www.shopify.com/pricing).
|
||||
|
||||
> Note: Next.js Commerce will not work with a Shopify Starter plan as it does not allow installation of custom themes, which is required to run as a headless storefront.
|
||||
|
||||
### Add Shopify domain to an environment variable
|
||||
|
||||
Create a `SHOPIFY_STORE_DOMAIN` environment variable and use your Shopify domain as the the value (ie. `[your-shopify-store-subdomain].myshopify.com`).
|
||||
|
||||
> Note: Do not include the `https://`.
|
||||
|
||||
### Accessing the Shopify Storefront API
|
||||
|
||||
Next.js Commerce utilizes [Shopify's Storefront API](https://shopify.dev/docs/api/storefront) to create unique customer experiences. The API offers a full range of commerce options making it possible for customers to control products, collections, menus, pages, cart, checkout, and more.
|
||||
|
||||
In order to use the Shopify's Storefront API, you need to install the [Headless app](https://apps.shopify.com/headless) in your Shopify store.
|
||||
|
||||
Once installed, you'll need to create a `SHOPIFY_STOREFRONT_ACCESS_TOKEN` environment variable and use the public access token as the value.
|
||||
|
||||
> Note: Shopify does offer a Node.js Storefront API SDK. We use the Storefront API via GraphQL directly instead of the Node.js SDK so we have more control over fetching and caching.
|
||||
|
||||
<details>
|
||||
<summary>Expand to view detailed walkthrough</summary>
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/apps`.
|
||||
1. Click the green `Shopify App Store` button.
|
||||

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

|
||||

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

|
||||

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

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

|
||||
|
||||

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

|
||||
|
||||

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

|
||||
|
||||

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

|
||||
|
||||
15
app/[page]/layout.tsx
Normal file
15
app/[page]/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import Footer from 'components/layout/footer';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense>
|
||||
<div className="w-full">
|
||||
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">
|
||||
<Suspense>{children}</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
11
app/[page]/opengraph-image.tsx
Normal file
11
app/[page]/opengraph-image.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
import { getPage } from 'lib/shopify';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image({ params }: { params: { page: string } }) {
|
||||
const page = await getPage(params.page);
|
||||
const title = page.seo?.title || page.title;
|
||||
|
||||
return await OpengraphImage({ title });
|
||||
}
|
||||
49
app/[page]/page.tsx
Normal file
49
app/[page]/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import Prose from 'components/prose';
|
||||
import { getPage } from 'lib/shopify';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export const revalidate = 43200; // 12 hours in seconds
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { page: string };
|
||||
}): Promise<Metadata> {
|
||||
const page = await getPage(params.page);
|
||||
|
||||
if (!page) return notFound();
|
||||
|
||||
return {
|
||||
title: page.seo?.title || page.title,
|
||||
description: page.seo?.description || page.bodySummary,
|
||||
openGraph: {
|
||||
publishedTime: page.createdAt,
|
||||
modifiedTime: page.updatedAt,
|
||||
type: 'article'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: { params: { page: string } }) {
|
||||
const page = await getPage(params.page);
|
||||
|
||||
if (!page) return notFound();
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
|
||||
<Prose className="mb-8" html={page.body as string} />
|
||||
<p className="text-sm italic">
|
||||
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(new Date(page.updatedAt))}.`}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
8
app/api/revalidate/route.ts
Normal file
8
app/api/revalidate/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { revalidate } from 'lib/shopify';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
return revalidate(req);
|
||||
}
|
||||
10
app/error.tsx
Normal file
10
app/error.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
'use client';
|
||||
|
||||
export default function Error({ reset }: { reset: () => void }) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Something went wrong.</h2>
|
||||
<button onClick={() => reset()}>Try again</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 535 B After Width: | Height: | Size: 535 B |
21
app/globals.css
Normal file
21
app/globals.css
Normal file
@@ -0,0 +1,21 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (font: -apple-system-body) and (-webkit-appearance: none) {
|
||||
img[loading='lazy'] {
|
||||
clip-path: inset(0.6px);
|
||||
}
|
||||
}
|
||||
|
||||
a,
|
||||
input,
|
||||
button {
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900;
|
||||
}
|
||||
44
app/layout.tsx
Normal file
44
app/layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import Navbar from 'components/layout/navbar';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { ReactNode, Suspense } from 'react';
|
||||
import './globals.css';
|
||||
|
||||
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
|
||||
|
||||
export const metadata = {
|
||||
title: {
|
||||
default: SITE_NAME,
|
||||
template: `%s | ${SITE_NAME}`
|
||||
},
|
||||
robots: {
|
||||
follow: true,
|
||||
index: true
|
||||
},
|
||||
...(TWITTER_CREATOR &&
|
||||
TWITTER_SITE && {
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
creator: TWITTER_CREATOR,
|
||||
site: TWITTER_SITE
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-inter'
|
||||
});
|
||||
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={inter.variable}>
|
||||
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
|
||||
<Navbar />
|
||||
<Suspense>
|
||||
<main>{children}</main>
|
||||
</Suspense>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
7
app/opengraph-image.tsx
Normal file
7
app/opengraph-image.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image() {
|
||||
return await OpengraphImage();
|
||||
}
|
||||
27
app/page.tsx
Normal file
27
app/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Carousel } from 'components/carousel';
|
||||
import { ThreeItemGrid } from 'components/grid/three-items';
|
||||
import Footer from 'components/layout/footer';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export const metadata = {
|
||||
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
|
||||
openGraph: {
|
||||
type: 'website'
|
||||
}
|
||||
};
|
||||
|
||||
export default async function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<ThreeItemGrid />
|
||||
<Suspense>
|
||||
<Carousel />
|
||||
<Suspense>
|
||||
<Footer />
|
||||
</Suspense>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
143
app/product/[handle]/page.tsx
Normal file
143
app/product/[handle]/page.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import Footer from 'components/layout/footer';
|
||||
import { Gallery } from 'components/product/gallery';
|
||||
import { 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';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { handle: string };
|
||||
}): Promise<Metadata> {
|
||||
const product = await getProduct(params.handle);
|
||||
|
||||
if (!product) return notFound();
|
||||
|
||||
const { url, width, height, altText: alt } = product.featuredImage || {};
|
||||
const 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({ params }: { params: { handle: string } }) {
|
||||
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 (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(productJsonLd)
|
||||
}}
|
||||
/>
|
||||
<div className="mx-auto max-w-screen-2xl px-4">
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-8 px-4 dark:border-neutral-800 dark:bg-black md:p-12 lg:grid lg:grid-cols-6">
|
||||
<div className="lg:col-span-4">
|
||||
<Gallery
|
||||
images={product.images.map((image: Image) => ({
|
||||
src: image.url,
|
||||
altText: image.altText
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="py-6 pr-8 md:pr-12 lg:col-span-2">
|
||||
<ProductDescription product={product} />
|
||||
</div>
|
||||
</div>
|
||||
<Suspense>
|
||||
<RelatedProducts id={product.id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense>
|
||||
<Footer />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="flex w-full gap-4 overflow-x-auto pt-1">
|
||||
{relatedProducts.map((product, i) => {
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
className="w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
|
||||
href={`/product/${product.handle}`}
|
||||
>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
title: product.title,
|
||||
amount: product.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
width={600}
|
||||
height={600}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
app/robots.ts
Normal file
15
app/robots.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: 'http://localhost:3000';
|
||||
|
||||
export default function robots() {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*'
|
||||
}
|
||||
],
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
host: baseUrl
|
||||
};
|
||||
}
|
||||
11
app/search/[collection]/opengraph-image.tsx
Normal file
11
app/search/[collection]/opengraph-image.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
import { getCollection } from 'lib/shopify';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image({ params }: { params: { collection: string } }) {
|
||||
const collection = await getCollection(params.collection);
|
||||
const title = collection?.seo?.title || collection?.title;
|
||||
|
||||
return await OpengraphImage({ title });
|
||||
}
|
||||
49
app/search/[collection]/page.tsx
Normal file
49
app/search/[collection]/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
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 const runtime = 'edge';
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { collection: string };
|
||||
}): Promise<Metadata> {
|
||||
const collection = await getCollection(params.collection);
|
||||
|
||||
if (!collection) return notFound();
|
||||
|
||||
return {
|
||||
title: collection.seo?.title || collection.title,
|
||||
description:
|
||||
collection.seo?.description || collection.description || `${collection.title} products`
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CategoryPage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: { collection: string };
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
22
app/search/layout.tsx
Normal file
22
app/search/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Footer from 'components/layout/footer';
|
||||
import Collections from 'components/layout/search/collections';
|
||||
import FilterList from 'components/layout/search/filter';
|
||||
import { sorting } from 'lib/constants';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export default function SearchLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense>
|
||||
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black dark:text-white md:flex-row">
|
||||
<div className="order-first w-full flex-none md:max-w-[125px]">
|
||||
<Collections />
|
||||
</div>
|
||||
<div className="order-last min-h-screen w-full md:order-none">{children}</div>
|
||||
<div className="order-none flex-none md:order-last md:w-[125px]">
|
||||
<FilterList list={sorting} title="Sort by" />
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
15
app/search/loading.tsx
Normal file
15
app/search/loading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import Grid from 'components/grid';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<Grid className="grid-cols-2 lg:grid-cols-3">
|
||||
{Array(12)
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-900" />
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
41
app/search/page.tsx
Normal file
41
app/search/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
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 runtime = 'edge';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Search',
|
||||
description: 'Search for products in the store.'
|
||||
};
|
||||
|
||||
export default async function SearchPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const { sort, q: searchValue } = searchParams as { [key: string]: string };
|
||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
|
||||
const products = await getProducts({ sortKey, reverse, query: searchValue });
|
||||
const resultsText = products.length > 1 ? 'results' : 'result';
|
||||
|
||||
return (
|
||||
<>
|
||||
{searchValue ? (
|
||||
<p>
|
||||
{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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
app/sitemap.ts
Normal file
40
app/sitemap.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { getCollections, getPages, getProducts } from 'lib/shopify';
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: 'http://localhost:3000';
|
||||
|
||||
export default async function sitemap(): Promise<Promise<Promise<MetadataRoute.Sitemap>>> {
|
||||
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
|
||||
}))
|
||||
);
|
||||
|
||||
const fetchedRoutes = (
|
||||
await Promise.all([collectionsPromise, productsPromise, pagesPromise])
|
||||
).flat();
|
||||
|
||||
return [...routesMap, ...fetchedRoutes];
|
||||
}
|
||||
36
components/carousel.tsx
Normal file
36
components/carousel.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className=" w-full overflow-x-auto pb-6 pt-1">
|
||||
<div className="flex animate-carousel gap-4">
|
||||
{[...products, ...products].map((product, i) => (
|
||||
<Link
|
||||
key={`${product.handle}${i}`}
|
||||
href={`/product/${product.handle}`}
|
||||
className="h-[30vh] w-2/3 flex-none md:w-1/3"
|
||||
>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
title: product.title,
|
||||
amount: product.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
width={600}
|
||||
height={600}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
components/cart/actions.ts
Normal file
68
components/cart/actions.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
'use server';
|
||||
|
||||
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const addItem = async (variantId: string | undefined): Promise<Error | undefined> => {
|
||||
let cartId = cookies().get('cartId')?.value;
|
||||
let cart;
|
||||
|
||||
if (cartId) {
|
||||
cart = await getCart(cartId);
|
||||
}
|
||||
|
||||
if (!cartId || !cart) {
|
||||
cart = await createCart();
|
||||
cartId = cart.id;
|
||||
cookies().set('cartId', cartId);
|
||||
}
|
||||
|
||||
if (!variantId) {
|
||||
return new Error('Missing variantId');
|
||||
}
|
||||
try {
|
||||
await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
|
||||
} catch (e) {
|
||||
return new Error('Error adding item', { cause: e });
|
||||
}
|
||||
};
|
||||
|
||||
export const removeItem = async (lineId: string): Promise<Error | undefined> => {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return new Error('Missing cartId');
|
||||
}
|
||||
try {
|
||||
await removeFromCart(cartId, [lineId]);
|
||||
} catch (e) {
|
||||
return new Error('Error removing item', { cause: e });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateItemQuantity = async ({
|
||||
lineId,
|
||||
variantId,
|
||||
quantity
|
||||
}: {
|
||||
lineId: string;
|
||||
variantId: string;
|
||||
quantity: number;
|
||||
}): Promise<Error | undefined> => {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return new Error('Missing cartId');
|
||||
}
|
||||
try {
|
||||
await updateCart(cartId, [
|
||||
{
|
||||
id: lineId,
|
||||
merchandiseId: variantId,
|
||||
quantity
|
||||
}
|
||||
]);
|
||||
} catch (e) {
|
||||
return new Error('Error updating item quantity', { cause: e });
|
||||
}
|
||||
};
|
||||
68
components/cart/add-to-cart.tsx
Normal file
68
components/cart/add-to-cart.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { addItem } from 'components/cart/actions';
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import { ProductVariant } from 'lib/shopify/types';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
export function AddToCart({
|
||||
variants,
|
||||
availableForSale
|
||||
}: {
|
||||
variants: ProductVariant[];
|
||||
availableForSale: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
variant.selectedOptions.every(
|
||||
(option) => option.value === searchParams.get(option.name.toLowerCase())
|
||||
)
|
||||
);
|
||||
const selectedVariantId = variant?.id || defaultVariantId;
|
||||
const title = !availableForSale
|
||||
? 'Out of stock'
|
||||
: !selectedVariantId
|
||||
? 'Please select options'
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Add item to cart"
|
||||
disabled={isPending || !availableForSale || !selectedVariantId}
|
||||
title={title}
|
||||
onClick={() => {
|
||||
// Safeguard in case someone messes with `disabled` in devtools.
|
||||
if (!availableForSale || !selectedVariantId) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const error = await addItem(selectedVariantId);
|
||||
|
||||
if (error) {
|
||||
alert(error);
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
className={clsx(
|
||||
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90',
|
||||
{
|
||||
'cursor-not-allowed opacity-60 hover:opacity-60': !availableForSale || !selectedVariantId,
|
||||
'cursor-not-allowed': isPending
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="absolute left-0 ml-4">
|
||||
{!isPending ? <PlusIcon className="h-5" /> : <LoadingDots className="mb-3 bg-white" />}
|
||||
</div>
|
||||
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
10
components/cart/close-cart.tsx
Normal file
10
components/cart/close-cart.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function CloseCart({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
|
||||
<XMarkIcon className={clsx('h-6 transition-all ease-in-out hover:scale-110 ', className)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
components/cart/delete-item-button.tsx
Normal file
44
components/cart/delete-item-button.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { removeItem } from 'components/cart/actions';
|
||||
import type { CartItem } from 'lib/shopify/types';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
export default function DeleteItemButton({ item }: { item: CartItem }) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Remove cart item"
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
const error = await removeItem(item.id);
|
||||
|
||||
if (error) {
|
||||
alert(error);
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={clsx(
|
||||
'ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-neutral-500 transition-all duration-200',
|
||||
{
|
||||
'cursor-not-allowed px-0': isPending
|
||||
}
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<LoadingDots className="bg-white" />
|
||||
) : (
|
||||
<XMarkIcon className="hover:text-accent-3 mx-[1px] h-4 w-4 text-white dark:text-black" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
60
components/cart/edit-item-quantity-button.tsx
Normal file
60
components/cart/edit-item-quantity-button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { removeItem, updateItemQuantity } from 'components/cart/actions';
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import type { CartItem } from 'lib/shopify/types';
|
||||
|
||||
export default function EditItemQuantityButton({
|
||||
item,
|
||||
type
|
||||
}: {
|
||||
item: CartItem;
|
||||
type: 'plus' | 'minus';
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
const error =
|
||||
type === 'minus' && item.quantity - 1 === 0
|
||||
? await removeItem(item.id)
|
||||
: await updateItemQuantity({
|
||||
lineId: item.id,
|
||||
variantId: item.merchandise.id,
|
||||
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
|
||||
});
|
||||
|
||||
if (error) {
|
||||
alert(error);
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={clsx(
|
||||
'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full px-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80',
|
||||
{
|
||||
'cursor-not-allowed': isPending,
|
||||
'ml-auto': type === 'minus'
|
||||
}
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<LoadingDots className="bg-black dark:bg-white" />
|
||||
) : type === 'plus' ? (
|
||||
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||
) : (
|
||||
<MinusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
14
components/cart/index.tsx
Normal file
14
components/cart/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getCart } from 'lib/shopify';
|
||||
import { cookies } from 'next/headers';
|
||||
import CartModal from './modal';
|
||||
|
||||
export default async function Cart() {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
let cart;
|
||||
|
||||
if (cartId) {
|
||||
cart = await getCart(cartId);
|
||||
}
|
||||
|
||||
return <CartModal cart={cart} />;
|
||||
}
|
||||
191
components/cart/modal.tsx
Normal file
191
components/cart/modal.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
||||
import Price from 'components/price';
|
||||
import { DEFAULT_OPTION } from 'lib/constants';
|
||||
import type { Cart } from 'lib/shopify/types';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import CloseCart from './close-cart';
|
||||
import 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({ cart }: { cart: Cart | undefined }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const quantityRef = useRef(cart?.totalQuantity);
|
||||
const openCart = () => setIsOpen(true);
|
||||
const closeCart = () => setIsOpen(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Open cart modal when quantity changes.
|
||||
if (cart?.totalQuantity !== quantityRef.current) {
|
||||
// But only if it's not already open (quantity also changes when editing items in cart).
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
// Always update the quantity reference
|
||||
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 dark:border-neutral-700 dark:bg-black/80 dark:text-white md:w-[390px]">
|
||||
<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="flex-grow overflow-auto py-4">
|
||||
{cart.lines.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 -mt-2 ml-[55px]">
|
||||
<DeleteItemButton item={item} />
|
||||
</div>
|
||||
<Link
|
||||
href={merchandiseUrl}
|
||||
onClick={closeCart}
|
||||
className="z-30 flex flex-row space-x-4"
|
||||
>
|
||||
<div className="relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
|
||||
<Image
|
||||
className="h-full w-full object-cover "
|
||||
width={64}
|
||||
height={64}
|
||||
alt={
|
||||
item.merchandise.product.featuredImage.altText ||
|
||||
item.merchandise.product.title
|
||||
}
|
||||
src={item.merchandise.product.featuredImage.url}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-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 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" />
|
||||
<p className="w-6 text-center ">
|
||||
<span className="w-full text-sm">{item.quantity}</span>
|
||||
</p>
|
||||
<EditItemQuantityButton item={item} type="plus" />
|
||||
</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>
|
||||
<a
|
||||
href={cart.checkoutUrl}
|
||||
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
|
||||
>
|
||||
Proceed to Checkout
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
components/cart/open-cart.tsx
Normal file
24
components/cart/open-cart.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function OpenCart({
|
||||
className,
|
||||
quantity
|
||||
}: {
|
||||
className?: string;
|
||||
quantity?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
|
||||
<ShoppingCartIcon
|
||||
className={clsx('h-4 transition-all ease-in-out hover:scale-110 ', className)}
|
||||
/>
|
||||
|
||||
{quantity ? (
|
||||
<div className="absolute right-0 top-0 -mr-2 -mt-2 h-4 w-4 rounded bg-blue-600 text-[11px] font-medium text-white">
|
||||
{quantity}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
components/grid/index.tsx
Normal file
21
components/grid/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
function Grid(props: React.ComponentProps<'ul'>) {
|
||||
return (
|
||||
<ul {...props} className={clsx('grid grid-flow-row gap-4', props.className)}>
|
||||
{props.children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function GridItem(props: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li {...props} className={clsx('aspect-square transition-opacity', props.className)}>
|
||||
{props.children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
Grid.Item = GridItem;
|
||||
|
||||
export default Grid;
|
||||
47
components/grid/three-items.tsx
Normal file
47
components/grid/three-items.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
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 }: { item: Product; size: 'full' | 'half' }) {
|
||||
return (
|
||||
<div
|
||||
className={size === 'full' ? 'lg:col-span-4 lg:row-span-2' : 'lg:col-span-2 lg:row-span-1'}
|
||||
>
|
||||
<Link className="block h-full" href={`/product/${item.handle}`}>
|
||||
<GridTileImage
|
||||
src={item.featuredImage.url}
|
||||
width={size === 'full' ? 1080 : 540}
|
||||
height={size === 'full' ? 1080 : 540}
|
||||
priority={true}
|
||||
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-screen-2xl gap-4 px-4 pb-4 lg:grid-cols-6 lg:grid-rows-2">
|
||||
<ThreeItemGridItem size="full" item={firstProduct} />
|
||||
<ThreeItemGridItem size="half" item={secondProduct} />
|
||||
<ThreeItemGridItem size="half" item={thirdProduct} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
50
components/grid/tile.tsx
Normal file
50
components/grid/tile.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
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(
|
||||
'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 ? (
|
||||
// eslint-disable-next-line jsx-a11y/alt-text -- `alt` is inherited from `props`, which is being enforced with TypeScript
|
||||
<Image
|
||||
className={clsx('relative h-full w-full object-contain', {
|
||||
'transition duration-300 ease-in-out hover:scale-105': isInteractive
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
) : null}
|
||||
{label ? (
|
||||
<Label
|
||||
title={label.title}
|
||||
amount={label.amount}
|
||||
currencyCode={label.currencyCode}
|
||||
position={label.position}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
components/icons/github.tsx
Normal file
13
components/icons/github.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export default function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
shapeRendering="geometricPrecision"
|
||||
className={className}
|
||||
>
|
||||
<path d="M12 0C5.37 0 0 5.50583 0 12.3035C0 17.7478 3.435 22.3463 8.205 23.9765C8.805 24.0842 9.03 23.715 9.03 23.3921C9.03 23.0999 9.015 22.131 9.015 21.1005C6 21.6696 5.22 20.347 4.98 19.6549C4.845 19.3012 4.26 18.2092 3.75 17.917C3.33 17.6863 2.73 17.1173 3.735 17.1019C4.68 17.0865 5.355 17.9939 5.58 18.363C6.66 20.2239 8.385 19.701 9.075 19.3781C9.18 18.5783 9.495 18.04 9.84 17.7325C7.17 17.4249 4.38 16.3637 4.38 11.6576C4.38 10.3196 4.845 9.21227 5.61 8.35102C5.49 8.04343 5.07 6.78232 5.73 5.09058C5.73 5.09058 6.735 4.76762 9.03 6.3517C9.99 6.07487 11.01 5.93645 12.03 5.93645C13.05 5.93645 14.07 6.07487 15.03 6.3517C17.325 4.75224 18.33 5.09058 18.33 5.09058C18.99 6.78232 18.57 8.04343 18.45 8.35102C19.215 9.21227 19.68 10.3042 19.68 11.6576C19.68 16.3791 16.875 17.4249 14.205 17.7325C14.64 18.1169 15.015 18.8552 15.015 20.0086C15.015 21.6542 15 22.9768 15 23.3921C15 23.715 15.225 24.0995 15.825 23.9765C18.2072 23.1519 20.2773 21.5822 21.7438 19.4882C23.2103 17.3942 23.9994 14.8814 24 12.3035C24 5.50583 18.63 0 12 0Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
16
components/icons/logo.tsx
Normal file
16
components/icons/logo.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function LogoIcon(props: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label={`${process.env.SITE_NAME} logo`}
|
||||
viewBox="0 0 32 28"
|
||||
{...props}
|
||||
className={clsx('h-4 w-4 fill-black dark:fill-white', props.className)}
|
||||
>
|
||||
<path d="M21.5758 9.75769L16 0L0 28H11.6255L21.5758 9.75769Z" />
|
||||
<path d="M26.2381 17.9167L20.7382 28H32L26.2381 17.9167Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
34
components/label.tsx
Normal file
34
components/label.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import clsx from 'clsx';
|
||||
import Price from './price';
|
||||
|
||||
const Label = ({
|
||||
title,
|
||||
amount,
|
||||
currencyCode,
|
||||
position = 'bottom'
|
||||
}: {
|
||||
title: string;
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
position?: 'bottom' | 'center';
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx('absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label', {
|
||||
'lg:px-20 lg:pb-[35%]': position === 'center'
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center rounded-full border bg-white/70 p-1 text-[10px] font-semibold text-black backdrop-blur-md @[275px]/label:text-xs dark:border-neutral-800 dark:bg-black/70 dark:text-white">
|
||||
<h3 className="mr-4 inline pl-2 leading-none tracking-tight">{title}</h3>
|
||||
<Price
|
||||
className="flex-none rounded-full bg-blue-600 p-2 text-white"
|
||||
amount={amount}
|
||||
currencyCode={currencyCode}
|
||||
currencyCodeClassName="hidden @[275px]/label:inline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Label;
|
||||
46
components/layout/footer-menu.tsx
Normal file
46
components/layout/footer-menu.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const 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 dark:hover:text-neutral-300 md:inline-block md:text-sm',
|
||||
{
|
||||
'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>
|
||||
);
|
||||
}
|
||||
70
components/layout/footer.tsx
Normal file
70
components/layout/footer.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import GitHubIcon from 'components/icons/github';
|
||||
import FooterMenu from 'components/layout/footer-menu';
|
||||
import LogoSquare from 'components/logo-square';
|
||||
import { getMenu } from 'lib/shopify';
|
||||
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 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 dark:border-neutral-700 md:flex-row md:gap-12 md:px-4 xl:px-0">
|
||||
<div>
|
||||
<Link className="flex items-center gap-2 text-black dark:text-white md:pt-1" 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 items-center gap-2 text-lg hover:text-black dark:hover:text-neutral-300 md:text-sm"
|
||||
aria-label="Github Repository"
|
||||
href="https://github.com/vercel/commerce"
|
||||
>
|
||||
<GitHubIcon className="h-6" />
|
||||
<p>Source</p>
|
||||
</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 xl: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>Designed in California</p>
|
||||
<p className="md:ml-auto">
|
||||
Crafted by{' '}
|
||||
<a href="https://vercel.com" className="text-black dark:text-white">
|
||||
▲ Vercel
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
58
components/layout/navbar/index.tsx
Normal file
58
components/layout/navbar/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import Cart from 'components/cart';
|
||||
import OpenCart from 'components/cart/open-cart';
|
||||
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 from './search';
|
||||
const { SITE_NAME } = process.env;
|
||||
|
||||
export default 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">
|
||||
<MobileMenu menu={menu} />
|
||||
</div>
|
||||
<div className="flex w-full items-center">
|
||||
<div className="flex w-full md:w-1/3">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Go back home"
|
||||
className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6"
|
||||
>
|
||||
<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}
|
||||
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">
|
||||
<Search />
|
||||
</div>
|
||||
<div className="flex justify-end md:w-1/3">
|
||||
<Suspense fallback={<OpenCart />}>
|
||||
<Cart />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
98
components/layout/navbar/mobile-menu.tsx
Normal file
98
components/layout/navbar/mobile-menu.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
|
||||
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import Search 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 dark:border-neutral-700 dark:text-white md:hidden"
|
||||
>
|
||||
<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">
|
||||
<Search />
|
||||
</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} onClick={closeMobileMenu}>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
components/layout/navbar/search.tsx
Normal file
50
components/layout/navbar/search.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import { createUrl } from 'lib/utils';
|
||||
|
||||
export default function Search() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setSearchValue(searchParams?.get('q') || '');
|
||||
}, [searchParams, setSearchValue]);
|
||||
|
||||
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
const val = e.target as HTMLFormElement;
|
||||
const search = val.search as HTMLInputElement;
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
if (search.value) {
|
||||
newParams.set('q', search.value);
|
||||
} else {
|
||||
newParams.delete('q');
|
||||
}
|
||||
|
||||
router.push(createUrl('/search', newParams));
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search for products..."
|
||||
autoComplete="off"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
className="w-full rounded-lg border bg-white px-4 py-2 text-sm text-black placeholder:text-neutral-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>
|
||||
);
|
||||
}
|
||||
28
components/layout/product-grid-items.tsx
Normal file
28
components/layout/product-grid-items.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
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="inline-block h-full w-full" href={`/product/${product.handle}`}>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
title: product.title,
|
||||
amount: product.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
width={600}
|
||||
height={600}
|
||||
/>
|
||||
</Link>
|
||||
</Grid.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
components/layout/search/collections.tsx
Normal file
37
components/layout/search/collections.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import clsx from 'clsx';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { getCollections } from 'lib/shopify';
|
||||
import FilterList from './filter';
|
||||
|
||||
async function CollectionList() {
|
||||
const collections = await getCollections();
|
||||
return <FilterList list={collections} title="Collections" />;
|
||||
}
|
||||
|
||||
const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded';
|
||||
const activeAndTitles = 'bg-neutral-800 dark:bg-neutral-300';
|
||||
const items = 'bg-neutral-400 dark:bg-neutral-700';
|
||||
|
||||
export default function Collections() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="col-span-2 hidden h-[400px] w-full flex-none py-4 lg:block">
|
||||
<div className={clsx(skeleton, activeAndTitles)} />
|
||||
<div className={clsx(skeleton, activeAndTitles)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CollectionList />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
64
components/layout/search/filter/dropdown.tsx
Normal file
64
components/layout/search/filter/dropdown.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import type { ListItem } from '.';
|
||||
import { FilterItem } from './item';
|
||||
|
||||
export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [active, setActive] = useState('');
|
||||
const [openSelect, setOpenSelect] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
setOpenSelect(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('click', handleClickOutside);
|
||||
return () => window.removeEventListener('click', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
list.forEach((listItem: ListItem) => {
|
||||
if (
|
||||
('path' in listItem && pathname === listItem.path) ||
|
||||
('slug' in listItem && searchParams.get('sort') === listItem.slug)
|
||||
) {
|
||||
setActive(listItem.title);
|
||||
}
|
||||
});
|
||||
}, [pathname, list, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<div
|
||||
onClick={() => {
|
||||
setOpenSelect(!openSelect);
|
||||
}}
|
||||
className="flex w-full items-center justify-between rounded 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>
|
||||
);
|
||||
}
|
||||
32
components/layout/search/filter/index.tsx
Normal file
32
components/layout/search/filter/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { SortFilterItem } from 'lib/constants';
|
||||
import FilterItemDropdown from './dropdown';
|
||||
import { FilterItem } from './item';
|
||||
|
||||
export type ListItem = SortFilterItem | PathFilterItem;
|
||||
export type PathFilterItem = { title: string; path: string };
|
||||
|
||||
function FilterItemList({ list }: { list: ListItem[] }) {
|
||||
return (
|
||||
<>
|
||||
{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">{title}</h3> : null}
|
||||
<ul className="hidden md:block">
|
||||
<FilterItemList list={list} />
|
||||
</ul>
|
||||
<ul className="md:hidden">
|
||||
<FilterItemDropdown list={list} />
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
67
components/layout/search/filter/item.tsx
Normal file
67
components/layout/search/filter/item.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { 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', {
|
||||
'underline underline-offset-4': active
|
||||
})}
|
||||
>
|
||||
{item.title}
|
||||
</DynamicTag>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterItem({ item }: { item: ListItem }) {
|
||||
return 'path' in item ? <PathFilterItem item={item} /> : <SortFilterItem item={item} />;
|
||||
}
|
||||
15
components/loading-dots.tsx
Normal file
15
components/loading-dots.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
const dots = 'mx-[1px] inline-block h-1 w-1 animate-blink rounded-md';
|
||||
|
||||
const LoadingDots = ({ className }: { className: string }) => {
|
||||
return (
|
||||
<span className="mx-2 inline-flex items-center">
|
||||
<span className={clsx(dots, className)} />
|
||||
<span className={clsx(dots, 'animation-delay-[200ms]', className)} />
|
||||
<span className={clsx(dots, 'animation-delay-[400ms]', className)} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingDots;
|
||||
23
components/logo-square.tsx
Normal file
23
components/logo-square.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import clsx from 'clsx';
|
||||
import LogoIcon from './icons/logo';
|
||||
|
||||
export default function LogoSquare({ size }: { size?: 'sm' | undefined }) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-none items-center justify-center border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-black',
|
||||
{
|
||||
'h-[40px] w-[40px] rounded-xl': !size,
|
||||
'h-[30px] w-[30px] rounded-lg': size === 'sm'
|
||||
}
|
||||
)}
|
||||
>
|
||||
<LogoIcon
|
||||
className={clsx({
|
||||
'h-[16px] w-[16px]': !size,
|
||||
'h-[10px] w-[10px]': size === 'sm'
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
components/opengraph-image.tsx
Normal file
40
components/opengraph-image.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ImageResponse } from 'next/server';
|
||||
import LogoIcon from './icons/logo';
|
||||
|
||||
export type Props = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export default async function OpengraphImage(props?: Props): Promise<ImageResponse> {
|
||||
const { title } = {
|
||||
...{
|
||||
title: process.env.SITE_NAME
|
||||
},
|
||||
...props
|
||||
};
|
||||
|
||||
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: await fetch(new URL('../fonts/Inter-Bold.ttf', import.meta.url)).then((res) =>
|
||||
res.arrayBuffer()
|
||||
),
|
||||
style: 'normal',
|
||||
weight: 700
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
24
components/price.tsx
Normal file
24
components/price.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
const Price = ({
|
||||
amount,
|
||||
className,
|
||||
currencyCode = 'USD',
|
||||
currencyCodeClassName
|
||||
}: {
|
||||
amount: string;
|
||||
className?: string;
|
||||
currencyCode: string;
|
||||
currencyCodeClassName?: string;
|
||||
} & React.ComponentProps<'p'>) => (
|
||||
<p suppressHydrationWarning={true} className={className}>
|
||||
{`${new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
currencyDisplay: 'narrowSymbol'
|
||||
}).format(parseFloat(amount))}`}
|
||||
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{`${currencyCode}`}</span>
|
||||
</p>
|
||||
);
|
||||
|
||||
export default Price;
|
||||
92
components/product/gallery.tsx
Normal file
92
components/product/gallery.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
|
||||
export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const imageSearchParam = searchParams.get('image');
|
||||
const imageIndex = imageSearchParam ? parseInt(imageSearchParam) : 0;
|
||||
|
||||
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
||||
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
|
||||
nextSearchParams.set('image', nextImageIndex.toString());
|
||||
const nextUrl = createUrl(pathname, nextSearchParams);
|
||||
|
||||
const previousSearchParams = new URLSearchParams(searchParams.toString());
|
||||
const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
|
||||
previousSearchParams.set('image', previousImageIndex.toString());
|
||||
const previousUrl = createUrl(pathname, previousSearchParams);
|
||||
|
||||
const buttonClassName =
|
||||
'h-full px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white flex items-center justify-center';
|
||||
|
||||
return (
|
||||
<div className="mr-8 h-full">
|
||||
<div className="relative mb-12 h-full max-h-[550px] overflow-hidden">
|
||||
{images[imageIndex] && (
|
||||
<Image
|
||||
className="relative h-full w-full object-contain"
|
||||
height={600}
|
||||
width={600}
|
||||
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 dark:border-black dark:bg-neutral-900/80">
|
||||
<Link
|
||||
aria-label="Previous product image"
|
||||
href={previousUrl}
|
||||
className={buttonClassName}
|
||||
>
|
||||
<ArrowLeftIcon className="h-5" />
|
||||
</Link>
|
||||
<div className="mx-1 h-6 w-px bg-neutral-500"></div>
|
||||
<Link aria-label="Next product image" href={nextUrl} className={buttonClassName}>
|
||||
<ArrowRightIcon className="h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{images.length > 1 ? (
|
||||
<div className="flex items-center justify-center gap-2 overflow-auto py-1">
|
||||
{images.map((image, index) => {
|
||||
const isActive = index === imageIndex;
|
||||
const imageSearchParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
imageSearchParams.set('image', index.toString());
|
||||
|
||||
return (
|
||||
<Link
|
||||
aria-label="Enlarge product image"
|
||||
key={image.src}
|
||||
className="h-auto w-20"
|
||||
href={createUrl(pathname, imageSearchParams)}
|
||||
scroll={false}
|
||||
>
|
||||
<GridTileImage
|
||||
alt={image.altText}
|
||||
src={image.src}
|
||||
width={600}
|
||||
height={600}
|
||||
active={isActive}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
components/product/product-description.tsx
Normal file
31
components/product/product-description.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
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 variants={product.variants} availableForSale={product.availableForSale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
110
components/product/variant-selector.tsx
Normal file
110
components/product/variant-selector.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { ProductOption, ProductVariant } from 'lib/shopify/types';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
|
||||
type Combination = {
|
||||
id: string;
|
||||
availableForSale: boolean;
|
||||
[key: string]: string | boolean; // ie. { color: 'Red', size: 'Large', ... }
|
||||
};
|
||||
|
||||
export function VariantSelector({
|
||||
options,
|
||||
variants
|
||||
}: {
|
||||
options: ProductOption[];
|
||||
variants: ProductVariant[];
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
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,
|
||||
// Adds key / value pairs for each variant (ie. "color": "Black" and "size": 'M").
|
||||
...variant.selectedOptions.reduce(
|
||||
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
|
||||
{}
|
||||
)
|
||||
}));
|
||||
|
||||
return options.map((option) => (
|
||||
<dl className="mb-8" key={option.id}>
|
||||
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
|
||||
<dd className="flex flex-wrap gap-3">
|
||||
{option.values.map((value) => {
|
||||
const optionNameLowerCase = option.name.toLowerCase();
|
||||
|
||||
// Base option params on current params so we can preserve any other param state in the url.
|
||||
const optionSearchParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
// Update the option params using the current option to reflect how the url *would* change,
|
||||
// if the option was clicked.
|
||||
optionSearchParams.set(optionNameLowerCase, value);
|
||||
const optionUrl = createUrl(pathname, optionSearchParams);
|
||||
|
||||
// In order to determine if an option is available for sale, we need to:
|
||||
//
|
||||
// 1. Filter out all other param state
|
||||
// 2. Filter out invalid options
|
||||
// 3. Check if the option combination is available for sale
|
||||
//
|
||||
// This is the "magic" that will cross check possible variant combinations and preemptively
|
||||
// disable combinations that are not available. For example, if the color gray is only available in size medium,
|
||||
// then all other sizes should be disabled.
|
||||
const filtered = Array.from(optionSearchParams.entries()).filter(([key, value]) =>
|
||||
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 url params.
|
||||
const isActive = searchParams.get(optionNameLowerCase) === value;
|
||||
|
||||
// You can't disable a link, so we need to render something that isn't clickable.
|
||||
const DynamicTag = isAvailableForSale ? Link : 'p';
|
||||
const dynamicProps = {
|
||||
...(isAvailableForSale && { scroll: false })
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicTag
|
||||
key={value}
|
||||
aria-disabled={!isAvailableForSale}
|
||||
href={optionUrl}
|
||||
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
|
||||
className={clsx(
|
||||
'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900',
|
||||
{
|
||||
'cursor-default ring-2 ring-blue-600': isActive,
|
||||
'ring-1 ring-transparent transition duration-300 ease-in-out hover:scale-110 hover:ring-blue-600 ':
|
||||
!isActive && isAvailableForSale,
|
||||
'relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 before:dark:bg-neutral-700':
|
||||
!isAvailableForSale
|
||||
}
|
||||
)}
|
||||
{...dynamicProps}
|
||||
>
|
||||
{value}
|
||||
</DynamicTag>
|
||||
);
|
||||
})}
|
||||
</dd>
|
||||
</dl>
|
||||
));
|
||||
}
|
||||
21
components/prose.tsx
Normal file
21
components/prose.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import clsx from 'clsx';
|
||||
import type { FunctionComponent } from 'react';
|
||||
|
||||
interface TextProps {
|
||||
html: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Prose: FunctionComponent<TextProps> = ({ html, className }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline hover:prose-a:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white',
|
||||
className
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: html as string }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Prose;
|
||||
BIN
fonts/Inter-Bold.ttf
Normal file
BIN
fonts/Inter-Bold.ttf
Normal file
Binary file not shown.
30
lib/constants.ts
Normal file
30
lib/constants.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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'
|
||||
};
|
||||
|
||||
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
||||
export const DEFAULT_OPTION = 'Default Title';
|
||||
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';
|
||||
53
lib/shopify/fragments/cart.ts
Normal file
53
lib/shopify/fragments/cart.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import productFragment from './product';
|
||||
|
||||
const cartFragment = /* GraphQL */ `
|
||||
fragment cart on Cart {
|
||||
id
|
||||
checkoutUrl
|
||||
cost {
|
||||
subtotalAmount {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
totalAmount {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
totalTaxAmount {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
lines(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
quantity
|
||||
cost {
|
||||
totalAmount {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
merchandise {
|
||||
... on ProductVariant {
|
||||
id
|
||||
title
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
}
|
||||
product {
|
||||
...product
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
totalQuantity
|
||||
}
|
||||
${productFragment}
|
||||
`;
|
||||
|
||||
export default cartFragment;
|
||||
10
lib/shopify/fragments/image.ts
Normal file
10
lib/shopify/fragments/image.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
const imageFragment = /* GraphQL */ `
|
||||
fragment image on Image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
`;
|
||||
|
||||
export default imageFragment;
|
||||
64
lib/shopify/fragments/product.ts
Normal file
64
lib/shopify/fragments/product.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import imageFragment from './image';
|
||||
import seoFragment from './seo';
|
||||
|
||||
const productFragment = /* GraphQL */ `
|
||||
fragment product on Product {
|
||||
id
|
||||
handle
|
||||
availableForSale
|
||||
title
|
||||
description
|
||||
descriptionHtml
|
||||
options {
|
||||
id
|
||||
name
|
||||
values
|
||||
}
|
||||
priceRange {
|
||||
maxVariantPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
minVariantPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
variants(first: 250) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
availableForSale
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
}
|
||||
price {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
featuredImage {
|
||||
...image
|
||||
}
|
||||
images(first: 20) {
|
||||
edges {
|
||||
node {
|
||||
...image
|
||||
}
|
||||
}
|
||||
}
|
||||
seo {
|
||||
...seo
|
||||
}
|
||||
tags
|
||||
updatedAt
|
||||
}
|
||||
${imageFragment}
|
||||
${seoFragment}
|
||||
`;
|
||||
|
||||
export default productFragment;
|
||||
8
lib/shopify/fragments/seo.ts
Normal file
8
lib/shopify/fragments/seo.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
const seoFragment = /* GraphQL */ `
|
||||
fragment seo on SEO {
|
||||
description
|
||||
title
|
||||
}
|
||||
`;
|
||||
|
||||
export default seoFragment;
|
||||
445
lib/shopify/index.ts
Normal file
445
lib/shopify/index.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
|
||||
import { isShopifyError } from 'lib/type-guards';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { 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 = `https://${process.env.SHOPIFY_STORE_DOMAIN!}`;
|
||||
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
|
||||
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
|
||||
|
||||
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
|
||||
|
||||
export async function shopifyFetch<T>({
|
||||
cache = 'force-cache',
|
||||
headers,
|
||||
query,
|
||||
tags,
|
||||
variables
|
||||
}: {
|
||||
cache?: RequestCache;
|
||||
headers?: HeadersInit;
|
||||
query: string;
|
||||
tags?: string[];
|
||||
variables?: ExtractVariables<T>;
|
||||
}): Promise<{ status: number; body: T } | never> {
|
||||
try {
|
||||
const result = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Shopify-Storefront-Access-Token': key,
|
||||
...headers
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(query && { query }),
|
||||
...(variables && { variables })
|
||||
}),
|
||||
cache,
|
||||
...(tags && { next: { tags } })
|
||||
});
|
||||
|
||||
const body = await result.json();
|
||||
|
||||
if (body.errors) {
|
||||
throw body.errors[0];
|
||||
}
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
body
|
||||
};
|
||||
} catch (e) {
|
||||
if (isShopifyError(e)) {
|
||||
throw {
|
||||
status: e.status || 500,
|
||||
message: e.message,
|
||||
query
|
||||
};
|
||||
}
|
||||
|
||||
throw {
|
||||
error: e,
|
||||
query
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const removeEdgesAndNodes = (array: Connection<any>) => {
|
||||
return array.edges.map((edge) => edge?.node);
|
||||
};
|
||||
|
||||
const reshapeCart = (cart: ShopifyCart): Cart => {
|
||||
if (!cart.cost?.totalTaxAmount) {
|
||||
cart.cost.totalTaxAmount = {
|
||||
amount: '0.0',
|
||||
currencyCode: 'USD'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cart,
|
||||
lines: removeEdgesAndNodes(cart.lines)
|
||||
};
|
||||
};
|
||||
|
||||
const reshapeCollection = (collection: ShopifyCollection): Collection | undefined => {
|
||||
if (!collection) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...collection,
|
||||
path: `/search/${collection.handle}`
|
||||
};
|
||||
};
|
||||
|
||||
const reshapeCollections = (collections: ShopifyCollection[]) => {
|
||||
const reshapedCollections = [];
|
||||
|
||||
for (const collection of collections) {
|
||||
if (collection) {
|
||||
const reshapedCollection = reshapeCollection(collection);
|
||||
|
||||
if (reshapedCollection) {
|
||||
reshapedCollections.push(reshapedCollection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reshapedCollections;
|
||||
};
|
||||
|
||||
const 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,
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
return reshapeCart(res.body.data.cartCreate.cart);
|
||||
}
|
||||
|
||||
export async function addToCart(
|
||||
cartId: string,
|
||||
lines: { merchandiseId: string; quantity: number }[]
|
||||
): Promise<Cart> {
|
||||
const res = await shopifyFetch<ShopifyAddToCartOperation>({
|
||||
query: addToCartMutation,
|
||||
variables: {
|
||||
cartId,
|
||||
lines
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
return reshapeCart(res.body.data.cartLinesAdd.cart);
|
||||
}
|
||||
|
||||
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
|
||||
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
|
||||
query: removeFromCartMutation,
|
||||
variables: {
|
||||
cartId,
|
||||
lineIds
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
return reshapeCart(res.body.data.cartLinesRemove.cart);
|
||||
}
|
||||
|
||||
export async function updateCart(
|
||||
cartId: string,
|
||||
lines: { id: string; merchandiseId: string; quantity: number }[]
|
||||
): Promise<Cart> {
|
||||
const res = await shopifyFetch<ShopifyUpdateCartOperation>({
|
||||
query: editCartItemsMutation,
|
||||
variables: {
|
||||
cartId,
|
||||
lines
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
return reshapeCart(res.body.data.cartLinesUpdate.cart);
|
||||
}
|
||||
|
||||
export async function getCart(cartId: string): Promise<Cart | undefined> {
|
||||
const res = await shopifyFetch<ShopifyCartOperation>({
|
||||
query: getCartQuery,
|
||||
variables: { cartId },
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
// 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> {
|
||||
const res = await shopifyFetch<ShopifyCollectionOperation>({
|
||||
query: getCollectionQuery,
|
||||
tags: [TAGS.collections],
|
||||
variables: {
|
||||
handle
|
||||
}
|
||||
});
|
||||
|
||||
return reshapeCollection(res.body.data.collection);
|
||||
}
|
||||
|
||||
export async function getCollectionProducts({
|
||||
collection,
|
||||
reverse,
|
||||
sortKey
|
||||
}: {
|
||||
collection: string;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
}): Promise<Product[]> {
|
||||
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
|
||||
query: getCollectionProductsQuery,
|
||||
tags: [TAGS.collections, TAGS.products],
|
||||
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[]> {
|
||||
const res = await shopifyFetch<ShopifyCollectionsOperation>({
|
||||
query: getCollectionsQuery,
|
||||
tags: [TAGS.collections]
|
||||
});
|
||||
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[]> {
|
||||
const res = await shopifyFetch<ShopifyMenuOperation>({
|
||||
query: getMenuQuery,
|
||||
tags: [TAGS.collections],
|
||||
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> {
|
||||
const res = await shopifyFetch<ShopifyProductOperation>({
|
||||
query: getProductQuery,
|
||||
tags: [TAGS.products],
|
||||
variables: {
|
||||
handle
|
||||
}
|
||||
});
|
||||
|
||||
return reshapeProduct(res.body.data.product, false);
|
||||
}
|
||||
|
||||
export async function getProductRecommendations(productId: string): Promise<Product[]> {
|
||||
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
|
||||
query: getProductRecommendationsQuery,
|
||||
tags: [TAGS.products],
|
||||
variables: {
|
||||
productId
|
||||
}
|
||||
});
|
||||
|
||||
return reshapeProducts(res.body.data.productRecommendations);
|
||||
}
|
||||
|
||||
export async function getProducts({
|
||||
query,
|
||||
reverse,
|
||||
sortKey
|
||||
}: {
|
||||
query?: string;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
}): Promise<Product[]> {
|
||||
const res = await shopifyFetch<ShopifyProductsOperation>({
|
||||
query: getProductsQuery,
|
||||
tags: [TAGS.products],
|
||||
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 = headers().get('x-shopify-topic') || 'unknown';
|
||||
const secret = req.nextUrl.searchParams.get('secret');
|
||||
const isCollectionUpdate = collectionWebhooks.includes(topic);
|
||||
const isProductUpdate = productWebhooks.includes(topic);
|
||||
|
||||
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
|
||||
console.error('Invalid revalidation secret.');
|
||||
return NextResponse.json({ status: 200 });
|
||||
}
|
||||
|
||||
if (!isCollectionUpdate && !isProductUpdate) {
|
||||
// We don't need to revalidate anything for any other topics.
|
||||
return NextResponse.json({ status: 200 });
|
||||
}
|
||||
|
||||
if (isCollectionUpdate) {
|
||||
revalidateTag(TAGS.collections);
|
||||
}
|
||||
|
||||
if (isProductUpdate) {
|
||||
revalidateTag(TAGS.products);
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
|
||||
}
|
||||
45
lib/shopify/mutations/cart.ts
Normal file
45
lib/shopify/mutations/cart.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import cartFragment from '../fragments/cart';
|
||||
|
||||
export const addToCartMutation = /* GraphQL */ `
|
||||
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
|
||||
cartLinesAdd(cartId: $cartId, lines: $lines) {
|
||||
cart {
|
||||
...cart
|
||||
}
|
||||
}
|
||||
}
|
||||
${cartFragment}
|
||||
`;
|
||||
|
||||
export const createCartMutation = /* GraphQL */ `
|
||||
mutation createCart($lineItems: [CartLineInput!]) {
|
||||
cartCreate(input: { lines: $lineItems }) {
|
||||
cart {
|
||||
...cart
|
||||
}
|
||||
}
|
||||
}
|
||||
${cartFragment}
|
||||
`;
|
||||
|
||||
export const editCartItemsMutation = /* GraphQL */ `
|
||||
mutation editCartItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
|
||||
cartLinesUpdate(cartId: $cartId, lines: $lines) {
|
||||
cart {
|
||||
...cart
|
||||
}
|
||||
}
|
||||
}
|
||||
${cartFragment}
|
||||
`;
|
||||
|
||||
export const removeFromCartMutation = /* GraphQL */ `
|
||||
mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) {
|
||||
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
|
||||
cart {
|
||||
...cart
|
||||
}
|
||||
}
|
||||
}
|
||||
${cartFragment}
|
||||
`;
|
||||
10
lib/shopify/queries/cart.ts
Normal file
10
lib/shopify/queries/cart.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import cartFragment from '../fragments/cart';
|
||||
|
||||
export const getCartQuery = /* GraphQL */ `
|
||||
query getCart($cartId: ID!) {
|
||||
cart(id: $cartId) {
|
||||
...cart
|
||||
}
|
||||
}
|
||||
${cartFragment}
|
||||
`;
|
||||
56
lib/shopify/queries/collection.ts
Normal file
56
lib/shopify/queries/collection.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import productFragment from '../fragments/product';
|
||||
import seoFragment from '../fragments/seo';
|
||||
|
||||
const collectionFragment = /* GraphQL */ `
|
||||
fragment collection on Collection {
|
||||
handle
|
||||
title
|
||||
description
|
||||
seo {
|
||||
...seo
|
||||
}
|
||||
updatedAt
|
||||
}
|
||||
${seoFragment}
|
||||
`;
|
||||
|
||||
export const getCollectionQuery = /* GraphQL */ `
|
||||
query getCollection($handle: String!) {
|
||||
collection(handle: $handle) {
|
||||
...collection
|
||||
}
|
||||
}
|
||||
${collectionFragment}
|
||||
`;
|
||||
|
||||
export const getCollectionsQuery = /* GraphQL */ `
|
||||
query getCollections {
|
||||
collections(first: 100, sortKey: TITLE) {
|
||||
edges {
|
||||
node {
|
||||
...collection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${collectionFragment}
|
||||
`;
|
||||
|
||||
export const getCollectionProductsQuery = /* GraphQL */ `
|
||||
query getCollectionProducts(
|
||||
$handle: String!
|
||||
$sortKey: ProductCollectionSortKeys
|
||||
$reverse: Boolean
|
||||
) {
|
||||
collection(handle: $handle) {
|
||||
products(sortKey: $sortKey, reverse: $reverse, first: 100) {
|
||||
edges {
|
||||
node {
|
||||
...product
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${productFragment}
|
||||
`;
|
||||
10
lib/shopify/queries/menu.ts
Normal file
10
lib/shopify/queries/menu.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const getMenuQuery = /* GraphQL */ `
|
||||
query getMenu($handle: String!) {
|
||||
menu(handle: $handle) {
|
||||
items {
|
||||
title
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
41
lib/shopify/queries/page.ts
Normal file
41
lib/shopify/queries/page.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import seoFragment from '../fragments/seo';
|
||||
|
||||
const pageFragment = /* GraphQL */ `
|
||||
fragment page on Page {
|
||||
... on Page {
|
||||
id
|
||||
title
|
||||
handle
|
||||
body
|
||||
bodySummary
|
||||
seo {
|
||||
...seo
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
${seoFragment}
|
||||
`;
|
||||
|
||||
export const getPageQuery = /* GraphQL */ `
|
||||
query getPage($handle: String!) {
|
||||
pageByHandle(handle: $handle) {
|
||||
...page
|
||||
}
|
||||
}
|
||||
${pageFragment}
|
||||
`;
|
||||
|
||||
export const getPagesQuery = /* GraphQL */ `
|
||||
query getPages {
|
||||
pages(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
...page
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${pageFragment}
|
||||
`;
|
||||
32
lib/shopify/queries/product.ts
Normal file
32
lib/shopify/queries/product.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import productFragment from '../fragments/product';
|
||||
|
||||
export const getProductQuery = /* GraphQL */ `
|
||||
query getProduct($handle: String!) {
|
||||
product(handle: $handle) {
|
||||
...product
|
||||
}
|
||||
}
|
||||
${productFragment}
|
||||
`;
|
||||
|
||||
export const getProductsQuery = /* GraphQL */ `
|
||||
query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) {
|
||||
products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
|
||||
edges {
|
||||
node {
|
||||
...product
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${productFragment}
|
||||
`;
|
||||
|
||||
export const getProductRecommendationsQuery = /* GraphQL */ `
|
||||
query getProductRecommendations($productId: ID!) {
|
||||
productRecommendations(productId: $productId) {
|
||||
...product
|
||||
}
|
||||
}
|
||||
${productFragment}
|
||||
`;
|
||||
265
lib/shopify/types.ts
Normal file
265
lib/shopify/types.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
export type Maybe<T> = T | null;
|
||||
|
||||
export type Connection<T> = {
|
||||
edges: Array<Edge<T>>;
|
||||
};
|
||||
|
||||
export type Edge<T> = {
|
||||
node: T;
|
||||
};
|
||||
|
||||
export type Cart = Omit<ShopifyCart, 'lines'> & {
|
||||
lines: CartItem[];
|
||||
};
|
||||
|
||||
export type CartItem = {
|
||||
id: string;
|
||||
quantity: number;
|
||||
cost: {
|
||||
totalAmount: Money;
|
||||
};
|
||||
merchandise: {
|
||||
id: string;
|
||||
title: string;
|
||||
selectedOptions: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
product: Product;
|
||||
};
|
||||
};
|
||||
|
||||
export type Collection = ShopifyCollection & {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type Image = {
|
||||
url: string;
|
||||
altText: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type Menu = {
|
||||
title: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type Money = {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
|
||||
export type Page = {
|
||||
id: string;
|
||||
title: string;
|
||||
handle: string;
|
||||
body: string;
|
||||
bodySummary: string;
|
||||
seo?: SEO;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Product = Omit<ShopifyProduct, 'variants' | 'images'> & {
|
||||
variants: ProductVariant[];
|
||||
images: Image[];
|
||||
};
|
||||
|
||||
export type ProductOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
values: string[];
|
||||
};
|
||||
|
||||
export type ProductVariant = {
|
||||
id: string;
|
||||
title: string;
|
||||
availableForSale: boolean;
|
||||
selectedOptions: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
price: Money;
|
||||
};
|
||||
|
||||
export type SEO = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ShopifyCart = {
|
||||
id: string;
|
||||
checkoutUrl: string;
|
||||
cost: {
|
||||
subtotalAmount: Money;
|
||||
totalAmount: Money;
|
||||
totalTaxAmount: Money;
|
||||
};
|
||||
lines: Connection<CartItem>;
|
||||
totalQuantity: number;
|
||||
};
|
||||
|
||||
export type ShopifyCollection = {
|
||||
handle: string;
|
||||
title: string;
|
||||
description: string;
|
||||
seo: SEO;
|
||||
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;
|
||||
};
|
||||
};
|
||||
26
lib/type-guards.ts
Normal file
26
lib/type-guards.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface ShopifyErrorLike {
|
||||
status: number;
|
||||
message: Error;
|
||||
}
|
||||
|
||||
export const isObject = (object: unknown): object is Record<string, unknown> => {
|
||||
return typeof object === 'object' && object !== null && !Array.isArray(object);
|
||||
};
|
||||
|
||||
export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
|
||||
if (!isObject(error)) return false;
|
||||
|
||||
if (error instanceof Error) return true;
|
||||
|
||||
return findError(error);
|
||||
};
|
||||
|
||||
function findError<T extends object>(error: T): boolean {
|
||||
if (Object.prototype.toString.call(error) === '[object Error]') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(error) as T | null;
|
||||
|
||||
return prototype === null ? false : findError(prototype);
|
||||
}
|
||||
8
lib/utils.ts
Normal file
8
lib/utils.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ReadonlyURLSearchParams } from 'next/navigation';
|
||||
|
||||
export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => {
|
||||
const paramsString = params.toString();
|
||||
const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;
|
||||
|
||||
return `${pathname}${queryString}`;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2020 Vercel, Inc.
|
||||
Copyright (c) 2023 Vercel, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
20
next.config.js
Normal file
20
next.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
eslint: {
|
||||
// Disabling on production builds because we're running checks on PRs via GitHub Actions.
|
||||
ignoreDuringBuilds: true
|
||||
},
|
||||
experimental: {
|
||||
serverActions: true
|
||||
},
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.shopify.com',
|
||||
pathname: '/s/files/**'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
62
package.json
62
package.json
@@ -1,23 +1,51 @@
|
||||
{
|
||||
"name": "commerce",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@8.2.0",
|
||||
"engines": {
|
||||
"node": ">=16",
|
||||
"pnpm": ">=7"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "turbo run build --filter=next-commerce...",
|
||||
"dev": "turbo run dev",
|
||||
"start": "turbo run start",
|
||||
"types": "turbo run types",
|
||||
"prettier-fix": "prettier --write ."
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint-staged": "lint-staged",
|
||||
"prettier": "prettier --write --ignore-unknown .",
|
||||
"prettier:check": "prettier --check --ignore-unknown .",
|
||||
"test": "pnpm lint && pnpm prettier:check"
|
||||
},
|
||||
"git": {
|
||||
"pre-commit": "lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "prettier --write --ignore-unknown"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"clsx": "^2.0.0",
|
||||
"next": "13.4.12",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "^8.0.1",
|
||||
"prettier": "^2.7.1",
|
||||
"turbo": "^1.4.6"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "turbo run lint"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@7.5.0"
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/node": "20.4.4",
|
||||
"@types/react": "18.2.16",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"@vercel/git-hooks": "^1.0.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-config-next": "^13.4.12",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-unicorn": "^48.0.0",
|
||||
"lint-staged": "^13.2.3",
|
||||
"postcss": "^8.4.27",
|
||||
"prettier": "^3.0.0",
|
||||
"prettier-plugin-tailwindcss": "^0.4.1",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "5.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
COMMERCE_PROVIDER=@vercel/commerce-bigcommerce
|
||||
|
||||
BIGCOMMERCE_STOREFRONT_API_URL=
|
||||
BIGCOMMERCE_STOREFRONT_API_TOKEN=
|
||||
BIGCOMMERCE_STORE_API_URL=
|
||||
BIGCOMMERCE_STORE_API_TOKEN=
|
||||
BIGCOMMERCE_STORE_API_CLIENT_ID=
|
||||
BIGCOMMERCE_CHANNEL_ID=
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
# Bigcommerce Provider
|
||||
|
||||
**Demo:** https://bigcommerce.demo.vercel.store/
|
||||
|
||||
With the deploy button below you'll be able to have a [BigCommerce](https://www.bigcommerce.com/) account and a store that works with this starter:
|
||||
|
||||
[](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-description=An%20all-in-one%20starter%20kit%20for%20high-performance%20e-commerce%20sites.&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&integration-ids=oac_MuWZiE4jtmQ2ejZQaQ7ncuDT)
|
||||
|
||||
If you already have a BigCommerce account and want to use your current store, then copy the `.env.template` file in this directory to `.env.local` in the main directory (which will be ignored by Git):
|
||||
|
||||
```bash
|
||||
cp packages/bigcommerce/.env.template .env.local
|
||||
```
|
||||
|
||||
Then, set the environment variables in `.env.local` to match the ones from your store.
|
||||
|
||||
## Contribute
|
||||
|
||||
Our commitment to Open Source can be found [here](https://vercel.com/oss).
|
||||
|
||||
If you find an issue with the provider or want a new feature, feel free to open a PR or [create a new issue](https://github.com/vercel/commerce/issues).
|
||||
|
||||
## Troubleshoot
|
||||
|
||||
<details>
|
||||
<summary>I already own a BigCommerce store. What should I do?</summary>
|
||||
<br>
|
||||
First thing you do is: <b>set your environment variables</b>
|
||||
<br>
|
||||
<br>
|
||||
.env.local
|
||||
|
||||
```sh
|
||||
BIGCOMMERCE_STOREFRONT_API_URL=<>
|
||||
BIGCOMMERCE_STOREFRONT_API_TOKEN=<>
|
||||
BIGCOMMERCE_STORE_API_URL=<>
|
||||
BIGCOMMERCE_STORE_API_TOKEN=<>
|
||||
BIGCOMMERCE_STORE_API_CLIENT_ID=<>
|
||||
BIGCOMMERCE_CHANNEL_ID=<>
|
||||
```
|
||||
|
||||
If your project was started with a "Deploy with Vercel" button, you can use Vercel's CLI to retrieve these credentials.
|
||||
|
||||
1. Install Vercel CLI: `npm i -g vercel`
|
||||
2. Link local instance with Vercel and Github accounts (creates .vercel file): `vercel link`
|
||||
3. Download your environment variables: `vercel env pull .env.local`
|
||||
|
||||
Next, you're free to customize the starter. More updates coming soon. Stay tuned.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>BigCommerce shows a Coming Soon page and requests a Preview Code</summary>
|
||||
<br>
|
||||
After Email confirmation, Checkout should be manually enabled through BigCommerce platform. Look for "Review & test your store" section through BigCommerce's dashboard.
|
||||
<br>
|
||||
<br>
|
||||
BigCommerce team has been notified and they plan to add more detailed about this subject.
|
||||
</details>
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"schema": {
|
||||
"https://buybutton.store/graphql": {
|
||||
"headers": {
|
||||
"Authorization": "Bearer xzy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"./src/api/**/*.ts": {
|
||||
"noRequire": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"generates": {
|
||||
"./schema.d.ts": {
|
||||
"plugins": ["typescript", "typescript-operations"]
|
||||
},
|
||||
"./schema.graphql": {
|
||||
"plugins": ["schema-ast"]
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"afterAllFileWrite": ["prettier --write"]
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
{
|
||||
"name": "@vercel/commerce-bigcommerce",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"release": "taskr release",
|
||||
"build": "taskr build",
|
||||
"dev": "taskr",
|
||||
"types": "tsc --emitDeclarationOnly",
|
||||
"generate:definitions": "node scripts/generate-definitions.js"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./*": [
|
||||
"./dist/*.js",
|
||||
"./dist/*/index.js"
|
||||
],
|
||||
"./next.config": "./dist/next.config.cjs"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*",
|
||||
"src/*/index"
|
||||
],
|
||||
"next.config": [
|
||||
"dist/next.config.d.cts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"publishConfig": {
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"dist/*.d.ts",
|
||||
"dist/*/index.d.ts"
|
||||
],
|
||||
"config": [
|
||||
"dist/next.config.d.cts"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@cfworker/uuid": "^1.12.4",
|
||||
"@tsndr/cloudflare-worker-jwt": "^2.1.0",
|
||||
"@vercel/commerce": "workspace:*",
|
||||
"cookie": "^0.4.1",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"js-cookie": "^3.0.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"uuidv4": "^6.2.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "^13",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@taskr/clear": "^1.1.0",
|
||||
"@taskr/esnext": "^1.1.0",
|
||||
"@taskr/watch": "^1.1.0",
|
||||
"@types/cookie": "^0.4.1",
|
||||
"@types/jsonwebtoken": "^8.5.7",
|
||||
"@types/lodash.debounce": "^4.0.6",
|
||||
"@types/node": "^17.0.8",
|
||||
"@types/node-fetch": "^2.6.2",
|
||||
"@types/react": "^18.0.14",
|
||||
"lint-staged": "^12.1.7",
|
||||
"next": "^13.0.6",
|
||||
"prettier": "^2.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"taskr": "^1.1.0",
|
||||
"taskr-swc": "^0.0.1",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*.{js,jsx,ts,tsx,json}": [
|
||||
"prettier --write",
|
||||
"git add"
|
||||
]
|
||||
}
|
||||
}
|
||||
2064
packages/bigcommerce/schema.d.ts
vendored
2064
packages/bigcommerce/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Generates definitions for REST API endpoints that are being
|
||||
* used by ../api using https://github.com/drwpow/swagger-to-ts
|
||||
*/
|
||||
const { readFileSync, promises } = require('fs')
|
||||
const path = require('path')
|
||||
const fetch = require('node-fetch')
|
||||
const swaggerToTS = require('@manifoldco/swagger-to-ts').default
|
||||
|
||||
async function getSchema(filename) {
|
||||
const url = `https://next-api.stoplight.io/projects/8433/files/${filename}`
|
||||
const res = await fetch(url)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Request failed with ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
const schemas = Object.entries({
|
||||
'../api/definitions/catalog.ts':
|
||||
'BigCommerce_Catalog_API.oas2.yml?ref=version%2F20.930',
|
||||
'../api/definitions/store-content.ts':
|
||||
'BigCommerce_Store_Content_API.oas2.yml?ref=version%2F20.930',
|
||||
'../api/definitions/wishlist.ts':
|
||||
'BigCommerce_Wishlist_API.oas2.yml?ref=version%2F20.930',
|
||||
// swagger-to-ts is not working for the schema of the cart API
|
||||
// '../api/definitions/cart.ts':
|
||||
// 'BigCommerce_Server_to_Server_Cart_API.oas2.yml',
|
||||
})
|
||||
|
||||
async function writeDefinitions() {
|
||||
const ops = schemas.map(async ([dest, filename]) => {
|
||||
const destination = path.join(__dirname, dest)
|
||||
const schema = await getSchema(filename)
|
||||
const definition = swaggerToTS(schema.content, {
|
||||
prettierConfig: 'package.json',
|
||||
})
|
||||
|
||||
await promises.writeFile(destination, definition)
|
||||
|
||||
console.log(`✔️ Added definitions for: ${dest}`)
|
||||
})
|
||||
|
||||
await Promise.all(ops)
|
||||
}
|
||||
|
||||
writeDefinitions()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,329 +0,0 @@
|
||||
/**
|
||||
* This file was auto-generated by swagger-to-ts.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface definitions {
|
||||
blogPost_Full: {
|
||||
/**
|
||||
* ID of this blog post. (READ-ONLY)
|
||||
*/
|
||||
id?: number
|
||||
} & definitions['blogPost_Base']
|
||||
addresses: {
|
||||
/**
|
||||
* Full URL of where the resource is located.
|
||||
*/
|
||||
url?: string
|
||||
/**
|
||||
* Resource being accessed.
|
||||
*/
|
||||
resource?: string
|
||||
}
|
||||
formField: {
|
||||
/**
|
||||
* Name of the form field
|
||||
*/
|
||||
name?: string
|
||||
/**
|
||||
* Value of the form field
|
||||
*/
|
||||
value?: string
|
||||
}
|
||||
page_Full: {
|
||||
/**
|
||||
* ID of the page.
|
||||
*/
|
||||
id?: number
|
||||
} & definitions['page_Base']
|
||||
redirect: {
|
||||
/**
|
||||
* Numeric ID of the redirect.
|
||||
*/
|
||||
id?: number
|
||||
/**
|
||||
* The path from which to redirect.
|
||||
*/
|
||||
path: string
|
||||
forward: definitions['forward']
|
||||
/**
|
||||
* URL of the redirect. READ-ONLY
|
||||
*/
|
||||
url?: string
|
||||
}
|
||||
forward: {
|
||||
/**
|
||||
* The type of redirect. If it is a `manual` redirect then type will always be manual. Dynamic redirects will have the type of the page. Such as product or category.
|
||||
*/
|
||||
type?: string
|
||||
/**
|
||||
* Reference of the redirect. Dynamic redirects will have the category or product number. Manual redirects will have the url that is being directed to.
|
||||
*/
|
||||
ref?: number
|
||||
}
|
||||
customer_Full: {
|
||||
/**
|
||||
* Unique numeric ID of this customer. This is a READ-ONLY field; do not set or modify its value in a POST or PUT request.
|
||||
*/
|
||||
id?: number
|
||||
/**
|
||||
* Not returned in any responses, but accepts up to two fields allowing you to set the customer’s password. If a password is not supplied, it is generated automatically. For further information about using this object, please see the Customers resource documentation.
|
||||
*/
|
||||
_authentication?: {
|
||||
force_reset?: string
|
||||
password?: string
|
||||
password_confirmation?: string
|
||||
}
|
||||
/**
|
||||
* The name of the company for which the customer works.
|
||||
*/
|
||||
company?: string
|
||||
/**
|
||||
* First name of the customer.
|
||||
*/
|
||||
first_name: string
|
||||
/**
|
||||
* Last name of the customer.
|
||||
*/
|
||||
last_name: string
|
||||
/**
|
||||
* Email address of the customer.
|
||||
*/
|
||||
email: string
|
||||
/**
|
||||
* Phone number of the customer.
|
||||
*/
|
||||
phone?: string
|
||||
/**
|
||||
* Date on which the customer registered from the storefront or was created in the control panel. This is a READ-ONLY field; do not set or modify its value in a POST or PUT request.
|
||||
*/
|
||||
date_created?: string
|
||||
/**
|
||||
* Date on which the customer updated their details in the storefront or was updated in the control panel. This is a READ-ONLY field; do not set or modify its value in a POST or PUT request.
|
||||
*/
|
||||
date_modified?: string
|
||||
/**
|
||||
* The amount of credit the customer has. (Float, Float as String, Integer)
|
||||
*/
|
||||
store_credit?: string
|
||||
/**
|
||||
* The customer’s IP address when they signed up.
|
||||
*/
|
||||
registration_ip_address?: string
|
||||
/**
|
||||
* The group to which the customer belongs.
|
||||
*/
|
||||
customer_group_id?: number
|
||||
/**
|
||||
* Store-owner notes on the customer.
|
||||
*/
|
||||
notes?: string
|
||||
/**
|
||||
* Used to identify customers who fall into special sales-tax categories – in particular, those who are fully or partially exempt from paying sales tax. Can be blank, or can contain a single AvaTax code. (The codes are case-sensitive.) Stores that subscribe to BigCommerce’s Avalara Premium integration will use this code to determine how/whether to apply sales tax. Does not affect sales-tax calculations for stores that do not subscribe to Avalara Premium.
|
||||
*/
|
||||
tax_exempt_category?: string
|
||||
/**
|
||||
* Records whether the customer would like to receive marketing content from this store. READ-ONLY.This is a READ-ONLY field; do not set or modify its value in a POST or PUT request.
|
||||
*/
|
||||
accepts_marketing?: boolean
|
||||
addresses?: definitions['addresses']
|
||||
/**
|
||||
* Array of custom fields. This is a READ-ONLY field; do not set or modify its value in a POST or PUT request.
|
||||
*/
|
||||
form_fields?: definitions['formField'][]
|
||||
/**
|
||||
* Force a password change on next login.
|
||||
*/
|
||||
reset_pass_on_login?: boolean
|
||||
}
|
||||
categoryAccessLevel: {
|
||||
/**
|
||||
* + `all` - Customers can access all categories
|
||||
* + `specific` - Customers can access a specific list of categories
|
||||
* + `none` - Customers are prevented from viewing any of the categories in this group.
|
||||
*/
|
||||
type?: 'all' | 'specific' | 'none'
|
||||
/**
|
||||
* Is an array of category IDs and should be supplied only if `type` is specific.
|
||||
*/
|
||||
categories?: string[]
|
||||
}
|
||||
timeZone: {
|
||||
/**
|
||||
* a string identifying the time zone, in the format: <Continent-name>/<City-name>.
|
||||
*/
|
||||
name?: string
|
||||
/**
|
||||
* a negative or positive number, identifying the offset from UTC/GMT, in seconds, during winter/standard time.
|
||||
*/
|
||||
raw_offset?: number
|
||||
/**
|
||||
* "-/+" offset from UTC/GMT, in seconds, during summer/daylight saving time.
|
||||
*/
|
||||
dst_offset?: number
|
||||
/**
|
||||
* a boolean indicating whether this time zone observes daylight saving time.
|
||||
*/
|
||||
dst_correction?: boolean
|
||||
date_format?: definitions['dateFormat']
|
||||
}
|
||||
count_Response: { count?: number }
|
||||
dateFormat: {
|
||||
/**
|
||||
* string that defines dates’ display format, in the pattern: M jS Y
|
||||
*/
|
||||
display?: string
|
||||
/**
|
||||
* string that defines the CSV export format for orders, customers, and products, in the pattern: M jS Y
|
||||
*/
|
||||
export?: string
|
||||
/**
|
||||
* string that defines dates’ extended-display format, in the pattern: M jS Y @ g:i A.
|
||||
*/
|
||||
extended_display?: string
|
||||
}
|
||||
blogTags: { tag?: string; post_ids?: number[] }[]
|
||||
blogPost_Base: {
|
||||
/**
|
||||
* Title of this blog post.
|
||||
*/
|
||||
title: string
|
||||
/**
|
||||
* URL for the public blog post.
|
||||
*/
|
||||
url?: string
|
||||
/**
|
||||
* URL to preview the blog post. (READ-ONLY)
|
||||
*/
|
||||
preview_url?: string
|
||||
/**
|
||||
* Text body of the blog post.
|
||||
*/
|
||||
body: string
|
||||
/**
|
||||
* Tags to characterize the blog post.
|
||||
*/
|
||||
tags?: string[]
|
||||
/**
|
||||
* Summary of the blog post. (READ-ONLY)
|
||||
*/
|
||||
summary?: string
|
||||
/**
|
||||
* Whether the blog post is published.
|
||||
*/
|
||||
is_published?: boolean
|
||||
published_date?: definitions['publishedDate']
|
||||
/**
|
||||
* Published date in `ISO 8601` format.
|
||||
*/
|
||||
published_date_iso8601?: string
|
||||
/**
|
||||
* Description text for this blog post’s `<meta/>` element.
|
||||
*/
|
||||
meta_description?: string
|
||||
/**
|
||||
* Keywords for this blog post’s `<meta/>` element.
|
||||
*/
|
||||
meta_keywords?: string
|
||||
/**
|
||||
* Name of the blog post’s author.
|
||||
*/
|
||||
author?: string
|
||||
/**
|
||||
* Local path to a thumbnail uploaded to `product_images/` via [WebDav](https://support.bigcommerce.com/s/article/File-Access-WebDAV).
|
||||
*/
|
||||
thumbnail_path?: string
|
||||
}
|
||||
publishedDate: { timezone_type?: string; date?: string; timezone?: string }
|
||||
/**
|
||||
* Not returned in any responses, but accepts up to two fields allowing you to set the customer’s password. If a password is not supplied, it is generated automatically. For further information about using this object, please see the Customers resource documentation.
|
||||
*/
|
||||
authentication: {
|
||||
force_reset?: string
|
||||
password?: string
|
||||
password_confirmation?: string
|
||||
}
|
||||
customer_Base: { [key: string]: any }
|
||||
page_Base: {
|
||||
/**
|
||||
* ID of any parent Web page.
|
||||
*/
|
||||
parent_id?: number
|
||||
/**
|
||||
* `page`: free-text page
|
||||
* `link`: link to another web address
|
||||
* `rss_feed`: syndicated content from an RSS feed
|
||||
* `contact_form`: When the store's contact form is used.
|
||||
*/
|
||||
type: 'page' | 'rss_feed' | 'contact_form' | 'raw' | 'link'
|
||||
/**
|
||||
* Where the page’s type is a contact form: object whose members are the fields enabled (in the control panel) for storefront display. Possible members are:`fullname`: full name of the customer submitting the form; `phone`: customer’s phone number, as submitted on the form; `companyname`: customer’s submitted company name; `orderno`: customer’s submitted order number; `rma`: customer’s submitted RMA (Return Merchandise Authorization) number.
|
||||
*/
|
||||
contact_fields?: string
|
||||
/**
|
||||
* Where the page’s type is a contact form: email address that receives messages sent via the form.
|
||||
*/
|
||||
email?: string
|
||||
/**
|
||||
* Page name, as displayed on the storefront.
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* Relative URL on the storefront for this page.
|
||||
*/
|
||||
url?: string
|
||||
/**
|
||||
* Description contained within this page’s `<meta/>` element.
|
||||
*/
|
||||
meta_description?: string
|
||||
/**
|
||||
* HTML or variable that populates this page’s `<body>` element, in default/desktop view. Required in POST if page type is `raw`.
|
||||
*/
|
||||
body: string
|
||||
/**
|
||||
* HTML to use for this page's body when viewed in the mobile template (deprecated).
|
||||
*/
|
||||
mobile_body?: string
|
||||
/**
|
||||
* If true, this page has a mobile version.
|
||||
*/
|
||||
has_mobile_version?: boolean
|
||||
/**
|
||||
* If true, this page appears in the storefront’s navigation menu.
|
||||
*/
|
||||
is_visible?: boolean
|
||||
/**
|
||||
* If true, this page is the storefront’s home page.
|
||||
*/
|
||||
is_homepage?: boolean
|
||||
/**
|
||||
* Text specified for this page’s `<title>` element. (If empty, the value of the name property is used.)
|
||||
*/
|
||||
meta_title?: string
|
||||
/**
|
||||
* Layout template for this page. This field is writable only for stores with a Blueprint theme applied.
|
||||
*/
|
||||
layout_file?: string
|
||||
/**
|
||||
* Order in which this page should display on the storefront. (Lower integers specify earlier display.)
|
||||
*/
|
||||
sort_order?: number
|
||||
/**
|
||||
* Comma-separated list of keywords that shoppers can use to locate this page when searching the store.
|
||||
*/
|
||||
search_keywords?: string
|
||||
/**
|
||||
* Comma-separated list of SEO-relevant keywords to include in the page’s `<meta/>` element.
|
||||
*/
|
||||
meta_keywords?: string
|
||||
/**
|
||||
* If page type is `rss_feed` the n this field is visisble. Required in POST required for `rss page` type.
|
||||
*/
|
||||
feed: string
|
||||
/**
|
||||
* If page type is `link` this field is returned. Required in POST to create a `link` page.
|
||||
*/
|
||||
link: string
|
||||
content_type?: 'application/json' | 'text/javascript' | 'text/html'
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* This file was auto-generated by swagger-to-ts.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface definitions {
|
||||
wishlist_Post: {
|
||||
/**
|
||||
* The customer id.
|
||||
*/
|
||||
customer_id: number
|
||||
/**
|
||||
* Whether the wishlist is available to the public.
|
||||
*/
|
||||
is_public?: boolean
|
||||
/**
|
||||
* The title of the wishlist.
|
||||
*/
|
||||
name?: string
|
||||
/**
|
||||
* Array of Wishlist items.
|
||||
*/
|
||||
items?: {
|
||||
/**
|
||||
* The ID of the product.
|
||||
*/
|
||||
product_id?: number
|
||||
/**
|
||||
* The variant ID of the product.
|
||||
*/
|
||||
variant_id?: number
|
||||
}[]
|
||||
}
|
||||
wishlist_Put: {
|
||||
/**
|
||||
* The customer id.
|
||||
*/
|
||||
customer_id: number
|
||||
/**
|
||||
* Whether the wishlist is available to the public.
|
||||
*/
|
||||
is_public?: boolean
|
||||
/**
|
||||
* The title of the wishlist.
|
||||
*/
|
||||
name?: string
|
||||
/**
|
||||
* Array of Wishlist items.
|
||||
*/
|
||||
items?: {
|
||||
/**
|
||||
* The ID of the item
|
||||
*/
|
||||
id?: number
|
||||
/**
|
||||
* The ID of the product.
|
||||
*/
|
||||
product_id?: number
|
||||
/**
|
||||
* The variant ID of the item.
|
||||
*/
|
||||
variant_id?: number
|
||||
}[]
|
||||
}
|
||||
wishlist_Full: {
|
||||
/**
|
||||
* Wishlist ID, provided after creating a wishlist with a POST.
|
||||
*/
|
||||
id?: number
|
||||
/**
|
||||
* The ID the customer to which the wishlist belongs.
|
||||
*/
|
||||
customer_id?: number
|
||||
/**
|
||||
* The Wishlist's name.
|
||||
*/
|
||||
name?: string
|
||||
/**
|
||||
* Whether the Wishlist is available to the public.
|
||||
*/
|
||||
is_public?: boolean
|
||||
/**
|
||||
* The token of the Wishlist. This is created internally within BigCommerce. The Wishlist ID is to be used for external apps. Read-Only
|
||||
*/
|
||||
token?: string
|
||||
/**
|
||||
* Array of Wishlist items
|
||||
*/
|
||||
items?: definitions['wishlistItem_Full'][]
|
||||
}
|
||||
wishlistItem_Full: {
|
||||
/**
|
||||
* The ID of the item
|
||||
*/
|
||||
id?: number
|
||||
/**
|
||||
* The ID of the product.
|
||||
*/
|
||||
product_id?: number
|
||||
/**
|
||||
* The variant ID of the item.
|
||||
*/
|
||||
variant_id?: number
|
||||
}
|
||||
wishlistItem_Post: {
|
||||
/**
|
||||
* The ID of the product.
|
||||
*/
|
||||
product_id?: number
|
||||
/**
|
||||
* The variant ID of the product.
|
||||
*/
|
||||
variant_id?: number
|
||||
}
|
||||
/**
|
||||
* Data about the response, including pagination and collection totals.
|
||||
*/
|
||||
pagination: {
|
||||
/**
|
||||
* Total number of items in the result set.
|
||||
*/
|
||||
total?: number
|
||||
/**
|
||||
* Total number of items in the collection response.
|
||||
*/
|
||||
count?: number
|
||||
/**
|
||||
* The amount of items returned in the collection per page, controlled by the limit parameter.
|
||||
*/
|
||||
per_page?: number
|
||||
/**
|
||||
* The page you are currently on within the collection.
|
||||
*/
|
||||
current_page?: number
|
||||
/**
|
||||
* The total number of pages in the collection.
|
||||
*/
|
||||
total_pages?: number
|
||||
}
|
||||
error: { status?: number; title?: string; type?: string }
|
||||
metaCollection: { pagination?: definitions['pagination'] }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user