This commit is contained in:
Matthew Mulford 2022-10-08 14:37:47 -05:00
parent 52aa9a3367
commit 470c40fd53
1361 changed files with 149286 additions and 9072 deletions

199
README.md
View File

@ -1 +1,198 @@
Developer DAO Merch Store
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-description=An%20all-in-one%20starter%20kit%20for%20high-performance%20e-commerce%20sites.&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&integration-ids=oac_MuWZiE4jtmQ2ejZQaQ7ncuDT,oac_9HSKtXld74NG0srzdxSiBGty&skippable-integrations=1&root-directory=site&build-command=cd%20..%20%26%26%20yarn%20build)
# 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)
Demo live at: [demo.vercel.store](https://demo.vercel.store/)
- Shopify Demo: https://shopify.vercel.store/
- Swell Demo: https://swell.vercel.store/
- BigCommerce Demo: https://bigcommerce.vercel.store/
- Vendure Demo: https://vendure.vercel.store
- Saleor Demo: https://saleor.vercel.store/
- Ordercloud Demo: https://ordercloud.vercel.store/
- Spree Demo: https://spree.vercel.store/
- Kibo Commerce Demo: https://kibocommerce.vercel.store/
- Commerce.js Demo: https://commercejs.vercel.store/
- SalesForce Cloud Commerce Demo: https://salesforce-cloud-commerce.vercel.store/
## Run minimal version locally
> To run a minimal version of Next.js Commerce you can start with the default local provider `@vercel/commerce-local` that has disabled all features (cart, auth) and use static files for the backend
```bash
pnpm install # run this command in root folder of the mono repo
pnpm dev
```
> If you encounter any problems while installing and running for the first time, please see the Troubleshoot section
## Features
- Performant by default
- SEO Ready
- Internationalization
- Responsive
- UI Components
- Theming
- Standardized Data Hooks
- Integrations - Integrate seamlessly with the most common ecommerce platforms.
- Dark Mode Support
## Integrations
Next.js Commerce integrates out-of-the-box with BigCommerce, Shopify, Swell, Saleor, Vendure, Spree and Commerce.js. We plan to support all major ecommerce backends.
## Considerations
- `packages/commerce` contains all types, helpers and functions to be used as 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 programatically.
- Each **provider** should add its corresponding `next.config.js` and `commerce.config.json` adding specific data related to the provider. For example in 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 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. Duplicate `site/.env.template` and rename it to `site/.env.local`
5. Add proper store values to `site/.env.local`
6. Run `pnpm dev` to build the packages and watch for code changes
7. Run `pnpm turbo run build` to check the build after your changes
## Work in progress
We're using Github Projects to keep track of issues in progress and todo's. Here is our [Board](https://github.com/vercel/commerce/projects/1)
People actively working on this project: @okbel, @lfades, @dominiksipowicz, @gbibeaul.
## Troubleshoot
<details>
<summary>I already own a BigCommerce store. What should I do?</summary>
<br>
First thing you do is: <b>set your environment variables</b>
<br>
<br>
.env.local
```sh
BIGCOMMERCE_STOREFRONT_API_URL=<>
BIGCOMMERCE_STOREFRONT_API_TOKEN=<>
BIGCOMMERCE_STORE_API_URL=<>
BIGCOMMERCE_STORE_API_TOKEN=<>
BIGCOMMERCE_STORE_API_CLIENT_ID=<>
BIGCOMMERCE_CHANNEL_ID=<>
```
If your project was started with a "Deploy with Vercel" button, you can use Vercel's CLI to retrieve these credentials.
1. Install Vercel CLI: `npm i -g vercel`
2. Link local instance with Vercel and Github accounts (creates .vercel file): `vercel link`
3. Download your environment variables: `vercel env pull .env.local`
Next, you're free to customize the starter. More updates coming soon. Stay tuned..
</details>
<details>
<summary>BigCommerce shows a Coming Soon page and requests a Preview Code</summary>
<br>
After Email confirmation, Checkout should be manually enabled through BigCommerce platform. Look for "Review & test your store" section through BigCommerce's dashboard.
<br>
<br>
BigCommerce team has been notified and they plan to add more details about this subject.
</details>
<details>
<summary>When run locally I get `Error: Cannot find module '...@vercel/commerce/dist/config'`</summary>
```bash
commerce/site
yarn dev
yarn run v1.22.17
$ next dev
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
info - Loaded env from /commerce/site/.env.local
error - Failed to load next.config.js, see more info here https://nextjs.org/docs/messages/next-config-error
Error: Cannot find module '/Users/dom/work/vercel/commerce/node_modules/@vercel/commerce/dist/config.cjs'
at createEsmNotFoundErr (node:internal/modules/cjs/loader:960:15)
at finalizeEsmResolution (node:internal/modules/cjs/loader:953:15)
at resolveExports (node:internal/modules/cjs/loader:482:14)
at Function.Module._findPath (node:internal/modules/cjs/loader:522:31)
at Function.Module._resolveFilename (node:internal/modules/cjs/loader:919:27)
at Function.mod._resolveFilename (/Users/dom/work/vercel/commerce/node_modules/next/dist/build/webpack/require-hook.js:179:28)
at Function.Module._load (node:internal/modules/cjs/loader:778:27)
at Module.require (node:internal/modules/cjs/loader:1005:19)
at require (node:internal/modules/cjs/helpers:102:18)
at Object.<anonymous> (/Users/dom/work/vercel/commerce/site/commerce-config.js:9:14) {
code: 'MODULE_NOT_FOUND',
path: '/Users/dom/work/vercel/commerce/node_modules/@vercel/commerce/package.json'
}
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
```
The error usually occurs when running `pnpm dev` inside of the `/site/` folder after installing a fresh repository.
In order to fix this, run `pnpm dev` in the monorepo root folder first.
> Using `pnpm dev` from the root is recommended for developing, which will run watch mode on all packages.
</details>

View File

@ -1,24 +0,0 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
// TODO Add Scope Enum Here
// 'scope-enum': [2, 'always', ['yourscope', 'yourscope']],
'type-enum': [
2,
'always',
[
'feat',
'fix',
'docs',
'chore',
'style',
'refactor',
'ci',
'test',
'perf',
'revert',
'vercel',
],
],
},
};

View File

@ -1,29 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const nextJest = require('next/jest');
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
});
// Add any custom config to be passed to Jest
const customJestConfig = {
// Add more setup options before each test is run
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
// if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
moduleDirectories: ['node_modules', '<rootDir>/'],
testEnvironment: 'jest-environment-jsdom',
/**
* Absolute imports and Module Path Aliases
*/
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^~/(.*)$': '<rootDir>/public/$1',
},
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);

View File

@ -1 +0,0 @@
import '@testing-library/jest-dom/extend-expect';

21
license.md Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2020 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
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,13 +0,0 @@
/**
* @type {import('next-sitemap').IConfig}
* @see https://github.com/iamvishnusankar/next-sitemap#readme
*/
module.exports = {
// !STARTERCONF Change the siteUrl
/** Without additional '/' on the end, e.g. https://developerdao.com */
siteUrl: 'https://merch.developerdao.com',
generateRobotsTxt: true,
robotsTxtOptions: {
policies: [{ userAgent: '*', allow: '/' }],
},
};

View File

@ -1,8 +0,0 @@
/** @type {import('next').NextConfig} */
module.exports = {
eslint: {
dirs: ['src'],
},
reactStrictMode: true,
};

View File

@ -1,66 +1,23 @@
{
"name": "ts-nextjs-tailwind-starter",
"version": "0.1.0",
"name": "commerce",
"license": "MIT",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "eslint src --fix && yarn format",
"lint:strict": "eslint --max-warnings=0 src",
"typecheck": "tsc --noEmit --incremental false",
"test:watch": "jest --watch",
"test": "jest",
"format": "prettier -w .",
"format:check": "prettier -c .",
"postbuild": "next-sitemap --config next-sitemap.config.js",
"prepare": "husky install"
},
"dependencies": {
"@headlessui/react": "^1.7.0",
"@heroicons/react": "^2.0.10",
"@tailwindcss/aspect-ratio": "^0.4.2",
"clsx": "^1.2.1",
"next": "^12.2.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.4.0",
"tailwind-merge": "^1.6.0"
"build": "turbo run build --filter=next-commerce...",
"dev": "turbo run dev",
"start": "turbo run start",
"types": "turbo run types",
"prettier-fix": "prettier --write ."
},
"devDependencies": {
"@commitlint/cli": "^16.3.0",
"@commitlint/config-conventional": "^16.2.4",
"@svgr/webpack": "^6.3.1",
"@tailwindcss/forms": "^0.5.3",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.3.0",
"@types/react": "^18.0.18",
"@typescript-eslint/eslint-plugin": "^5.36.1",
"@typescript-eslint/parser": "^5.36.1",
"autoprefixer": "^10.4.8",
"eslint": "^8.23.0",
"eslint-config-next": "^12.2.5",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-unused-imports": "^2.0.0",
"husky": "^7.0.4",
"jest": "^27.5.1",
"lint-staged": "^12.5.0",
"next-sitemap": "^2.5.28",
"postcss": "^8.4.16",
"husky": "^8.0.1",
"prettier": "^2.7.1",
"prettier-plugin-tailwindcss": "^0.1.13",
"tailwindcss": "^3.1.8",
"typescript": "^4.8.2"
"turbo": "^1.4.6"
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx}": [
"eslint --max-warnings=0",
"prettier -w"
],
"**/*.{json,css,scss,md}": [
"prettier -w"
]
}
"husky": {
"hooks": {
"pre-commit": "turbo run lint"
}
},
"packageManager": "pnpm@7.5.0"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,89 @@
{
"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": {
"@vercel/commerce": "workspace:*",
"@vercel/fetch": "^6.2.0",
"cookie": "^0.4.1",
"immutability-helper": "^3.1.1",
"jsonwebtoken": "^8.5.1",
"lodash.debounce": "^4.0.8",
"uuidv4": "^6.2.12",
"node-fetch": "^2.6.7"
},
"peerDependencies": {
"next": "^12",
"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/react": "^18.0.14",
"@types/node-fetch": "^2.6.2",
"lint-staged": "^12.1.7",
"next": "^12.0.8",
"prettier": "^2.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"taskr": "^1.1.0",
"taskr-swc": "^0.0.1",
"typescript": "^4.7.4"
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json}": [
"prettier --write",
"git add"
]
}
}

2064
packages/bigcommerce/schema.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,329 @@
/**
* This file was auto-generated by swagger-to-ts.
* Do not make direct changes to the file.
*/
export interface definitions {
blogPost_Full: {
/**
* ID of this blog post. (READ-ONLY)
*/
id?: number
} & definitions['blogPost_Base']
addresses: {
/**
* Full URL of where the resource is located.
*/
url?: string
/**
* Resource being accessed.
*/
resource?: string
}
formField: {
/**
* Name of the form field
*/
name?: string
/**
* Value of the form field
*/
value?: string
}
page_Full: {
/**
* ID of the page.
*/
id?: number
} & definitions['page_Base']
redirect: {
/**
* Numeric ID of the redirect.
*/
id?: number
/**
* The path from which to redirect.
*/
path: string
forward: definitions['forward']
/**
* URL of the redirect. READ-ONLY
*/
url?: string
}
forward: {
/**
* The type of redirect. If it is a `manual` redirect then type will always be manual. Dynamic redirects will have the type of the page. Such as product or category.
*/
type?: string
/**
* Reference of the redirect. Dynamic redirects will have the category or product number. Manual redirects will have the url that is being directed to.
*/
ref?: number
}
customer_Full: {
/**
* Unique numeric ID of this customer. This is a READ-ONLY field; do not set or modify its value in a POST or PUT request.
*/
id?: number
/**
* Not returned in any responses, but accepts up to two fields allowing you to set the customers 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 customers 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 BigCommerces 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 posts `<meta/>` element.
*/
meta_description?: string
/**
* Keywords for this blog posts `<meta/>` element.
*/
meta_keywords?: string
/**
* Name of the blog posts 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 customers 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 pages 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`: customers phone number, as submitted on the form; `companyname`: customers submitted company name; `orderno`: customers submitted order number; `rma`: customers submitted RMA (Return Merchandise Authorization) number.
*/
contact_fields?: string
/**
* Where the pages 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 pages `<meta/>` element.
*/
meta_description?: string
/**
* HTML or variable that populates this pages `<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 storefronts navigation menu.
*/
is_visible?: boolean
/**
* If true, this page is the storefronts home page.
*/
is_homepage?: boolean
/**
* Text specified for this pages `<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 pages `<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'
}
}

View File

@ -0,0 +1,142 @@
/**
* This file was auto-generated by swagger-to-ts.
* Do not make direct changes to the file.
*/
export interface definitions {
wishlist_Post: {
/**
* The customer id.
*/
customer_id: number
/**
* Whether the wishlist is available to the public.
*/
is_public?: boolean
/**
* The title of the wishlist.
*/
name?: string
/**
* Array of Wishlist items.
*/
items?: {
/**
* The ID of the product.
*/
product_id?: number
/**
* The variant ID of the product.
*/
variant_id?: number
}[]
}
wishlist_Put: {
/**
* The customer id.
*/
customer_id: number
/**
* Whether the wishlist is available to the public.
*/
is_public?: boolean
/**
* The title of the wishlist.
*/
name?: string
/**
* Array of Wishlist items.
*/
items?: {
/**
* The ID of the item
*/
id?: number
/**
* The ID of the product.
*/
product_id?: number
/**
* The variant ID of the item.
*/
variant_id?: number
}[]
}
wishlist_Full: {
/**
* Wishlist ID, provided after creating a wishlist with a POST.
*/
id?: number
/**
* The ID the customer to which the wishlist belongs.
*/
customer_id?: number
/**
* The Wishlist's name.
*/
name?: string
/**
* Whether the Wishlist is available to the public.
*/
is_public?: boolean
/**
* The token of the Wishlist. This is created internally within BigCommerce. The Wishlist ID is to be used for external apps. Read-Only
*/
token?: string
/**
* Array of Wishlist items
*/
items?: definitions['wishlistItem_Full'][]
}
wishlistItem_Full: {
/**
* The ID of the item
*/
id?: number
/**
* The ID of the product.
*/
product_id?: number
/**
* The variant ID of the item.
*/
variant_id?: number
}
wishlistItem_Post: {
/**
* The ID of the product.
*/
product_id?: number
/**
* The variant ID of the product.
*/
variant_id?: number
}
/**
* Data about the response, including pagination and collection totals.
*/
pagination: {
/**
* Total number of items in the result set.
*/
total?: number
/**
* Total number of items in the collection response.
*/
count?: number
/**
* The amount of items returned in the collection per page, controlled by the limit parameter.
*/
per_page?: number
/**
* The page you are currently on within the collection.
*/
current_page?: number
/**
* The total number of pages in the collection.
*/
total_pages?: number
}
error: { status?: number; title?: string; type?: string }
metaCollection: { pagination?: definitions['pagination'] }
}

View File

@ -0,0 +1,47 @@
// @ts-nocheck
import { normalizeCart } from '../../../lib/normalize'
import { parseCartItem } from '../../utils/parse-item'
import getCartCookie from '../../utils/get-cart-cookie'
import type { CartEndpoint } from '.'
const addItem: CartEndpoint['handlers']['addItem'] = async ({
res,
body: { cartId, item },
config,
}) => {
if (!item) {
return res.status(400).json({
data: null,
errors: [{ message: 'Missing item' }],
})
}
if (!item.quantity) item.quantity = 1
const options = {
method: 'POST',
body: JSON.stringify({
line_items: [parseCartItem(item)],
...(!cartId && config.storeChannelId
? { channel_id: config.storeChannelId }
: {}),
}),
}
const { data } = cartId
? await config.storeApiFetch(
`/v3/carts/${cartId}/items?include=line_items.physical_items.options,line_items.digital_items.options`,
options
)
: await config.storeApiFetch(
'/v3/carts?include=line_items.physical_items.options,line_items.digital_items.options',
options
)
// Create or update the cart cookie
res.setHeader(
'Set-Cookie',
getCartCookie(config.cartCookie, data.id, config.cartCookieMaxAge)
)
res.status(200).json({ data: normalizeCart(data) })
}
export default addItem

View File

@ -0,0 +1,36 @@
// @ts-nocheck
import { normalizeCart } from '../../../lib/normalize'
import { BigcommerceApiError } from '../../utils/errors'
import getCartCookie from '../../utils/get-cart-cookie'
import type { BigcommerceCart } from '../../../types/cart'
import type { CartEndpoint } from '.'
// Return current cart info
const getCart: CartEndpoint['handlers']['getCart'] = async ({
res,
body: { cartId },
config,
}) => {
let result: { data?: BigcommerceCart } = {}
if (cartId) {
try {
result = await config.storeApiFetch(
`/v3/carts/${cartId}?include=line_items.physical_items.options,line_items.digital_items.options`
)
} catch (error) {
if (error instanceof BigcommerceApiError && error.status === 404) {
// Remove the cookie if it exists but the cart wasn't found
res.setHeader('Set-Cookie', getCartCookie(config.cartCookie))
} else {
throw error
}
}
}
res.status(200).json({
data: result.data ? normalizeCart(result.data) : null,
})
}
export default getCart

View File

@ -0,0 +1,26 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import cartEndpoint from '@vercel/commerce/api/endpoints/cart'
import type { CartSchema } from '../../../types/cart'
import type { BigcommerceAPI } from '../..'
import getCart from './get-cart'
import addItem from './add-item'
import updateItem from './update-item'
import removeItem from './remove-item'
export type CartAPI = GetAPISchema<BigcommerceAPI, CartSchema>
export type CartEndpoint = CartAPI['endpoint']
export const handlers: CartEndpoint['handlers'] = {
getCart,
addItem,
updateItem,
removeItem,
}
const cartApi = createEndpoint<CartAPI>({
handler: cartEndpoint,
handlers,
})
export default cartApi

View File

@ -0,0 +1,34 @@
import { normalizeCart } from '../../../lib/normalize'
import getCartCookie from '../../utils/get-cart-cookie'
import type { CartEndpoint } from '.'
const removeItem: CartEndpoint['handlers']['removeItem'] = async ({
res,
body: { cartId, itemId },
config,
}) => {
if (!cartId || !itemId) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
const result = await config.storeApiFetch<{ data: any } | null>(
`/v3/carts/${cartId}/items/${itemId}?include=line_items.physical_items.options`,
{ method: 'DELETE' }
)
const data = result?.data ?? null
res.setHeader(
'Set-Cookie',
data
? // Update the cart cookie
getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge)
: // Remove the cart cookie if the cart was removed (empty items)
getCartCookie(config.cartCookie)
)
res.status(200).json({ data: data && normalizeCart(data) })
}
export default removeItem

View File

@ -0,0 +1,36 @@
import { normalizeCart } from '../../../lib/normalize'
import { parseCartItem } from '../../utils/parse-item'
import getCartCookie from '../../utils/get-cart-cookie'
import type { CartEndpoint } from '.'
const updateItem: CartEndpoint['handlers']['updateItem'] = async ({
res,
body: { cartId, itemId, item },
config,
}) => {
if (!cartId || !itemId || !item) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
const { data } = await config.storeApiFetch(
`/v3/carts/${cartId}/items/${itemId}?include=line_items.physical_items.options`,
{
method: 'PUT',
body: JSON.stringify({
line_item: parseCartItem(item),
}),
}
)
// Update the cart cookie
res.setHeader(
'Set-Cookie',
getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge)
)
res.status(200).json({ data: normalizeCart(data) })
}
export default updateItem

View File

@ -0,0 +1,79 @@
import { Product } from '@vercel/commerce/types/product'
import { ProductsEndpoint } from '.'
const SORT: { [key: string]: string | undefined } = {
latest: 'id',
trending: 'total_sold',
price: 'price',
}
const LIMIT = 12
// Return current cart info
const getProducts: ProductsEndpoint['handlers']['getProducts'] = async ({
res,
body: { search, categoryId, brandId, sort },
config,
commerce,
}) => {
// Use a dummy base as we only care about the relative path
const url = new URL('/v3/catalog/products', 'http://a')
url.searchParams.set('is_visible', 'true')
url.searchParams.set('limit', String(LIMIT))
if (search) url.searchParams.set('keyword', search)
if (categoryId && Number.isInteger(Number(categoryId)))
url.searchParams.set('categories:in', String(categoryId))
if (brandId && Number.isInteger(Number(brandId)))
url.searchParams.set('brand_id', String(brandId))
if (sort) {
const [_sort, direction] = sort.split('-')
const sortValue = SORT[_sort]
if (sortValue && direction) {
url.searchParams.set('sort', sortValue)
url.searchParams.set('direction', direction)
}
}
// We only want the id of each product
url.searchParams.set('include_fields', 'id')
const { data } = await config.storeApiFetch<{ data: { id: number }[] }>(
url.pathname + url.search
)
const ids = data.map((p) => String(p.id))
const found = ids.length > 0
// We want the GraphQL version of each product
const graphqlData = await commerce.getAllProducts({
variables: { first: LIMIT, ids },
config,
})
// Put the products in an object that we can use to get them by id
const productsById = graphqlData.products.reduce<{
[k: string]: Product
}>((prods, p) => {
prods[Number(p.id)] = p
return prods
}, {})
const products: Product[] = found ? [] : graphqlData.products
// Populate the products array with the graphql products, in the order
// assigned by the list of entity ids
ids.forEach((id) => {
const product = productsById[id]
if (product) products.push(product)
})
res.status(200).json({ data: { products, found } })
}
export default getProducts

View File

@ -0,0 +1,18 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import productsEndpoint from '@vercel/commerce/api/endpoints/catalog/products'
import type { ProductsSchema } from '../../../../types/product'
import type { BigcommerceAPI } from '../../..'
import getProducts from './get-products'
export type ProductsAPI = GetAPISchema<BigcommerceAPI, ProductsSchema>
export type ProductsEndpoint = ProductsAPI['endpoint']
export const handlers: ProductsEndpoint['handlers'] = { getProducts }
const productsApi = createEndpoint<ProductsAPI>({
handler: productsEndpoint,
handlers,
})
export default productsApi

View File

@ -0,0 +1,90 @@
import type { CheckoutEndpoint } from '.'
import getCustomerId from '../../utils/get-customer-id'
import jwt from 'jsonwebtoken'
import { uuid } from 'uuidv4'
const fullCheckout = true
const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({
req,
res,
config,
}) => {
const { cookies } = req
const cartId = cookies[config.cartCookie]
const customerToken = cookies[config.customerCookie]
if (!cartId) {
res.redirect('/cart')
return
}
const { data } = await config.storeApiFetch(
`/v3/carts/${cartId}/redirect_urls`,
{
method: 'POST',
}
)
const customerId =
customerToken && (await getCustomerId({ customerToken, config }))
//if there is a customer create a jwt token
if (!customerId) {
if (fullCheckout) {
res.redirect(data.checkout_url)
return
}
} else {
const dateCreated = Math.round(new Date().getTime() / 1000)
const payload = {
iss: config.storeApiClientId,
iat: dateCreated,
jti: uuid(),
operation: 'customer_login',
store_hash: config.storeHash,
customer_id: customerId,
channel_id: config.storeChannelId,
redirect_to: data.checkout_url.replace(config.storeUrl, ""),
}
let token = jwt.sign(payload, config.storeApiClientSecret!, {
algorithm: 'HS256',
})
let checkouturl = `${config.storeUrl}/login/token/${token}`
console.log('checkouturl', checkouturl)
if (fullCheckout) {
res.redirect(checkouturl)
return
}
}
// TODO: make the embedded checkout work too!
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Checkout</title>
<script src="https://checkout-sdk.bigcommerce.com/v1/loader.js"></script>
<script>
window.onload = function() {
checkoutKitLoader.load('checkout-sdk').then(function (service) {
service.embedCheckout({
containerId: 'checkout',
url: '${data.embedded_checkout_url}'
});
});
}
</script>
</head>
<body>
<div id="checkout"></div>
</body>
</html>
`
res.status(200)
res.setHeader('Content-Type', 'text/html')
res.write(html)
res.end()
}
export default getCheckout

View File

@ -0,0 +1,18 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import checkoutEndpoint from '@vercel/commerce/api/endpoints/checkout'
import type { CheckoutSchema } from '../../../types/checkout'
import type { BigcommerceAPI } from '../..'
import getCheckout from './get-checkout'
export type CheckoutAPI = GetAPISchema<BigcommerceAPI, CheckoutSchema>
export type CheckoutEndpoint = CheckoutAPI['endpoint']
export const handlers: CheckoutEndpoint['handlers'] = { getCheckout }
const checkoutApi = createEndpoint<CheckoutAPI>({
handler: checkoutEndpoint,
handlers,
})
export default checkoutApi

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1,56 @@
import type { GetLoggedInCustomerQuery } from '../../../../schema'
import type { CustomerEndpoint } from '.'
export const getLoggedInCustomerQuery = /* GraphQL */ `
query getLoggedInCustomer {
customer {
entityId
firstName
lastName
email
company
customerGroupId
notes
phone
addressCount
attributeCount
storeCredit {
value
currencyCode
}
}
}
`
export type Customer = NonNullable<GetLoggedInCustomerQuery['customer']>
const getLoggedInCustomer: CustomerEndpoint['handlers']['getLoggedInCustomer'] =
async ({ req, res, config }) => {
const token = req.cookies[config.customerCookie]
if (token) {
const { data } = await config.fetch<GetLoggedInCustomerQuery>(
getLoggedInCustomerQuery,
undefined,
{
headers: {
cookie: `${config.customerCookie}=${token}`,
},
}
)
const { customer } = data
if (!customer) {
return res.status(400).json({
data: null,
errors: [{ message: 'Customer not found', code: 'not_found' }],
})
}
return res.status(200).json({ data: { customer } })
}
res.status(200).json({ data: null })
}
export default getLoggedInCustomer

View File

@ -0,0 +1,18 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import customerEndpoint from '@vercel/commerce/api/endpoints/customer'
import type { CustomerSchema } from '../../../types/customer'
import type { BigcommerceAPI } from '../..'
import getLoggedInCustomer from './get-logged-in-customer'
export type CustomerAPI = GetAPISchema<BigcommerceAPI, CustomerSchema>
export type CustomerEndpoint = CustomerAPI['endpoint']
export const handlers: CustomerEndpoint['handlers'] = { getLoggedInCustomer }
const customerApi = createEndpoint<CustomerAPI>({
handler: customerEndpoint,
handlers,
})
export default customerApi

View File

@ -0,0 +1,18 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import loginEndpoint from '@vercel/commerce/api/endpoints/login'
import type { LoginSchema } from '../../../types/login'
import type { BigcommerceAPI } from '../..'
import login from './login'
export type LoginAPI = GetAPISchema<BigcommerceAPI, LoginSchema>
export type LoginEndpoint = LoginAPI['endpoint']
export const handlers: LoginEndpoint['handlers'] = { login }
const loginApi = createEndpoint<LoginAPI>({
handler: loginEndpoint,
handlers,
})
export default loginApi

View File

@ -0,0 +1,49 @@
import { FetcherError } from '@vercel/commerce/utils/errors'
import type { LoginEndpoint } from '.'
const invalidCredentials = /invalid credentials/i
const login: LoginEndpoint['handlers']['login'] = async ({
res,
body: { email, password },
config,
commerce,
}) => {
// TODO: Add proper validations with something like Ajv
if (!(email && password)) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
// TODO: validate the password and email
// Passwords must be at least 7 characters and contain both alphabetic
// and numeric characters.
try {
await commerce.login({ variables: { email, password }, config, res })
} catch (error) {
// Check if the email and password didn't match an existing account
if (
error instanceof FetcherError &&
invalidCredentials.test(error.message)
) {
return res.status(401).json({
data: null,
errors: [
{
message:
'Cannot find an account that matches the provided credentials',
code: 'invalid_credentials',
},
],
})
}
throw error
}
res.status(200).json({ data: null })
}
export default login

View File

@ -0,0 +1,18 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import logoutEndpoint from '@vercel/commerce/api/endpoints/logout'
import type { LogoutSchema } from '../../../types/logout'
import type { BigcommerceAPI } from '../..'
import logout from './logout'
export type LogoutAPI = GetAPISchema<BigcommerceAPI, LogoutSchema>
export type LogoutEndpoint = LogoutAPI['endpoint']
export const handlers: LogoutEndpoint['handlers'] = { logout }
const logoutApi = createEndpoint<LogoutAPI>({
handler: logoutEndpoint,
handlers,
})
export default logoutApi

View File

@ -0,0 +1,23 @@
import { serialize } from 'cookie'
import type { LogoutEndpoint } from '.'
const logout: LogoutEndpoint['handlers']['logout'] = async ({
res,
body: { redirectTo },
config,
}) => {
// Remove the cookie
res.setHeader(
'Set-Cookie',
serialize(config.customerCookie, '', { maxAge: -1, path: '/' })
)
// Only allow redirects to a relative URL
if (redirectTo?.startsWith('/')) {
res.redirect(redirectTo)
} else {
res.status(200).json({ data: null })
}
}
export default logout

View File

@ -0,0 +1,18 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import signupEndpoint from '@vercel/commerce/api/endpoints/signup'
import type { SignupSchema } from '../../../types/signup'
import type { BigcommerceAPI } from '../..'
import signup from './signup'
export type SignupAPI = GetAPISchema<BigcommerceAPI, SignupSchema>
export type SignupEndpoint = SignupAPI['endpoint']
export const handlers: SignupEndpoint['handlers'] = { signup }
const singupApi = createEndpoint<SignupAPI>({
handler: signupEndpoint,
handlers,
})
export default singupApi

View File

@ -0,0 +1,62 @@
import { BigcommerceApiError } from '../../utils/errors'
import type { SignupEndpoint } from '.'
const signup: SignupEndpoint['handlers']['signup'] = async ({
res,
body: { firstName, lastName, email, password },
config,
commerce,
}) => {
// TODO: Add proper validations with something like Ajv
if (!(firstName && lastName && email && password)) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
// TODO: validate the password and email
// Passwords must be at least 7 characters and contain both alphabetic
// and numeric characters.
try {
await config.storeApiFetch('/v3/customers', {
method: 'POST',
body: JSON.stringify([
{
first_name: firstName,
last_name: lastName,
email,
authentication: {
new_password: password,
},
},
]),
})
} catch (error) {
if (error instanceof BigcommerceApiError && error.status === 422) {
const hasEmailError = '0.email' in error.data?.errors
// If there's an error with the email, it most likely means it's duplicated
if (hasEmailError) {
return res.status(400).json({
data: null,
errors: [
{
message: 'The email is already in use',
code: 'duplicated_email',
},
],
})
}
}
throw error
}
// Login the customer right after creating it
await commerce.login({ variables: { email, password }, res, config })
res.status(200).json({ data: null })
}
export default signup

View File

@ -0,0 +1,67 @@
import getCustomerWishlist from '../../operations/get-customer-wishlist'
import { parseWishlistItem } from '../../utils/parse-item'
import getCustomerId from '../../utils/get-customer-id'
import type { WishlistEndpoint } from '.'
const addItem: WishlistEndpoint['handlers']['addItem'] = async ({
res,
body: { customerToken, item },
config,
commerce,
}) => {
if (!item) {
return res.status(400).json({
data: null,
errors: [{ message: 'Missing item' }],
})
}
try {
const customerId =
customerToken && (await getCustomerId({ customerToken, config }))
if (!customerId) {
throw new Error('Invalid request. No CustomerId')
}
let { wishlist } = await commerce.getCustomerWishlist({
variables: { customerId },
config,
})
if (!wishlist) {
// If user has no wishlist, then let's create one with new item
const { data } = await config.storeApiFetch('/v3/wishlists', {
method: 'POST',
body: JSON.stringify({
name: 'Next.js Commerce Wishlist',
is_public: false,
customer_id: Number(customerId),
items: [parseWishlistItem(item)],
}),
})
return res.status(200).json(data)
}
// Existing Wishlist, let's add Item to Wishlist
const { data } = await config.storeApiFetch(
`/v3/wishlists/${wishlist.id}/items`,
{
method: 'POST',
body: JSON.stringify({
items: [parseWishlistItem(item)],
}),
}
)
// Returns Wishlist
return res.status(200).json(data)
} catch (err: any) {
res.status(500).json({
data: null,
errors: [{ message: err.message }],
})
}
}
export default addItem

View File

@ -0,0 +1,39 @@
import type { Wishlist } from '../../../types/wishlist'
import type { WishlistEndpoint } from '.'
import getCustomerId from '../../utils/get-customer-id'
import getCustomerWishlist from '../../operations/get-customer-wishlist'
// Return wishlist info
const getWishlist: WishlistEndpoint['handlers']['getWishlist'] = async ({
res,
body: { customerToken, includeProducts },
config,
commerce,
}) => {
let result: { data?: Wishlist } = {}
if (customerToken) {
const customerId =
customerToken && (await getCustomerId({ customerToken, config }))
if (!customerId) {
// If the customerToken is invalid, then this request is too
return res.status(404).json({
data: null,
errors: [{ message: 'Wishlist not found' }],
})
}
const { wishlist } = await commerce.getCustomerWishlist({
variables: { customerId },
includeProducts,
config,
})
result = { data: wishlist }
}
res.status(200).json({ data: result.data ?? null })
}
export default getWishlist

View File

@ -0,0 +1,24 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import wishlistEndpoint from '@vercel/commerce/api/endpoints/wishlist'
import type { WishlistSchema } from '../../../types/wishlist'
import type { BigcommerceAPI } from '../..'
import getWishlist from './get-wishlist'
import addItem from './add-item'
import removeItem from './remove-item'
export type WishlistAPI = GetAPISchema<BigcommerceAPI, WishlistSchema>
export type WishlistEndpoint = WishlistAPI['endpoint']
export const handlers: WishlistEndpoint['handlers'] = {
getWishlist,
addItem,
removeItem,
}
const wishlistApi = createEndpoint<WishlistAPI>({
handler: wishlistEndpoint,
handlers,
})
export default wishlistApi

View File

@ -0,0 +1,39 @@
import type { Wishlist } from '../../../types/wishlist'
import getCustomerWishlist from '../../operations/get-customer-wishlist'
import getCustomerId from '../../utils/get-customer-id'
import type { WishlistEndpoint } from '.'
// Return wishlist info
const removeItem: WishlistEndpoint['handlers']['removeItem'] = async ({
res,
body: { customerToken, itemId },
config,
commerce,
}) => {
const customerId =
customerToken && (await getCustomerId({ customerToken, config }))
const { wishlist } =
(customerId &&
(await commerce.getCustomerWishlist({
variables: { customerId },
config,
}))) ||
{}
if (!wishlist || !itemId) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
const result = await config.storeApiFetch<{ data: Wishlist } | null>(
`/v3/wishlists/${wishlist.id}/items/${itemId}`,
{ method: 'DELETE' }
)
const data = result?.data ?? null
res.status(200).json({ data })
}
export default removeItem

View File

@ -0,0 +1,9 @@
export const categoryTreeItemFragment = /* GraphQL */ `
fragment categoryTreeItem on CategoryTreeItem {
entityId
name
path
description
productCount
}
`

View File

@ -0,0 +1,113 @@
export const productPrices = /* GraphQL */ `
fragment productPrices on Prices {
price {
value
currencyCode
}
salePrice {
value
currencyCode
}
retailPrice {
value
currencyCode
}
}
`
export const swatchOptionFragment = /* GraphQL */ `
fragment swatchOption on SwatchOptionValue {
isDefault
hexColors
}
`
export const multipleChoiceOptionFragment = /* GraphQL */ `
fragment multipleChoiceOption on MultipleChoiceOption {
values {
edges {
node {
label
...swatchOption
}
}
}
}
${swatchOptionFragment}
`
export const productInfoFragment = /* GraphQL */ `
fragment productInfo on Product {
entityId
name
path
brand {
entityId
}
description
prices {
...productPrices
}
images {
edges {
node {
urlOriginal
altText
isDefault
}
}
}
variants(first: 250) {
edges {
node {
entityId
defaultImage {
urlOriginal
altText
isDefault
}
}
}
}
productOptions {
edges {
node {
__typename
entityId
displayName
...multipleChoiceOption
}
}
}
localeMeta: metafields(namespace: $locale, keys: ["name", "description"])
@include(if: $hasLocale) {
edges {
node {
key
value
}
}
}
}
${productPrices}
${multipleChoiceOptionFragment}
`
export const productConnectionFragment = /* GraphQL */ `
fragment productConnnection on ProductConnection {
pageInfo {
startCursor
endCursor
}
edges {
cursor
node {
...productInfo
}
}
}
${productInfoFragment}
`

View File

@ -0,0 +1,120 @@
import type { RequestInit } from '@vercel/fetch'
import {
CommerceAPI,
CommerceAPIConfig,
getCommerceApi as commerceApi,
} from '@vercel/commerce/api'
import createFetchGraphqlApi from './utils/fetch-graphql-api'
import createFetchStoreApi from './utils/fetch-store-api'
import type { CartAPI } from './endpoints/cart'
import type { CustomerAPI } from './endpoints/customer'
import type { LoginAPI } from './endpoints/login'
import type { LogoutAPI } from './endpoints/logout'
import type { SignupAPI } from './endpoints/signup'
import type { ProductsAPI } from './endpoints/catalog/products'
import type { WishlistAPI } from './endpoints/wishlist'
import login from './operations/login'
import getAllPages from './operations/get-all-pages'
import getPage from './operations/get-page'
import getSiteInfo from './operations/get-site-info'
import getCustomerWishlist from './operations/get-customer-wishlist'
import getAllProductPaths from './operations/get-all-product-paths'
import getAllProducts from './operations/get-all-products'
import getProduct from './operations/get-product'
export interface BigcommerceConfig extends CommerceAPIConfig {
// Indicates if the returned metadata with translations should be applied to the
// data or returned as it is
applyLocale?: boolean
storeApiUrl: string
storeApiToken: string
storeApiClientId: string
storeChannelId?: string
storeUrl?: string
storeApiClientSecret?: string
storeHash?: string
storeApiFetch<T>(endpoint: string, options?: RequestInit): Promise<T>
}
const API_URL = process.env.BIGCOMMERCE_STOREFRONT_API_URL // GraphAPI
const API_TOKEN = process.env.BIGCOMMERCE_STOREFRONT_API_TOKEN
const STORE_API_URL = process.env.BIGCOMMERCE_STORE_API_URL // REST API
const STORE_API_TOKEN = process.env.BIGCOMMERCE_STORE_API_TOKEN
const STORE_API_CLIENT_ID = process.env.BIGCOMMERCE_STORE_API_CLIENT_ID
const STORE_CHANNEL_ID = process.env.BIGCOMMERCE_CHANNEL_ID
const STORE_URL = process.env.BIGCOMMERCE_STORE_URL
const CLIENT_SECRET = process.env.BIGCOMMERCE_STORE_API_CLIENT_SECRET
const STOREFRONT_HASH = process.env.BIGCOMMERCE_STORE_API_STORE_HASH
if (!API_URL) {
throw new Error(
`The environment variable BIGCOMMERCE_STOREFRONT_API_URL is missing and it's required to access your store`
)
}
if (!API_TOKEN) {
throw new Error(
`The environment variable BIGCOMMERCE_STOREFRONT_API_TOKEN is missing and it's required to access your store`
)
}
if (!(STORE_API_URL && STORE_API_TOKEN && STORE_API_CLIENT_ID)) {
throw new Error(
`The environment variables BIGCOMMERCE_STORE_API_URL, BIGCOMMERCE_STORE_API_TOKEN, BIGCOMMERCE_STORE_API_CLIENT_ID have to be set in order to access the REST API of your store`
)
}
const ONE_DAY = 60 * 60 * 24
const config: BigcommerceConfig = {
commerceUrl: API_URL,
apiToken: API_TOKEN,
customerCookie: 'SHOP_TOKEN',
cartCookie: process.env.BIGCOMMERCE_CART_COOKIE ?? 'bc_cartId',
cartCookieMaxAge: ONE_DAY * 30,
fetch: createFetchGraphqlApi(() => getCommerceApi().getConfig()),
applyLocale: true,
// REST API only
storeApiUrl: STORE_API_URL,
storeApiToken: STORE_API_TOKEN,
storeApiClientId: STORE_API_CLIENT_ID,
storeChannelId: STORE_CHANNEL_ID,
storeUrl: STORE_URL,
storeApiClientSecret: CLIENT_SECRET,
storeHash: STOREFRONT_HASH,
storeApiFetch: createFetchStoreApi(() => getCommerceApi().getConfig()),
}
const operations = {
login,
getAllPages,
getPage,
getSiteInfo,
getCustomerWishlist,
getAllProductPaths,
getAllProducts,
getProduct,
}
export const provider = { config, operations }
export type Provider = typeof provider
export type APIs =
| CartAPI
| CustomerAPI
| LoginAPI
| LogoutAPI
| SignupAPI
| ProductsAPI
| WishlistAPI
export type BigcommerceAPI<P extends Provider = Provider> = CommerceAPI<P>
export function getCommerceApi<P extends Provider>(
customProvider: P = provider as any
): BigcommerceAPI<P> {
return commerceApi(customProvider)
}

View File

@ -0,0 +1,46 @@
import type {
OperationContext,
OperationOptions,
} from '@vercel/commerce/api/operations'
import type { Page, GetAllPagesOperation } from '../../types/page'
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import { BigcommerceConfig, Provider } from '..'
export default function getAllPagesOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllPages<T extends GetAllPagesOperation>(opts?: {
config?: Partial<BigcommerceConfig>
preview?: boolean
}): Promise<T['data']>
async function getAllPages<T extends GetAllPagesOperation>(
opts: {
config?: Partial<BigcommerceConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getAllPages<T extends GetAllPagesOperation>({
config,
preview,
}: {
url?: string
config?: Partial<BigcommerceConfig>
preview?: boolean
} = {}): Promise<T['data']> {
const cfg = commerce.getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `url`
const { data } = await cfg.storeApiFetch<
RecursivePartial<{ data: Page[] }>
>('/v3/content/pages')
const pages = (data as RecursiveRequired<typeof data>) ?? []
return {
pages: preview ? pages : pages.filter((p) => p.is_visible),
}
}
return getAllPages
}

View File

@ -0,0 +1,66 @@
import type {
OperationContext,
OperationOptions,
} from '@vercel/commerce/api/operations'
import type { GetAllProductPathsQuery } from '../../../schema'
import type { GetAllProductPathsOperation } from '../../types/product'
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import filterEdges from '../utils/filter-edges'
import { BigcommerceConfig, Provider } from '..'
export const getAllProductPathsQuery = /* GraphQL */ `
query getAllProductPaths($first: Int = 100) {
site {
products(first: $first) {
edges {
node {
path
}
}
}
}
}
`
export default function getAllProductPathsOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllProductPaths<
T extends GetAllProductPathsOperation
>(opts?: {
variables?: T['variables']
config?: BigcommerceConfig
}): Promise<T['data']>
async function getAllProductPaths<T extends GetAllProductPathsOperation>(
opts: {
variables?: T['variables']
config?: BigcommerceConfig
} & OperationOptions
): Promise<T['data']>
async function getAllProductPaths<T extends GetAllProductPathsOperation>({
query = getAllProductPathsQuery,
variables,
config,
}: {
query?: string
variables?: T['variables']
config?: BigcommerceConfig
} = {}): Promise<T['data']> {
config = commerce.getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `query`
const { data } = await config.fetch<
RecursivePartial<GetAllProductPathsQuery>
>(query, { variables })
const products = data.site?.products?.edges
return {
products: filterEdges(products as RecursiveRequired<typeof products>).map(
({ node }) => node
),
}
}
return getAllProductPaths
}

View File

@ -0,0 +1,135 @@
import type {
OperationContext,
OperationOptions,
} from '@vercel/commerce/api/operations'
import type {
GetAllProductsQuery,
GetAllProductsQueryVariables,
} from '../../../schema'
import type { GetAllProductsOperation } from '../../types/product'
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import filterEdges from '../utils/filter-edges'
import setProductLocaleMeta from '../utils/set-product-locale-meta'
import { productConnectionFragment } from '../fragments/product'
import { BigcommerceConfig, Provider } from '..'
import { normalizeProduct } from '../../lib/normalize'
export const getAllProductsQuery = /* GraphQL */ `
query getAllProducts(
$hasLocale: Boolean = false
$locale: String = "null"
$entityIds: [Int!]
$first: Int = 10
$products: Boolean = false
$featuredProducts: Boolean = false
$bestSellingProducts: Boolean = false
$newestProducts: Boolean = false
) {
site {
products(first: $first, entityIds: $entityIds) @include(if: $products) {
...productConnnection
}
featuredProducts(first: $first) @include(if: $featuredProducts) {
...productConnnection
}
bestSellingProducts(first: $first) @include(if: $bestSellingProducts) {
...productConnnection
}
newestProducts(first: $first) @include(if: $newestProducts) {
...productConnnection
}
}
}
${productConnectionFragment}
`
export type ProductEdge = NonNullable<
NonNullable<GetAllProductsQuery['site']['products']['edges']>[0]
>
export type ProductNode = ProductEdge['node']
export type GetAllProductsResult<
T extends Record<keyof GetAllProductsResult, any[]> = {
products: ProductEdge[]
}
> = T
function getProductsType(
relevance?: GetAllProductsOperation['variables']['relevance']
) {
switch (relevance) {
case 'featured':
return 'featuredProducts'
case 'best_selling':
return 'bestSellingProducts'
case 'newest':
return 'newestProducts'
default:
return 'products'
}
}
export default function getAllProductsOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllProducts<T extends GetAllProductsOperation>(opts?: {
variables?: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
}): Promise<T['data']>
async function getAllProducts<T extends GetAllProductsOperation>(
opts: {
variables?: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getAllProducts<T extends GetAllProductsOperation>({
query = getAllProductsQuery,
variables: vars = {},
config: cfg,
}: {
query?: string
variables?: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
} = {}): Promise<T['data']> {
const config = commerce.getConfig(cfg)
const { locale } = config
const field = getProductsType(vars.relevance)
const variables: GetAllProductsQueryVariables = {
locale,
hasLocale: !!locale,
}
variables[field] = true
if (vars.first) variables.first = vars.first
if (vars.ids) variables.entityIds = vars.ids.map((id) => Number(id))
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `query`
const { data } = await config.fetch<RecursivePartial<GetAllProductsQuery>>(
query,
{ variables }
)
const edges = data.site?.[field]?.edges
const products = filterEdges(edges as RecursiveRequired<typeof edges>)
if (locale && config.applyLocale) {
products.forEach((product: RecursivePartial<ProductEdge>) => {
if (product.node) setProductLocaleMeta(product.node)
})
}
return {
products: products.map(({ node }) => normalizeProduct(node as any)),
}
}
return getAllProducts
}

View File

@ -0,0 +1,82 @@
import type {
OperationContext,
OperationOptions,
} from '@vercel/commerce/api/operations'
import type {
GetCustomerWishlistOperation,
Wishlist,
} from '../../types/wishlist'
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import { BigcommerceConfig, Provider } from '..'
import getAllProducts, { ProductEdge } from './get-all-products'
export default function getCustomerWishlistOperation({
commerce,
}: OperationContext<Provider>) {
async function getCustomerWishlist<
T extends GetCustomerWishlistOperation
>(opts: {
variables: T['variables']
config?: BigcommerceConfig
includeProducts?: boolean
}): Promise<T['data']>
async function getCustomerWishlist<T extends GetCustomerWishlistOperation>(
opts: {
variables: T['variables']
config?: BigcommerceConfig
includeProducts?: boolean
} & OperationOptions
): Promise<T['data']>
async function getCustomerWishlist<T extends GetCustomerWishlistOperation>({
config,
variables,
includeProducts,
}: {
url?: string
variables: T['variables']
config?: BigcommerceConfig
includeProducts?: boolean
}): Promise<T['data']> {
config = commerce.getConfig(config)
const { data = [] } = await config.storeApiFetch<
RecursivePartial<{ data: Wishlist[] }>
>(`/v3/wishlists?customer_id=${variables.customerId}`)
const wishlist = data[0]
if (includeProducts && wishlist?.items?.length) {
const ids = wishlist.items
?.map((item) => (item?.product_id ? String(item?.product_id) : null))
.filter((id): id is string => !!id)
if (ids?.length) {
const graphqlData = await commerce.getAllProducts({
variables: { first: 50, ids },
config,
})
// Put the products in an object that we can use to get them by id
const productsById = graphqlData.products.reduce<{
[k: number]: ProductEdge
}>((prods, p) => {
prods[Number(p.id)] = p as any
return prods
}, {})
// Populate the wishlist items with the graphql products
wishlist.items.forEach((item) => {
const product = item && productsById[item.product_id!]
if (item && product) {
// @ts-ignore Fix this type when the wishlist type is properly defined
item.product = product
}
})
}
}
return { wishlist: wishlist as RecursiveRequired<typeof wishlist> }
}
return getCustomerWishlist
}

View File

@ -0,0 +1,54 @@
import type {
OperationContext,
OperationOptions,
} from '@vercel/commerce/api/operations'
import type { GetPageOperation, Page } from '../../types/page'
import type { RecursivePartial, RecursiveRequired } from '../utils/types'
import type { BigcommerceConfig, Provider } from '..'
import { normalizePage } from '../../lib/normalize'
export default function getPageOperation({
commerce,
}: OperationContext<Provider>) {
async function getPage<T extends GetPageOperation>(opts: {
variables: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
}): Promise<T['data']>
async function getPage<T extends GetPageOperation>(
opts: {
variables: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getPage<T extends GetPageOperation>({
url,
variables,
config,
preview,
}: {
url?: string
variables: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
}): Promise<T['data']> {
const cfg = commerce.getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `url`
const { data } = await cfg.storeApiFetch<
RecursivePartial<{ data: Page[] }>
>(url || `/v3/content/pages?id=${variables.id}&include=body`)
const firstPage = data?.[0]
const page = firstPage as RecursiveRequired<typeof firstPage>
if (preview || page?.is_visible) {
return { page: normalizePage(page as any) }
}
return {}
}
return getPage
}

View File

@ -0,0 +1,119 @@
import type {
OperationContext,
OperationOptions,
} from '@vercel/commerce/api/operations'
import type { GetProductOperation } from '../../types/product'
import type { GetProductQuery, GetProductQueryVariables } from '../../../schema'
import setProductLocaleMeta from '../utils/set-product-locale-meta'
import { productInfoFragment } from '../fragments/product'
import { BigcommerceConfig, Provider } from '..'
import { normalizeProduct } from '../../lib/normalize'
export const getProductQuery = /* GraphQL */ `
query getProduct(
$hasLocale: Boolean = false
$locale: String = "null"
$path: String!
) {
site {
route(path: $path) {
node {
__typename
... on Product {
...productInfo
variants(first: 250) {
edges {
node {
entityId
defaultImage {
urlOriginal
altText
isDefault
}
prices {
...productPrices
}
inventory {
aggregated {
availableToSell
warningLevel
}
isInStock
}
productOptions {
edges {
node {
__typename
entityId
displayName
...multipleChoiceOption
}
}
}
}
}
}
}
}
}
}
}
${productInfoFragment}
`
// TODO: See if this type is useful for defining the Product type
// export type ProductNode = Extract<
// GetProductQuery['site']['route']['node'],
// { __typename: 'Product' }
// >
export default function getAllProductPathsOperation({
commerce,
}: OperationContext<Provider>) {
async function getProduct<T extends GetProductOperation>(opts: {
variables: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
}): Promise<T['data']>
async function getProduct<T extends GetProductOperation>(
opts: {
variables: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getProduct<T extends GetProductOperation>({
query = getProductQuery,
variables: { slug, ...vars },
config: cfg,
}: {
query?: string
variables: T['variables']
config?: Partial<BigcommerceConfig>
preview?: boolean
}): Promise<T['data']> {
const config = commerce.getConfig(cfg)
const { locale } = config
const variables: GetProductQueryVariables = {
locale,
hasLocale: !!locale,
path: slug ? `/${slug}/` : vars.path!,
}
const { data } = await config.fetch<GetProductQuery>(query, { variables })
const product = data.site?.route?.node
if (product?.__typename === 'Product') {
if (locale && config.applyLocale) {
setProductLocaleMeta(product)
}
return { product: normalizeProduct(product as any) }
}
return {}
}
return getProduct
}

View File

@ -0,0 +1,87 @@
import type {
OperationContext,
OperationOptions,
} from '@vercel/commerce/api/operations'
import type { GetSiteInfoOperation } from '../../types/site'
import type { GetSiteInfoQuery } from '../../../schema'
import filterEdges from '../utils/filter-edges'
import type { BigcommerceConfig, Provider } from '..'
import { categoryTreeItemFragment } from '../fragments/category-tree'
import { normalizeCategory } from '../../lib/normalize'
// Get 3 levels of categories
export const getSiteInfoQuery = /* GraphQL */ `
query getSiteInfo {
site {
categoryTree {
...categoryTreeItem
children {
...categoryTreeItem
children {
...categoryTreeItem
}
}
}
brands {
pageInfo {
startCursor
endCursor
}
edges {
cursor
node {
entityId
name
defaultImage {
urlOriginal
altText
}
pageTitle
metaDesc
metaKeywords
searchKeywords
path
}
}
}
}
}
${categoryTreeItemFragment}
`
export default function getSiteInfoOperation({
commerce,
}: OperationContext<Provider>) {
async function getSiteInfo<T extends GetSiteInfoOperation>(opts?: {
config?: Partial<BigcommerceConfig>
preview?: boolean
}): Promise<T['data']>
async function getSiteInfo<T extends GetSiteInfoOperation>(
opts: {
config?: Partial<BigcommerceConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getSiteInfo<T extends GetSiteInfoOperation>({
query = getSiteInfoQuery,
config,
}: {
query?: string
config?: Partial<BigcommerceConfig>
preview?: boolean
} = {}): Promise<T['data']> {
const cfg = commerce.getConfig(config)
const { data } = await cfg.fetch<GetSiteInfoQuery>(query)
const categories = data.site.categoryTree.map(normalizeCategory)
const brands = data.site?.brands?.edges
return {
categories: categories ?? [],
brands: filterEdges(brands),
}
}
return getSiteInfo
}

View File

@ -0,0 +1,79 @@
import type { ServerResponse } from 'http'
import type {
OperationContext,
OperationOptions,
} from '@vercel/commerce/api/operations'
import type { LoginOperation } from '../../types/login'
import type { LoginMutation } from '../../../schema'
import type { RecursivePartial } from '../utils/types'
import concatHeader from '../utils/concat-cookie'
import type { BigcommerceConfig, Provider } from '..'
export const loginMutation = /* GraphQL */ `
mutation login($email: String!, $password: String!) {
login(email: $email, password: $password) {
result
}
}
`
export default function loginOperation({
commerce,
}: OperationContext<Provider>) {
async function login<T extends LoginOperation>(opts: {
variables: T['variables']
config?: BigcommerceConfig
res: ServerResponse
}): Promise<T['data']>
async function login<T extends LoginOperation>(
opts: {
variables: T['variables']
config?: BigcommerceConfig
res: ServerResponse
} & OperationOptions
): Promise<T['data']>
async function login<T extends LoginOperation>({
query = loginMutation,
variables,
res: response,
config,
}: {
query?: string
variables: T['variables']
res: ServerResponse
config?: BigcommerceConfig
}): Promise<T['data']> {
config = commerce.getConfig(config)
const { data, res } = await config.fetch<RecursivePartial<LoginMutation>>(
query,
{ variables }
)
// Bigcommerce returns a Set-Cookie header with the auth cookie
let cookie = res.headers.get('Set-Cookie')
if (cookie && typeof cookie === 'string') {
// In development, don't set a secure cookie or the browser will ignore it
if (process.env.NODE_ENV !== 'production') {
cookie = cookie.replace('; Secure', '')
// SameSite=none can't be set unless the cookie is Secure
// bc seems to sometimes send back SameSite=None rather than none so make
// this case insensitive
cookie = cookie.replace(/; SameSite=none/gi, '; SameSite=lax')
}
response.setHeader(
'Set-Cookie',
concatHeader(response.getHeader('Set-Cookie'), cookie)!
)
}
return {
result: data.login?.result,
}
}
return login
}

View File

@ -0,0 +1,14 @@
type Header = string | number | string[] | undefined
export default function concatHeader(prev: Header, val: Header) {
if (!val) return prev
if (!prev) return val
if (Array.isArray(prev)) return prev.concat(String(val))
prev = String(prev)
if (Array.isArray(val)) return [prev].concat(val)
return [prev, String(val)]
}

View File

@ -0,0 +1,25 @@
import type { Response } from '@vercel/fetch'
// Used for GraphQL errors
export class BigcommerceGraphQLError extends Error {}
export class BigcommerceApiError extends Error {
status: number
res: Response
data: any
constructor(msg: string, res: Response, data?: any) {
super(msg)
this.name = 'BigcommerceApiError'
this.status = res.status
this.res = res
this.data = data
}
}
export class BigcommerceNetworkError extends Error {
constructor(msg: string) {
super(msg)
this.name = 'BigcommerceNetworkError'
}
}

View File

@ -0,0 +1,36 @@
import { FetcherError } from '@vercel/commerce/utils/errors'
import type { GraphQLFetcher } from '@vercel/commerce/api'
import type { BigcommerceConfig } from '../index'
import fetch from './fetch'
const fetchGraphqlApi: (getConfig: () => BigcommerceConfig) => GraphQLFetcher =
(getConfig) =>
async (query: string, { variables, preview } = {}, fetchOptions) => {
// log.warn(query)
const config = getConfig()
const res = await fetch(config.commerceUrl + (preview ? '/preview' : ''), {
...fetchOptions,
method: 'POST',
headers: {
Authorization: `Bearer ${config.apiToken}`,
...fetchOptions?.headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables,
}),
})
const json = await res.json()
if (json.errors) {
throw new FetcherError({
errors: json.errors ?? [{ message: 'Failed to fetch Bigcommerce API' }],
status: res.status,
})
}
return { data: json.data, res }
}
export default fetchGraphqlApi

View File

@ -0,0 +1,71 @@
import type { FetchOptions, Response } from '@vercel/fetch'
import type { BigcommerceConfig } from '../index'
import { BigcommerceApiError, BigcommerceNetworkError } from './errors'
import fetch from './fetch'
const fetchStoreApi =
<T>(getConfig: () => BigcommerceConfig) =>
async (endpoint: string, options?: FetchOptions): Promise<T> => {
const config = getConfig()
let res: Response
try {
res = await fetch(config.storeApiUrl + endpoint, {
...options,
headers: {
...options?.headers,
'Content-Type': 'application/json',
'X-Auth-Token': config.storeApiToken,
'X-Auth-Client': config.storeApiClientId,
},
})
} catch (error: any) {
throw new BigcommerceNetworkError(
`Fetch to Bigcommerce failed: ${error.message}`
)
}
const contentType = res.headers.get('Content-Type')
const isJSON = contentType?.includes('application/json')
if (!res.ok) {
const data = isJSON ? await res.json() : await getTextOrNull(res)
const headers = getRawHeaders(res)
const msg = `Big Commerce API error (${
res.status
}) \nHeaders: ${JSON.stringify(headers, null, 2)}\n${
typeof data === 'string' ? data : JSON.stringify(data, null, 2)
}`
throw new BigcommerceApiError(msg, res, data)
}
if (res.status !== 204 && !isJSON) {
throw new BigcommerceApiError(
`Fetch to Bigcommerce API failed, expected JSON content but found: ${contentType}`,
res
)
}
// If something was removed, the response will be empty
return res.status === 204 ? null : await res.json()
}
export default fetchStoreApi
function getRawHeaders(res: Response) {
const headers: { [key: string]: string } = {}
res.headers.forEach((value, key) => {
headers[key] = value
})
return headers
}
function getTextOrNull(res: Response) {
try {
return res.text()
} catch (err) {
return null
}
}

View File

@ -0,0 +1,3 @@
import vercelFetch from '@vercel/fetch'
export default vercelFetch()

View File

@ -0,0 +1,5 @@
export default function filterEdges<T>(
edges: (T | null | undefined)[] | null | undefined
) {
return edges?.filter((edge): edge is T => !!edge) ?? []
}

View File

@ -0,0 +1,20 @@
import { serialize, CookieSerializeOptions } from 'cookie'
export default function getCartCookie(
name: string,
cartId?: string,
maxAge?: number
) {
const options: CookieSerializeOptions =
cartId && maxAge
? {
maxAge,
expires: new Date(Date.now() + maxAge * 1000),
secure: process.env.NODE_ENV === 'production',
path: '/',
sameSite: 'lax',
}
: { maxAge: -1, path: '/' } // Removes the cookie
return serialize(name, cartId || '', options)
}

View File

@ -0,0 +1,32 @@
import type { GetCustomerIdQuery } from '../../../schema'
import type { BigcommerceConfig } from '../'
export const getCustomerIdQuery = /* GraphQL */ `
query getCustomerId {
customer {
entityId
}
}
`
async function getCustomerId({
customerToken,
config,
}: {
customerToken: string
config: BigcommerceConfig
}): Promise<string | undefined> {
const { data } = await config.fetch<GetCustomerIdQuery>(
getCustomerIdQuery,
undefined,
{
headers: {
cookie: `${config.customerCookie}=${customerToken}`,
},
}
)
return String(data?.customer?.entityId)
}
export default getCustomerId

View File

@ -0,0 +1,28 @@
import type { WishlistItemBody } from '../../types/wishlist'
import type { CartItemBody, OptionSelections } from '../../types/cart'
type BCWishlistItemBody = {
product_id: number
variant_id: number
}
type BCCartItemBody = {
product_id: number
variant_id: number
quantity?: number
option_selections?: OptionSelections[]
}
export const parseWishlistItem = (
item: WishlistItemBody
): BCWishlistItemBody => ({
product_id: Number(item.productId),
variant_id: Number(item.variantId),
})
export const parseCartItem = (item: CartItemBody): BCCartItemBody => ({
quantity: item.quantity,
product_id: Number(item.productId),
variant_id: Number(item.variantId),
option_selections: item.optionSelections,
})

View File

@ -0,0 +1,21 @@
import type { ProductNode } from '../operations/get-all-products'
import type { RecursivePartial } from './types'
export default function setProductLocaleMeta(
node: RecursivePartial<ProductNode>
) {
if (node.localeMeta?.edges) {
node.localeMeta.edges = node.localeMeta.edges.filter((edge) => {
const { key, value } = edge?.node ?? {}
if (key && key in node) {
;(node as any)[key] = value
return false
}
return true
})
if (!node.localeMeta.edges.length) {
delete node.localeMeta
}
}
}

View File

@ -0,0 +1,7 @@
export type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>
}
export type RecursiveRequired<T> = {
[P in keyof T]-?: RecursiveRequired<T[P]>
}

View File

@ -0,0 +1,3 @@
export { default as useLogin } from './use-login'
export { default as useLogout } from './use-logout'
export { default as useSignup } from './use-signup'

View File

@ -0,0 +1,41 @@
import { useCallback } from 'react'
import type { MutationHook } from '@vercel/commerce/utils/types'
import { CommerceError } from '@vercel/commerce/utils/errors'
import useLogin, { UseLogin } from '@vercel/commerce/auth/use-login'
import type { LoginHook } from '../types/login'
import useCustomer from '../customer/use-customer'
export default useLogin as UseLogin<typeof handler>
export const handler: MutationHook<LoginHook> = {
fetchOptions: {
url: '/api/login',
method: 'POST',
},
async fetcher({ input: { email, password }, options, fetch }) {
if (!(email && password)) {
throw new CommerceError({
message: 'An email and password are required to login',
})
}
return fetch({
...options,
body: { email, password },
})
},
useHook:
({ fetch }) =>
() => {
const { mutate } = useCustomer()
return useCallback(
async function login(input) {
const data = await fetch({ input })
await mutate()
return data
},
[fetch, mutate]
)
},
}

View File

@ -0,0 +1,28 @@
import { useCallback } from 'react'
import type { MutationHook } from '@vercel/commerce/utils/types'
import useLogout, { UseLogout } from '@vercel/commerce/auth/use-logout'
import type { LogoutHook } from '../types/logout'
import useCustomer from '../customer/use-customer'
export default useLogout as UseLogout<typeof handler>
export const handler: MutationHook<LogoutHook> = {
fetchOptions: {
url: '/api/logout',
method: 'GET',
},
useHook:
({ fetch }) =>
() => {
const { mutate } = useCustomer()
return useCallback(
async function logout() {
const data = await fetch()
await mutate(null, false)
return data
},
[fetch, mutate]
)
},
}

View File

@ -0,0 +1,46 @@
import { useCallback } from 'react'
import type { MutationHook } from '@vercel/commerce/utils/types'
import { CommerceError } from '@vercel/commerce/utils/errors'
import useSignup, { UseSignup } from '@vercel/commerce/auth/use-signup'
import type { SignupHook } from '../types/signup'
import useCustomer from '../customer/use-customer'
export default useSignup as UseSignup<typeof handler>
export const handler: MutationHook<SignupHook> = {
fetchOptions: {
url: '/api/signup',
method: 'POST',
},
async fetcher({
input: { firstName, lastName, email, password },
options,
fetch,
}) {
if (!(firstName && lastName && email && password)) {
throw new CommerceError({
message:
'A first name, last name, email and password are required to signup',
})
}
return fetch({
...options,
body: { firstName, lastName, email, password },
})
},
useHook:
({ fetch }) =>
() => {
const { mutate } = useCustomer()
return useCallback(
async function signup(input) {
const data = await fetch({ input })
await mutate()
return data
},
[fetch, mutate]
)
},
}

View File

@ -0,0 +1,4 @@
export { default as useCart } from './use-cart'
export { default as useAddItem } from './use-add-item'
export { default as useRemoveItem } from './use-remove-item'
export { default as useUpdateItem } from './use-update-item'

View File

@ -0,0 +1,46 @@
import { useCallback } from 'react'
import type { MutationHook } from '@vercel/commerce/utils/types'
import { CommerceError } from '@vercel/commerce/utils/errors'
import useAddItem, { UseAddItem } from '@vercel/commerce/cart/use-add-item'
import type { AddItemHook } from '@vercel/commerce/types/cart'
import useCart from './use-cart'
export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<AddItemHook> = {
fetchOptions: {
url: '/api/cart',
method: 'POST',
},
async fetcher({ input: item, options, fetch }) {
if (
item.quantity &&
(!Number.isInteger(item.quantity) || item.quantity! < 1)
) {
throw new CommerceError({
message: 'The item quantity has to be a valid integer greater than 0',
})
}
const data = await fetch({
...options,
body: { item },
})
return data
},
useHook:
({ fetch }) =>
() => {
const { mutate } = useCart()
return useCallback(
async function addItem(input) {
const data = await fetch({ input })
await mutate(data, false)
return data
},
[fetch, mutate]
)
},
}

View File

@ -0,0 +1,33 @@
import { useMemo } from 'react'
import { SWRHook } from '@vercel/commerce/utils/types'
import useCart, { UseCart } from '@vercel/commerce/cart/use-cart'
import type { GetCartHook } from '@vercel/commerce/types/cart'
export default useCart as UseCart<typeof handler>
export const handler: SWRHook<GetCartHook> = {
fetchOptions: {
url: '/api/cart',
method: 'GET',
},
useHook:
({ useData }) =>
(input) => {
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
})
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.lineItems.length ?? 0) <= 0
},
enumerable: true,
},
}),
[response]
)
},
}

View File

@ -0,0 +1,54 @@
import { useCallback } from 'react'
import type {
MutationHookContext,
HookFetcherContext,
} from '@vercel/commerce/utils/types'
import { ValidationError } from '@vercel/commerce/utils/errors'
import useRemoveItem, { UseRemoveItem } from '@vercel/commerce/cart/use-remove-item'
import type { Cart, LineItem, RemoveItemHook } from '@vercel/commerce/types/cart'
import useCart from './use-cart'
export type RemoveItemFn<T = any> = T extends LineItem
? (input?: RemoveItemActionInput<T>) => Promise<Cart | null | undefined>
: (input: RemoveItemActionInput<T>) => Promise<Cart | null>
export type RemoveItemActionInput<T = any> = T extends LineItem
? Partial<RemoveItemHook['actionInput']>
: RemoveItemHook['actionInput']
export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler = {
fetchOptions: {
url: '/api/cart',
method: 'DELETE',
},
async fetcher({
input: { itemId },
options,
fetch,
}: HookFetcherContext<RemoveItemHook>) {
return await fetch({ ...options, body: { itemId } })
},
useHook:
({ fetch }: MutationHookContext<RemoveItemHook>) =>
<T extends LineItem | undefined = undefined>(ctx: { item?: T } = {}) => {
const { item } = ctx
const { mutate } = useCart()
const removeItem: RemoveItemFn<LineItem> = async (input) => {
const itemId = input?.id ?? item?.id
if (!itemId) {
throw new ValidationError({
message: 'Invalid input used for this operation',
})
}
const data = await fetch({ input: { itemId } })
await mutate(data, false)
return data
}
return useCallback(removeItem as RemoveItemFn<T>, [fetch, mutate])
},
}

View File

@ -0,0 +1,84 @@
import { useCallback } from 'react'
import debounce from 'lodash.debounce'
import type {
MutationHookContext,
HookFetcherContext,
} from '@vercel/commerce/utils/types'
import { ValidationError } from '@vercel/commerce/utils/errors'
import useUpdateItem, { UseUpdateItem } from '@vercel/commerce/cart/use-update-item'
import type { LineItem, UpdateItemHook } from '@vercel/commerce/types/cart'
import { handler as removeItemHandler } from './use-remove-item'
import useCart from './use-cart'
export type UpdateItemActionInput<T = any> = T extends LineItem
? Partial<UpdateItemHook['actionInput']>
: UpdateItemHook['actionInput']
export default useUpdateItem as UseUpdateItem<typeof handler>
export const handler = {
fetchOptions: {
url: '/api/cart',
method: 'PUT',
},
async fetcher({
input: { itemId, item },
options,
fetch,
}: HookFetcherContext<UpdateItemHook>) {
if (Number.isInteger(item.quantity)) {
// Also allow the update hook to remove an item if the quantity is lower than 1
if (item.quantity! < 1) {
return removeItemHandler.fetcher({
options: removeItemHandler.fetchOptions,
input: { itemId },
fetch,
})
}
} else if (item.quantity) {
throw new ValidationError({
message: 'The item quantity has to be a valid integer',
})
}
return await fetch({
...options,
body: { itemId, item },
})
},
useHook:
({ fetch }: MutationHookContext<UpdateItemHook>) =>
<T extends LineItem | undefined = undefined>(
ctx: {
item?: T
wait?: number
} = {}
) => {
const { item } = ctx
const { mutate } = useCart() as any
return useCallback(
debounce(async (input: UpdateItemActionInput<T>) => {
const itemId = input.id ?? item?.id
const productId = input.productId ?? item?.productId
const variantId = input.productId ?? item?.variantId
if (!itemId || !productId || !variantId) {
throw new ValidationError({
message: 'Invalid input used for this operation',
})
}
const data = await fetch({
input: {
itemId,
item: { productId, variantId, quantity: input.quantity },
},
})
await mutate(data, false)
return data
}, ctx.wait ?? 500),
[fetch, mutate]
)
},
}

View File

@ -0,0 +1,14 @@
import { SWRHook } from '@vercel/commerce/utils/types'
import useCheckout, { UseCheckout } from '@vercel/commerce/checkout/use-checkout'
export default useCheckout as UseCheckout<typeof handler>
export const handler: SWRHook<any> = {
fetchOptions: {
query: '',
},
async fetcher({ input, options, fetch }) {},
useHook:
({ useData }) =>
async (input) => ({}),
}

View File

@ -0,0 +1,7 @@
{
"provider": "bigcommerce",
"features": {
"wishlist": true,
"customerAuth": true
}
}

View File

@ -0,0 +1,15 @@
import useAddItem, { UseAddItem } from '@vercel/commerce/customer/address/use-add-item'
import { MutationHook } from '@vercel/commerce/utils/types'
export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<any> = {
fetchOptions: {
query: '',
},
async fetcher({ input, options, fetch }) {},
useHook:
({ fetch }) =>
() =>
async () => ({}),
}

View File

@ -0,0 +1,15 @@
import useAddItem, { UseAddItem } from '@vercel/commerce/customer/card/use-add-item'
import { MutationHook } from '@vercel/commerce/utils/types'
export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<any> = {
fetchOptions: {
query: '',
},
async fetcher({ input, options, fetch }) {},
useHook:
({ fetch }) =>
() =>
async () => ({}),
}

View File

@ -0,0 +1 @@
export { default as useCustomer } from './use-customer'

View File

@ -0,0 +1,26 @@
import { SWRHook } from '@vercel/commerce/utils/types'
import useCustomer, { UseCustomer } from '@vercel/commerce/customer/use-customer'
import type { CustomerHook } from '../types/customer'
export default useCustomer as UseCustomer<typeof handler>
export const handler: SWRHook<CustomerHook> = {
fetchOptions: {
url: '/api/customer',
method: 'GET',
},
async fetcher({ options, fetch }) {
const data = await fetch(options)
return data?.customer ?? null
},
useHook:
({ useData }) =>
(input) => {
return useData({
swrOptions: {
revalidateOnFocus: false,
...input?.swrOptions,
},
})
},
}

View File

@ -0,0 +1,41 @@
import { FetcherError } from '@vercel/commerce/utils/errors'
import type { Fetcher } from '@vercel/commerce/utils/types'
async function getText(res: Response) {
try {
return (await res.text()) || res.statusText
} catch (error) {
return res.statusText
}
}
async function getError(res: Response) {
if (res.headers.get('Content-Type')?.includes('application/json')) {
const data = await res.json()
return new FetcherError({ errors: data.errors, status: res.status })
}
return new FetcherError({ message: await getText(res), status: res.status })
}
const fetcher: Fetcher = async ({
url,
method = 'GET',
variables,
body: bodyObj,
}) => {
const hasBody = Boolean(variables || bodyObj)
const body = hasBody
? JSON.stringify(variables ? { variables } : bodyObj)
: undefined
const headers = hasBody ? { 'Content-Type': 'application/json' } : undefined
const res = await fetch(url!, { method, body, headers })
if (res.ok) {
const { data } = await res.json()
return data
}
throw await getError(res)
}
export default fetcher

View File

@ -0,0 +1,9 @@
import { getCommerceProvider, useCommerce as useCoreCommerce } from '@vercel/commerce'
import { bigcommerceProvider, BigcommerceProvider } from './provider'
export { bigcommerceProvider }
export type { BigcommerceProvider }
export const CommerceProvider = getCommerceProvider(bigcommerceProvider)
export const useCommerce = () => useCoreCommerce<BigcommerceProvider>()

View File

@ -0,0 +1,5 @@
// Remove trailing and leading slash, usually included in nodes
// returned by the BigCommerce API
const getSlug = (path: string) => path.replace(/^\/|\/$/g, '')
export default getSlug

View File

@ -0,0 +1,13 @@
import update, { Context } from 'immutability-helper'
const c = new Context()
c.extend('$auto', function (value, object) {
return object ? c.update(object, value) : c.update({}, value)
})
c.extend('$autoArray', function (value, object) {
return object ? c.update(object, value) : c.update([], value)
})
export default c.update

View File

@ -0,0 +1,136 @@
import type { Product } from '../types/product'
import type { Cart, BigcommerceCart, LineItem } from '../types/cart'
import type { Page } from '../types/page'
import type { BCCategory, Category } from '../types/site'
import { definitions } from '../api/definitions/store-content'
import update from './immutability'
import getSlug from './get-slug'
function normalizeProductOption(productOption: any) {
const {
node: { entityId, values: { edges = [] } = {}, ...rest },
} = productOption
return {
id: entityId,
values: edges?.map(({ node }: any) => node),
...rest,
}
}
export function normalizeProduct(productNode: any): Product {
const {
entityId: id,
productOptions,
prices,
path,
id: _,
options: _0,
} = productNode
return update(productNode, {
id: { $set: String(id) },
images: {
$apply: ({ edges }: any) =>
edges?.map(({ node: { urlOriginal, altText, ...rest } }: any) => ({
url: urlOriginal,
alt: altText,
...rest,
})),
},
variants: {
$apply: ({ edges }: any) =>
edges?.map(({ node: { entityId, productOptions, ...rest } }: any) => ({
id: entityId,
options: productOptions?.edges
? productOptions.edges.map(normalizeProductOption)
: [],
...rest,
})),
},
options: {
$set: productOptions.edges
? productOptions?.edges.map(normalizeProductOption)
: [],
},
brand: {
$apply: (brand: any) => (brand?.entityId ? brand?.entityId : null),
},
slug: {
$set: path?.replace(/^\/+|\/+$/g, ''),
},
price: {
$set: {
value: prices?.price.value,
currencyCode: prices?.price.currencyCode,
},
},
$unset: ['entityId'],
})
}
export function normalizePage(page: definitions['page_Full']): Page {
return {
id: String(page.id),
name: page.name,
is_visible: page.is_visible,
sort_order: page.sort_order,
body: page.body,
}
}
export function normalizeCart(data: BigcommerceCart): Cart {
return {
id: data.id,
customerId: String(data.customer_id),
email: data.email,
createdAt: data.created_time,
currency: data.currency,
taxesIncluded: data.tax_included,
lineItems: [
...data.line_items.physical_items.map(normalizeLineItem),
...data.line_items.digital_items.map(normalizeLineItem),
],
lineItemsSubtotalPrice: data.base_amount,
subtotalPrice: data.base_amount + data.discount_amount,
totalPrice: data.cart_amount,
discounts: data.discounts?.map((discount) => ({
value: discount.discounted_amount,
})),
}
}
function normalizeLineItem(item: any): LineItem {
return {
id: item.id,
variantId: String(item.variant_id),
productId: String(item.product_id),
name: item.name,
quantity: item.quantity,
variant: {
id: String(item.variant_id),
sku: item.sku,
name: item.name,
image: {
url: item.image_url,
},
requiresShipping: item.is_require_shipping,
price: item.sale_price,
listPrice: item.list_price,
},
options: item.options,
path: item.url.split('/')[3],
discounts: item.discounts.map((discount: any) => ({
value: discount.discounted_amount,
})),
}
}
export function normalizeCategory(category: BCCategory): Category {
return {
id: `${category.entityId}`,
name: category.name,
slug: getSlug(category.path),
path: category.path,
}
}

View File

@ -0,0 +1,8 @@
const commerce = require('./commerce.config.json')
module.exports = {
commerce,
images: {
domains: ['cdn11.bigcommerce.com'],
},
}

View File

@ -0,0 +1,2 @@
export { default as usePrice } from './use-price'
export { default as useSearch } from './use-search'

View File

@ -0,0 +1,2 @@
export * from '@vercel/commerce/product/use-price'
export { default } from '@vercel/commerce/product/use-price'

View File

@ -0,0 +1,52 @@
import { SWRHook } from '@vercel/commerce/utils/types'
import useSearch, { UseSearch } from '@vercel/commerce/product/use-search'
import type { SearchProductsHook } from '../types/product'
export default useSearch as UseSearch<typeof handler>
export type SearchProductsInput = {
search?: string
categoryId?: number | string
brandId?: number
sort?: string
locale?: string
}
export const handler: SWRHook<SearchProductsHook> = {
fetchOptions: {
url: '/api/catalog/products',
method: 'GET',
},
fetcher({ input: { search, categoryId, brandId, sort }, options, fetch }) {
// Use a dummy base as we only care about the relative path
const url = new URL(options.url!, 'http://a')
if (search) url.searchParams.set('search', search)
if (Number.isInteger(Number(categoryId)))
url.searchParams.set('categoryId', String(categoryId))
if (Number.isInteger(brandId))
url.searchParams.set('brandId', String(brandId))
if (sort) url.searchParams.set('sort', sort)
return fetch({
url: url.pathname + url.search,
method: options.method,
})
},
useHook:
({ useData }) =>
(input = {}) => {
return useData({
input: [
['search', input.search],
['categoryId', input.categoryId],
['brandId', input.brandId],
['sort', input.sort],
],
swrOptions: {
revalidateOnFocus: false,
...input.swrOptions,
},
})
},
}

View File

@ -0,0 +1,34 @@
import { handler as useCart } from './cart/use-cart'
import { handler as useAddItem } from './cart/use-add-item'
import { handler as useUpdateItem } from './cart/use-update-item'
import { handler as useRemoveItem } from './cart/use-remove-item'
import { handler as useWishlist } from './wishlist/use-wishlist'
import { handler as useWishlistAddItem } from './wishlist/use-add-item'
import { handler as useWishlistRemoveItem } from './wishlist/use-remove-item'
import { handler as useCustomer } from './customer/use-customer'
import { handler as useSearch } from './product/use-search'
import { handler as useLogin } from './auth/use-login'
import { handler as useLogout } from './auth/use-logout'
import { handler as useSignup } from './auth/use-signup'
import fetcher from './fetcher'
export const bigcommerceProvider = {
locale: 'en-us',
cartCookie: 'bc_cartId',
fetcher,
cart: { useCart, useAddItem, useUpdateItem, useRemoveItem },
wishlist: {
useWishlist,
useAddItem: useWishlistAddItem,
useRemoveItem: useWishlistRemoveItem,
},
customer: { useCustomer },
products: { useSearch },
auth: { useLogin, useLogout, useSignup },
}
export type BigcommerceProvider = typeof bigcommerceProvider

View File

@ -0,0 +1,66 @@
import * as Core from '@vercel/commerce/types/cart'
export * from '@vercel/commerce/types/cart'
// TODO: this type should match:
// https://developer.bigcommerce.com/api-reference/cart-checkout/server-server-cart-api/cart/getacart#responses
export type BigcommerceCart = {
id: string
parent_id?: string
customer_id: number
email: string
currency: { code: string }
tax_included: boolean
base_amount: number
discount_amount: number
cart_amount: number
line_items: {
custom_items: any[]
digital_items: any[]
gift_certificates: any[]
physical_items: any[]
}
created_time: string
discounts?: { id: number; discounted_amount: number }[]
// TODO: add missing fields
}
/**
* Extend core cart types
*/
export type Cart = Core.Cart & {
lineItems: Core.LineItem[]
}
export type OptionSelections = {
option_id: number
option_value: number | string
}
export type CartItemBody = Core.CartItemBody & {
productId: string // The product id is always required for BC
optionSelections?: OptionSelections[]
}
export type CartTypes = {
cart: Cart
item: Core.LineItem
itemBody: CartItemBody
}
export type CartHooks = Core.CartHooks<CartTypes>
export type GetCartHook = CartHooks['getCart']
export type AddItemHook = CartHooks['addItem']
export type UpdateItemHook = CartHooks['updateItem']
export type RemoveItemHook = CartHooks['removeItem']
export type CartSchema = Core.CartSchema<CartTypes>
export type CartHandlers = Core.CartHandlers<CartTypes>
export type GetCartHandler = CartHandlers['getCart']
export type AddItemHandler = CartHandlers['addItem']
export type UpdateItemHandler = CartHandlers['updateItem']
export type RemoveItemHandler = CartHandlers['removeItem']

View File

@ -0,0 +1 @@
export * from '@vercel/commerce/types/checkout'

View File

@ -0,0 +1 @@
export * from '@vercel/commerce/types/common'

View File

@ -0,0 +1,5 @@
import * as Core from '@vercel/commerce/types/customer'
export * from '@vercel/commerce/types/customer'
export type CustomerSchema = Core.CustomerSchema

View File

@ -0,0 +1,25 @@
import * as Cart from './cart'
import * as Checkout from './checkout'
import * as Common from './common'
import * as Customer from './customer'
import * as Login from './login'
import * as Logout from './logout'
import * as Page from './page'
import * as Product from './product'
import * as Signup from './signup'
import * as Site from './site'
import * as Wishlist from './wishlist'
export type {
Cart,
Checkout,
Common,
Customer,
Login,
Logout,
Page,
Product,
Signup,
Site,
Wishlist,
}

View File

@ -0,0 +1,8 @@
import * as Core from '@vercel/commerce/types/login'
import type { LoginMutationVariables } from '../../schema'
export * from '@vercel/commerce/types/login'
export type LoginOperation = Core.LoginOperation & {
variables: LoginMutationVariables
}

View File

@ -0,0 +1 @@
export * from '@vercel/commerce/types/logout'

View File

@ -0,0 +1,11 @@
import * as Core from '@vercel/commerce/types/page'
export * from '@vercel/commerce/types/page'
export type Page = Core.Page
export type PageTypes = {
page: Page
}
export type GetAllPagesOperation = Core.GetAllPagesOperation<PageTypes>
export type GetPageOperation = Core.GetPageOperation<PageTypes>

View File

@ -0,0 +1 @@
export * from '@vercel/commerce/types/product'

View File

@ -0,0 +1 @@
export * from '@vercel/commerce/types/signup'

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