diff --git a/.gitignore b/.gitignore index b7301fe56..7879cb671 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ yarn-error.log* # Turborepo .turbo + +#typescript +tsconfig.tsbuildinfo diff --git a/README.md b/README.md index 10502012a..b01d959d5 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Demo live at: [demo.vercel.store](https://demo.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/ +- Olist Demo: https://olist.vercel.store/ ## Run minimal version locally diff --git a/packages/bigcommerce/package.json b/packages/bigcommerce/package.json index 34b8ac614..869a7111c 100644 --- a/packages/bigcommerce/package.json +++ b/packages/bigcommerce/package.json @@ -69,7 +69,7 @@ "@types/node": "^17.0.8", "@types/react": "^17.0.38", "lint-staged": "^12.1.7", - "next": "^12.0.8", + "next": "^12", "prettier": "^2.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/packages/commerce/new-provider.md b/packages/commerce/new-provider.md index c75076175..181bdac63 100644 --- a/packages/commerce/new-provider.md +++ b/packages/commerce/new-provider.md @@ -15,6 +15,7 @@ A commerce provider is a headless e-commerce platform that integrates with the [ - Kibo Commerce ([packages/kibocommerce](../kibocommerce)) - Commerce.js ([packages/commercejs](../commercejs)) - SFCC - SalesForce Cloud Commerce ([packages/sfcc](../sfcc)) +- Olist ([packages/olist](../olist)) Adding a commerce provider means adding a new folder in `packages` with a folder structure like the next one: @@ -69,7 +70,10 @@ Then, open [/site/.env.template](/site/.env.template) and add the provider name Using BigCommerce as an example. The first thing to do is export a `CommerceProvider` component that includes a `provider` object with all the handlers that can be used for hooks: ```tsx -import { getCommerceProvider, useCommerce as useCoreCommerce } from '@vercel/commerce' +import { + getCommerceProvider, + useCommerce as useCoreCommerce, +} from '@vercel/commerce' import { bigcommerceProvider, BigcommerceProvider } from './provider' export { bigcommerceProvider } @@ -213,25 +217,26 @@ export const handler: MutationHook = { ``` ## Showing progress and features + When creating a PR for a new provider, include this list in the PR description and mark the progress as you push so we can organize the code review. Not all points are required (but advised) so make sure to keep the list up to date. **Status** -* [ ] CommerceProvider -* [ ] Schema & TS types -* [ ] API Operations - Get all collections -* [ ] API Operations - Get all pages -* [ ] API Operations - Get all products -* [ ] API Operations - Get page -* [ ] API Operations - Get product -* [ ] API Operations - Get Shop Info (categories and vendors working — `vendors` query still a WIP PR on Reaction) -* [ ] Hook - Add Item -* [ ] Hook - Remove Item -* [ ] Hook - Update Item -* [ ] Hook - Get Cart (account-tied carts working, anonymous carts working, cart reconciliation working) -* [ ] Auth (based on a WIP PR on Reaction - still need to implement refresh tokens) -* [ ] Customer information -* [ ] Product attributes - Size, Colors -* [ ] Custom checkout -* [ ] Typing (in progress) -* [ ] Tests +- [ ] CommerceProvider +- [ ] Schema & TS types +- [ ] API Operations - Get all collections +- [ ] API Operations - Get all pages +- [ ] API Operations - Get all products +- [ ] API Operations - Get page +- [ ] API Operations - Get product +- [ ] API Operations - Get Shop Info (categories and vendors working — `vendors` query still a WIP PR on Reaction) +- [ ] Hook - Add Item +- [ ] Hook - Remove Item +- [ ] Hook - Update Item +- [ ] Hook - Get Cart (account-tied carts working, anonymous carts working, cart reconciliation working) +- [ ] Auth (based on a WIP PR on Reaction - still need to implement refresh tokens) +- [ ] Customer information +- [ ] Product attributes - Size, Colors +- [ ] Custom checkout +- [ ] Typing (in progress) +- [ ] Tests diff --git a/packages/commerce/package.json b/packages/commerce/package.json index 17343a8b2..eb6e6dc70 100644 --- a/packages/commerce/package.json +++ b/packages/commerce/package.json @@ -66,7 +66,7 @@ "@types/node": "^17.0.8", "@types/react": "^17.0.38", "lint-staged": "^12.1.7", - "next": "^12.0.8", + "next": "^12", "prettier": "^2.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/packages/commerce/src/api/index.ts b/packages/commerce/src/api/index.ts index 6914b9364..03cb7ded3 100644 --- a/packages/commerce/src/api/index.ts +++ b/packages/commerce/src/api/index.ts @@ -1,5 +1,6 @@ import type { NextApiHandler } from 'next' import type { FetchOptions, Response } from '@vercel/fetch' + import type { APIEndpoint, APIHandler } from './utils/types' import type { CartSchema } from '../types/cart' import type { CustomerSchema } from '../types/customer' diff --git a/packages/commercejs/package.json b/packages/commercejs/package.json index 9887a709f..ebfa831f0 100644 --- a/packages/commercejs/package.json +++ b/packages/commercejs/package.json @@ -65,7 +65,7 @@ "@types/node": "^17.0.8", "@types/react": "^17.0.38", "lint-staged": "^12.1.7", - "next": "^12.0.8", + "next": "^12", "prettier": "^2.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/packages/kibocommerce/package.json b/packages/kibocommerce/package.json index 6a2912814..2b9d125f2 100644 --- a/packages/kibocommerce/package.json +++ b/packages/kibocommerce/package.json @@ -70,7 +70,7 @@ "@types/node": "^17.0.8", "@types/react": "^17.0.38", "lint-staged": "^12.1.7", - "next": "^12.0.8", + "next": "^12", "prettier": "^2.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/packages/local/package.json b/packages/local/package.json index 3ec3e69a4..0e992c5da 100644 --- a/packages/local/package.json +++ b/packages/local/package.json @@ -62,7 +62,7 @@ "@types/node": "^17.0.8", "@types/react": "^17.0.38", "lint-staged": "^12.1.7", - "next": "^12.0.8", + "next": "^12", "prettier": "^2.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/packages/olist/.env.template b/packages/olist/.env.template new file mode 100644 index 000000000..c5c7f6154 --- /dev/null +++ b/packages/olist/.env.template @@ -0,0 +1,4 @@ +COMMERCE_PROVIDER=@vercel/commerce-olist + +NEXT_PUBLIC_OLIST_STOREFRONT_DOMAIN= +NEXT_PUBLIC_OLIST_STOREFRONT_ACCESS_TOKEN= diff --git a/packages/olist/.prettierignore b/packages/olist/.prettierignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/packages/olist/.prettierignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/olist/.prettierrc b/packages/olist/.prettierrc new file mode 100644 index 000000000..e1076edfa --- /dev/null +++ b/packages/olist/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "useTabs": false +} diff --git a/packages/olist/README.md b/packages/olist/README.md new file mode 100644 index 000000000..709734522 --- /dev/null +++ b/packages/olist/README.md @@ -0,0 +1 @@ +# Next.js Olist Provider diff --git a/packages/olist/package.json b/packages/olist/package.json new file mode 100644 index 000000000..b9a878439 --- /dev/null +++ b/packages/olist/package.json @@ -0,0 +1,80 @@ +{ + "name": "@vercel/commerce-olist", + "version": "0.0.1", + "license": "MIT", + "scripts": { + "release": "taskr release", + "build": "taskr build", + "dev": "taskr", + "types": "tsc --emitDeclarationOnly", + "prettier-fix": "prettier --write ." + }, + "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" + ], + "next.config": [ + "dist/next.config.d.cts" + ] + } + } + }, + "dependencies": { + "@vercel/commerce": "^0.0.1", + "@vercel/fetch": "^6.1.1", + "@vnda/headless-framework": "^0.0.33" + }, + "peerDependencies": { + "next": "^12", + "react": "^17", + "react-dom": "^17" + }, + "devDependencies": { + "@taskr/clear": "^1.1.0", + "@taskr/esnext": "^1.1.0", + "@taskr/watch": "^1.1.0", + "@types/node": "^17.0.8", + "@types/react": "^17.0.38", + "lint-staged": "^12.1.7", + "next": "^12", + "prettier": "^2.5.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "taskr": "^1.1.0", + "taskr-swc": "^0.0.1", + "typescript": "^4.5.4" + }, + "lint-staged": { + "**/*.{js,jsx,ts,tsx,json}": [ + "prettier --write", + "git add" + ] + } +} diff --git a/packages/olist/src/api/endpoints/cart/add-item.ts b/packages/olist/src/api/endpoints/cart/add-item.ts new file mode 100644 index 000000000..8b0e80878 --- /dev/null +++ b/packages/olist/src/api/endpoints/cart/add-item.ts @@ -0,0 +1,84 @@ +import { serialize } from 'cookie' +import type { Cart } from '@vnda/headless-framework' + +import type { CartEndpoint, Handler } from '.' + +import { + mapCommerceToRawRequest, + mapRawToCommerceResponse, +} from '../../../utils/cart' + +const addItem: CartEndpoint['handlers']['addItem'] = async ({ + res: response, + body: { cartId, item }, + config: { service, cartCookie, cartTokenCookie }, +}: Handler) => { + try { + if (!item) { + return response.status(400).json({ + data: null, + errors: [{ message: 'Missing item' }], + }) + } + + if (!item.quantity) item.quantity = 1 + + let cart: Cart + + if (!cartId) { + cart = await service.cart.create() + + response.setHeader('Set-Cookie', [ + serialize(cartCookie, cart.id.toString(), { + maxAge: 60 * 60 * 24 * 30, + expires: new Date(Date.now() + 60 * 60 * 24 * 30 * 1000), + secure: process.env.NODE_ENV === 'production', + path: '/', + sameSite: 'lax', + }), + serialize(cartTokenCookie, cart.token, { + maxAge: 60 * 60 * 24 * 30, + expires: new Date(Date.now() + 60 * 60 * 24 * 30 * 1000), + secure: process.env.NODE_ENV === 'production', + path: '/', + sameSite: 'lax', + }), + ]) + } else { + cart = await service.cart.getById(Number(cartId)) + } + + const itemExistQuantity = + ( + cart.items.find(({ variantSku }) => variantSku === item.variantId)! || + [] + ).quantity || 0 + + const cartItem = await service.cart.addItem( + cartId ? Number(cartId) : cart!.id, + mapCommerceToRawRequest({ + ...item, + quantity: Number(itemExistQuantity) + 1 || 1, + }) + ) + + cart.items = [ + ...(cart?.items.map((value) => + value.variantSku === item.variantId ? cartItem : value + ) || []), + ...(itemExistQuantity ? [] : [cartItem]), + ] + + response.status(200).json({ + data: mapRawToCommerceResponse(cart), + errors: [], + }) + } catch (error) { + response.status(500).json({ + data: {}, + errors: error, + }) + } +} + +export default addItem diff --git a/packages/olist/src/api/endpoints/cart/get-cart.ts b/packages/olist/src/api/endpoints/cart/get-cart.ts new file mode 100644 index 000000000..a53b5629f --- /dev/null +++ b/packages/olist/src/api/endpoints/cart/get-cart.ts @@ -0,0 +1,41 @@ +import { serialize } from 'cookie' + +import type { CartEndpoint, Handler } from '.' + +import { mapRawToCommerceResponse } from '../../../utils/cart' + +const getCart: CartEndpoint['handlers']['getCart'] = async ({ + res: response, + body: { cartId }, + config: { service, cartCookie, cartTokenCookie }, +}: Handler) => { + if (!cartId) { + return response.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + try { + const cart = await service.cart.getById(Number(cartId)) + + response + .status(200) + .json({ data: mapRawToCommerceResponse(cart), errors: [] }) + } catch (error) { + response.setHeader('Set-Cookie', [ + serialize(cartCookie, cartId, { + maxAge: -1, + path: '/', + }), + serialize(cartTokenCookie, cartId, { + maxAge: -1, + path: '/', + }), + ]) + + response.status(200).json({ data: null, errors: [] }) + } +} + +export default getCart diff --git a/packages/olist/src/api/endpoints/cart/index.ts b/packages/olist/src/api/endpoints/cart/index.ts new file mode 100644 index 000000000..9839473ea --- /dev/null +++ b/packages/olist/src/api/endpoints/cart/index.ts @@ -0,0 +1,47 @@ +import cartEndpoint from '@vercel/commerce/api/endpoints/cart' +import { createEndpoint } from '@vercel/commerce/api' + +import type { GetAPISchema } from '@vercel/commerce/api' +import type { CartItemBody, CartSchema } from '@vercel/commerce/types/cart' + +import getCart from './get-cart' +import addItem from './add-item' +import updateItem from './update-item' +import removeItem from './remove-item' + +import type { OlistAPI } from '../../../api' +import type { Handler as HandlerAPI } from '../../../types/api' + +export type CartAPI = GetAPISchema + +export type CartEndpoint = CartAPI['endpoint'] + +export const handlers: CartEndpoint['handlers'] = { + getCart, + addItem, + updateItem, + removeItem, +} + +type GetCartBody = { + cartId?: string +} + +type AddItemBody = { + item?: CartItemBody +} + +type RemoveItemBody = { + itemId?: string +} + +export type Handler = { + body: GetCartBody & AddItemBody & RemoveItemBody +} & HandlerAPI + +const cartApi = createEndpoint({ + handler: cartEndpoint, + handlers, +}) + +export default cartApi diff --git a/packages/olist/src/api/endpoints/cart/remove-item.ts b/packages/olist/src/api/endpoints/cart/remove-item.ts new file mode 100644 index 000000000..f6eeed421 --- /dev/null +++ b/packages/olist/src/api/endpoints/cart/remove-item.ts @@ -0,0 +1,26 @@ +import type { CartEndpoint, Handler } from '.' + +import { mapRawToCommerceResponse } from '../../../utils/cart' + +const removeItem: CartEndpoint['handlers']['removeItem'] = async ({ + res: response, + body: { cartId, itemId }, + config: { service }, +}: Handler) => { + if (!cartId || !itemId) { + return response.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + await service.cart.removeItem(Number(cartId), Number(itemId)) + + const cart = await service.cart.getById(Number(cartId)) + + response + .status(200) + .json({ data: mapRawToCommerceResponse(cart), errors: [] }) +} + +export default removeItem diff --git a/packages/olist/src/api/endpoints/cart/update-item.ts b/packages/olist/src/api/endpoints/cart/update-item.ts new file mode 100644 index 000000000..b66aa3397 --- /dev/null +++ b/packages/olist/src/api/endpoints/cart/update-item.ts @@ -0,0 +1,28 @@ +import type { CartEndpoint, Handler } from '.' + +import { mapRawToCommerceResponse } from '../../../utils/cart' + +const updateItem: CartEndpoint['handlers']['updateItem'] = async ({ + res: response, + body: { cartId, itemId, item }, + config: { service }, +}: Handler) => { + if (!cartId || !itemId || !item) { + return response.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + await service.cart.updateItem(Number(cartId), Number(itemId), { + quantity: item.quantity, + }) + + const cart = await service.cart.getById(Number(cartId)) + + response + .status(200) + .json({ data: mapRawToCommerceResponse(cart), errors: [] }) +} + +export default updateItem diff --git a/packages/olist/src/api/endpoints/catalog/products/get-products.ts b/packages/olist/src/api/endpoints/catalog/products/get-products.ts new file mode 100644 index 000000000..ec2ef9cd7 --- /dev/null +++ b/packages/olist/src/api/endpoints/catalog/products/get-products.ts @@ -0,0 +1,34 @@ +import type { Product } from '@vnda/headless-framework' + +import { ProductsEndpoint, Handler } from '.' + +import { mapItemRawToCommerceResponse } from '../../../../utils/product' + +// Get products for the product list page. Search and category filter implemented. Sort and brand filter not implemented. +const getProducts: ProductsEndpoint['handlers']['getProducts'] = async ({ + res, + body: { search, sort, categoryId }, + config: { service }, +}: Handler) => { + try { + let result: Product[] = [] + + result = await (search + ? service.product.search({ term: search }) + : service.product.list({ + sort, + limit: 20, + ...(categoryId && { tag: [categoryId.toString()] }), + })) + + const found = result?.length > 0 + + res.status(200).json({ + data: { products: result?.map(mapItemRawToCommerceResponse), found }, + }) + } catch (error) { + throw error + } +} + +export default getProducts diff --git a/packages/olist/src/api/endpoints/catalog/products/index.ts b/packages/olist/src/api/endpoints/catalog/products/index.ts new file mode 100644 index 000000000..68453f4ed --- /dev/null +++ b/packages/olist/src/api/endpoints/catalog/products/index.ts @@ -0,0 +1,30 @@ +import { createEndpoint } from '@vercel/commerce/api' +import productsEndpoint from '@vercel/commerce/api/endpoints/catalog/products' + +import type { GetAPISchema } from '@vercel/commerce/api' +import type { + ProductsSchema, + SearchProductsBody, +} from '@vercel/commerce/types/product' + +import getProducts from './get-products' + +import type { OlistAPI } from '../../../../api' +import type { Handler as HandlerAPI } from '../../../../types/api' + +export type ProductsAPI = GetAPISchema + +export type ProductsEndpoint = ProductsAPI['endpoint'] + +export const handlers: ProductsEndpoint['handlers'] = { getProducts } + +export type Handler = { + body: SearchProductsBody +} & HandlerAPI + +const productsApi = createEndpoint({ + handler: productsEndpoint, + handlers, +}) + +export default productsApi diff --git a/packages/olist/src/api/endpoints/checkout/get-checkout.ts b/packages/olist/src/api/endpoints/checkout/get-checkout.ts new file mode 100644 index 000000000..3fbf0f891 --- /dev/null +++ b/packages/olist/src/api/endpoints/checkout/get-checkout.ts @@ -0,0 +1,20 @@ +import type { CheckoutEndpoint } from '.' +import { Handler } from '../cart' + +const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({ + req: request, + res: response, + body: { cartId }, + config: { storeDomain, cartTokenCookie }, +}: Handler) => { + const cartToken = request.cookies[cartTokenCookie] + + if (!cartId || !cartToken) { + response.redirect('/') + return + } + + response.redirect(`https://${storeDomain}/checkout/${cartToken}`) +} + +export default getCheckout diff --git a/packages/olist/src/api/endpoints/checkout/index.ts b/packages/olist/src/api/endpoints/checkout/index.ts new file mode 100644 index 000000000..95e79782c --- /dev/null +++ b/packages/olist/src/api/endpoints/checkout/index.ts @@ -0,0 +1,25 @@ +import { createEndpoint } from '@vercel/commerce/api' +import checkoutEndpoint from '@vercel/commerce/api/endpoints/checkout' + +import type { GetAPISchema } from '@vercel/commerce/api' +import type { CheckoutSchema } from '@vercel/commerce/types/checkout' + +import getCheckout from './get-checkout' +import submitCheckout from './submit-checkout' + +import type { OlistAPI } from '../..' + +export type CheckoutAPI = GetAPISchema +export type CheckoutEndpoint = CheckoutAPI['endpoint'] + +export const handlers: CheckoutEndpoint['handlers'] = { + getCheckout, + submitCheckout, +} + +const checkoutApi = createEndpoint({ + handler: checkoutEndpoint, + handlers, +}) + +export default checkoutApi diff --git a/packages/olist/src/api/endpoints/checkout/submit-checkout.ts b/packages/olist/src/api/endpoints/checkout/submit-checkout.ts new file mode 100644 index 000000000..361f74331 --- /dev/null +++ b/packages/olist/src/api/endpoints/checkout/submit-checkout.ts @@ -0,0 +1,10 @@ +import type { CheckoutEndpoint } from '.' + +const submitCheckout: CheckoutEndpoint['handlers']['submitCheckout'] = async ({ + res, +}) => { + // Return cart and errors + res.status(200).json({ data: null, errors: [] }) +} + +export default submitCheckout diff --git a/packages/olist/src/api/endpoints/customer/address.ts b/packages/olist/src/api/endpoints/customer/address.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/packages/olist/src/api/endpoints/customer/address.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/packages/olist/src/api/endpoints/customer/card.ts b/packages/olist/src/api/endpoints/customer/card.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/packages/olist/src/api/endpoints/customer/card.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/packages/olist/src/api/endpoints/customer/get-logged-in-customer.ts b/packages/olist/src/api/endpoints/customer/get-logged-in-customer.ts new file mode 100644 index 000000000..1a418d24d --- /dev/null +++ b/packages/olist/src/api/endpoints/customer/get-logged-in-customer.ts @@ -0,0 +1,51 @@ +import { verify, TokenExpiredError } from 'jsonwebtoken' + +import type { CustomerEndpoint, Handler } from '.' + +const getLoggedInCustomer: CustomerEndpoint['handlers']['getLoggedInCustomer'] = + async ({ + req: request, + res: response, + config: { apiToken, customerTokenCookie, service }, + }: Handler) => { + const token = request.cookies[customerTokenCookie] + + if (token) { + const decoded = verify(token, apiToken) + + try { + const customer = await service.client.getUserById((decoded as any).id) + + if (!customer) { + return response.status(400).json({ + data: null, + errors: [{ message: 'Customer not found', code: 'not_found' }], + }) + } + + return response.status(200).json({ + data: { + customer, + }, + }) + } catch (error) { + if (error instanceof TokenExpiredError) { + response.status(401).json({ + data: null, + errors: [ + { + message: 'Jwt expired', + code: 'token_expired_error', + }, + ], + }) + } + + throw error + } + } + + response.status(200).json({ data: null }) + } + +export default getLoggedInCustomer diff --git a/packages/olist/src/api/endpoints/customer/index.ts b/packages/olist/src/api/endpoints/customer/index.ts new file mode 100644 index 000000000..76a90d7de --- /dev/null +++ b/packages/olist/src/api/endpoints/customer/index.ts @@ -0,0 +1,24 @@ +import { GetAPISchema, createEndpoint } from '@vercel/commerce/api' +import customerEndpoint from '@vercel/commerce/api/endpoints/customer' + +import type { CustomerSchema } from '@vercel/commerce/types/customer' + +import { OlistAPI } from '../../../api' +import getLoggedInCustomer from './get-logged-in-customer' + +import type { Handler as HandlerAPI } from '../../../types/api' + +export type CustomerAPI = GetAPISchema + +export type CustomerEndpoint = CustomerAPI['endpoint'] + +export const handlers: CustomerEndpoint['handlers'] = { getLoggedInCustomer } + +export type Handler = { body: any } & HandlerAPI + +const customerApi = createEndpoint({ + handler: customerEndpoint, + handlers, +}) + +export default customerApi diff --git a/packages/olist/src/api/endpoints/login/index.ts b/packages/olist/src/api/endpoints/login/index.ts new file mode 100644 index 000000000..454d3c41a --- /dev/null +++ b/packages/olist/src/api/endpoints/login/index.ts @@ -0,0 +1,25 @@ +import { createEndpoint } from '@vercel/commerce/api' +import loginEndpoint from '@vercel/commerce/api/endpoints/login' + +import type { GetAPISchema } from '@vercel/commerce/api' +import type { LoginBody, LoginSchema } from '@vercel/commerce/types/login' + +import login from './login' + +import type { OlistAPI } from '../../../api' +import type { Handler as HandlerAPI } from '../../../types/api' + +export type LoginAPI = GetAPISchema + +export type LoginEndpoint = LoginAPI['endpoint'] + +export type Handler = { body: LoginBody } & HandlerAPI + +export const handlers: LoginEndpoint['handlers'] = { login } + +const loginApi = createEndpoint({ + handler: loginEndpoint, + handlers, +}) + +export default loginApi diff --git a/packages/olist/src/api/endpoints/login/login.ts b/packages/olist/src/api/endpoints/login/login.ts new file mode 100644 index 000000000..bed9c72da --- /dev/null +++ b/packages/olist/src/api/endpoints/login/login.ts @@ -0,0 +1,63 @@ +import { sign } from 'jsonwebtoken' +import { serialize } from 'cookie' + +import { FetcherError } from '@vercel/commerce/utils/errors' + +import type { Handler, LoginEndpoint } from '.' + +export const invalidCredentials = /email and\/or password invalid/i + +const login: LoginEndpoint['handlers']['login'] = async ({ + res: response, + body: { email, password }, + config: { apiToken, customerTokenCookie, service }, +}: Handler) => { + if (!email || !password) { + return response.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + try { + const { id } = await service.client.login({ email, password }) + + if (id) { + response.setHeader('Set-Cookie', [ + serialize( + customerTokenCookie, + sign({ id: id.toString() }, apiToken, { expiresIn: 60 * 60 }), + { + maxAge: 60 * 60, + expires: new Date(Date.now() + 60 * 60 * 1000), + secure: process.env.NODE_ENV === 'production', + path: '/', + sameSite: 'lax', + } + ), + ]) + } + } catch (error) { + if ( + error instanceof FetcherError && + invalidCredentials.test(error.message) + ) { + return response.status(401).json({ + data: null, + errors: [ + { + message: + 'Cannot find an account that matches the provided credentials', + code: 'invalid_credentials', + }, + ], + }) + } + + throw error + } + + response.status(200).json({ data: null }) +} + +export default login diff --git a/packages/olist/src/api/endpoints/logout/index.ts b/packages/olist/src/api/endpoints/logout/index.ts new file mode 100644 index 000000000..54eb6618d --- /dev/null +++ b/packages/olist/src/api/endpoints/logout/index.ts @@ -0,0 +1,25 @@ +import { createEndpoint } from '@vercel/commerce/api' +import logoutEndpoint from '@vercel/commerce/api/endpoints/logout' +import { LogoutSchema, LogoutTypes } from '@vercel/commerce/types/logout' + +import type { GetAPISchema } from '@vercel/commerce/api' + +import logout from './logout' + +import type { OlistAPI } from '../../../api' +import type { Handler as HandlerAPI } from '../../../types/api' + +export type LogoutAPI = GetAPISchema + +export type LogoutEndpoint = LogoutAPI['endpoint'] + +export const handlers: LogoutEndpoint['handlers'] = { logout } + +export type Handler = { body: LogoutTypes['body'] } & HandlerAPI + +const logoutApi = createEndpoint({ + handler: logoutEndpoint, + handlers, +}) + +export default logoutApi diff --git a/packages/olist/src/api/endpoints/logout/logout.ts b/packages/olist/src/api/endpoints/logout/logout.ts new file mode 100644 index 000000000..6ddb71d4d --- /dev/null +++ b/packages/olist/src/api/endpoints/logout/logout.ts @@ -0,0 +1,21 @@ +import { serialize } from 'cookie' +import type { Handler, LogoutEndpoint } from '.' + +const logout: LogoutEndpoint['handlers']['logout'] = async ({ + res: response, + body: { redirectTo }, + config: { customerTokenCookie }, +}: Handler) => { + response.setHeader( + 'Set-Cookie', + serialize(customerTokenCookie, '', { maxAge: -1, path: '/' }) + ) + + if (redirectTo?.startsWith('/')) { + response.redirect(redirectTo) + } else { + response.status(200).json({ data: null }) + } +} + +export default logout diff --git a/packages/olist/src/api/endpoints/signup/index.ts b/packages/olist/src/api/endpoints/signup/index.ts new file mode 100644 index 000000000..c24efd7e7 --- /dev/null +++ b/packages/olist/src/api/endpoints/signup/index.ts @@ -0,0 +1,25 @@ +import { createEndpoint } from '@vercel/commerce/api' +import signupEndpoint from '@vercel/commerce/api/endpoints/signup' + +import type { GetAPISchema } from '@vercel/commerce/api' +import type { SignupBody, SignupSchema } from '@vercel/commerce/types/signup' + +import signup from './signup' + +import type { OlistAPI } from '../../../api' +import type { Handler as HandlerAPI } from '../../../types/api' + +export type SignupAPI = GetAPISchema + +export type SignupEndpoint = SignupAPI['endpoint'] + +export const handlers: SignupEndpoint['handlers'] = { signup } + +export type Handler = { body: SignupBody } & HandlerAPI + +const singupApi = createEndpoint({ + handler: signupEndpoint, + handlers, +}) + +export default singupApi diff --git a/packages/olist/src/api/endpoints/signup/signup.ts b/packages/olist/src/api/endpoints/signup/signup.ts new file mode 100644 index 000000000..ece6dd931 --- /dev/null +++ b/packages/olist/src/api/endpoints/signup/signup.ts @@ -0,0 +1,48 @@ +import { sign } from 'jsonwebtoken' +import { serialize } from 'cookie' + +import type { Handler, SignupEndpoint } from '.' + +const signup: SignupEndpoint['handlers']['signup'] = async ({ + res: response, + body: { firstName, lastName, email, password }, + config: { apiToken, customerTokenCookie, service }, +}: Handler) => { + if (!(firstName && lastName && email && password)) { + return response.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + try { + const { id } = await service.client.signup({ + firstName, + lastName, + email, + password, + passwordConfirmation: password, + }) + + if (id) { + response.setHeader('Set-Cookie', [ + serialize( + customerTokenCookie, + sign({ id: id.toString() }, apiToken, { expiresIn: 60 * 60 }), + { + maxAge: 60 * 60, + expires: new Date(Date.now() + 60 * 60 * 1000), + secure: process.env.NODE_ENV === 'production', + path: '/', + sameSite: 'lax', + } + ), + ]) + } + } catch (error) { + throw error + } + + response.status(200).json({ data: null }) +} + +export default signup diff --git a/packages/olist/src/api/endpoints/wishlist/index.tsx b/packages/olist/src/api/endpoints/wishlist/index.tsx new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/packages/olist/src/api/endpoints/wishlist/index.tsx @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/packages/olist/src/api/index.ts b/packages/olist/src/api/index.ts new file mode 100644 index 000000000..ac5755cfc --- /dev/null +++ b/packages/olist/src/api/index.ts @@ -0,0 +1,86 @@ +import { getCommerceApi as commerceApi } from '@vercel/commerce/api' + +import type { CommerceAPI, CommerceAPIConfig } from '@vercel/commerce/api' +import type { VndaServiceSingleton } from '@vnda/headless-framework' + +import createGraphqlFetcher from './utils/fetch-graphql' + +import { client } from '../api/utils/fetch' + +import getPage from '../api/operations/get-page' +import getProduct from '../api/operations/get-product' +import getSiteInfo from '../api/operations/get-site-info' +import getAllPages from '../api/operations/get-all-pages' +import getAllProducts from '../api/operations/get-all-products' +import getAllProductPaths from '../api/operations/get-all-product-paths' + +import { + API_URL, + API_VERSION, + CART_COOKIE, + CUSTOMER_COOKIE, + TOKEN_COOKIE, + API_TOKEN, + STORE_DOMAIN, + CART_TOKEN_COOKIE, + CUSTOMER_TOKEN_COOKIE, +} from '../constants' + +if (!API_TOKEN) { + throw new Error( + `The environment variable NEXT_PUBLIC_OLIST_STOREFRONT_ACCESS_TOKEN is missing and it's required to access your store` + ) +} + +if (!STORE_DOMAIN) { + throw new Error( + `The environment variable NEXT_PUBLIC_OLIST_STOREFRONT_DOMAIN is missing and it's required to access your store` + ) +} + +export interface OlistConfig extends CommerceAPIConfig { + commerceUrl: string + service: VndaServiceSingleton + apiVersion: string + tokenCookie: string + storeDomain: string + cartTokenCookie: string + customerTokenCookie: string +} + +const ONE_DAY = 60 * 60 * 24 + +const config: OlistConfig = { + commerceUrl: API_URL, + apiToken: API_TOKEN, + storeDomain: STORE_DOMAIN, + apiVersion: API_VERSION, + cartCookie: CART_COOKIE, + customerCookie: CUSTOMER_COOKIE, + tokenCookie: TOKEN_COOKIE, + cartTokenCookie: CART_TOKEN_COOKIE, + customerTokenCookie: CUSTOMER_TOKEN_COOKIE, + cartCookieMaxAge: ONE_DAY * 30, + fetch: createGraphqlFetcher(() => getCommerceApi().getConfig()), + service: client(API_TOKEN, STORE_DOMAIN), +} + +const operations = { + getAllPages, + getPage, + getSiteInfo, + getAllProductPaths, + getAllProducts, + getProduct, +} + +export const provider = { config, operations } + +export type Provider = typeof provider +export type OlistAPI

= CommerceAPI

+ +export function getCommerceApi

( + customProvider: P = provider as any +): OlistAPI

{ + return commerceApi(customProvider as any) +} diff --git a/packages/olist/src/api/operations/get-all-pages.ts b/packages/olist/src/api/operations/get-all-pages.ts new file mode 100644 index 000000000..466bd8656 --- /dev/null +++ b/packages/olist/src/api/operations/get-all-pages.ts @@ -0,0 +1,22 @@ +import { GetAllPagesOperation } from '@vercel/commerce/types/page' + +import type { OlistConfig } from '../' + +export type Page = { url: string } +export type GetAllPagesResult = { pages: Page[] } + +export default function getAllPagesOperation() { + async function getAllPages({ + config, + preview, + }: { + url?: string + config?: Partial + preview?: boolean + } = {}): Promise { + return Promise.resolve({ + pages: [], + }) + } + return getAllPages +} diff --git a/packages/olist/src/api/operations/get-all-product-paths.ts b/packages/olist/src/api/operations/get-all-product-paths.ts new file mode 100644 index 000000000..1a1eb04b4 --- /dev/null +++ b/packages/olist/src/api/operations/get-all-product-paths.ts @@ -0,0 +1,30 @@ +import type { OperationContext } from '@vercel/commerce/api/operations' +import type { GetAllProductPathsOperation } from '@vercel/commerce/types/product' + +import type { OlistConfig, Provider } from '../' + +export type GetAllProductPathsResult = { + products: Array<{ path: string }> +} + +export default function getAllProductPathsOperation({ + commerce, +}: OperationContext) { + async function getAllProductPaths({ + config, + }: { + config?: Partial + } = {}): Promise { + const { service } = commerce.getConfig(config) + + const products = await service.product.list({ limit: 20 }) + + return { + products: products.map((product) => ({ + path: `/${product.slug}-${product.id}`, + })), + } + } + + return getAllProductPaths +} diff --git a/packages/olist/src/api/operations/get-all-products.ts b/packages/olist/src/api/operations/get-all-products.ts new file mode 100644 index 000000000..08002e4d2 --- /dev/null +++ b/packages/olist/src/api/operations/get-all-products.ts @@ -0,0 +1,28 @@ +import type { OperationContext } from '@vercel/commerce/api/operations' +import type { GetAllProductsOperation } from '@vercel/commerce/types/product' + +import type { OlistConfig, Provider } from '..' +import { mapItemRawToCommerceResponse } from '../../utils/product' + +export default function getAllProductsOperation({ + commerce, +}: OperationContext) { + async function getAllProducts({ + config, + }: { + query?: string + variables?: T['variables'] + config?: Partial + preview?: boolean + } = {}): Promise { + const { service } = commerce.getConfig(config) + + const products = await service.product.list({ limit: 20 }) + + return { + products: products.map(mapItemRawToCommerceResponse), + } + } + + return getAllProducts +} diff --git a/packages/olist/src/api/operations/get-page.ts b/packages/olist/src/api/operations/get-page.ts new file mode 100644 index 000000000..e403d1660 --- /dev/null +++ b/packages/olist/src/api/operations/get-page.ts @@ -0,0 +1,15 @@ +import { GetPageOperation } from '@vercel/commerce/types/page' + +export type Page = any +export type GetPageResult = { page?: Page } + +export type PageVariables = { + id: number +} + +export default function getPageOperation() { + async function getPage(): Promise { + return Promise.resolve({}) + } + return getPage +} diff --git a/packages/olist/src/api/operations/get-product.ts b/packages/olist/src/api/operations/get-product.ts new file mode 100644 index 000000000..6600a7635 --- /dev/null +++ b/packages/olist/src/api/operations/get-product.ts @@ -0,0 +1,51 @@ +import type { OperationContext } from '@vercel/commerce/api/operations' +import type { GetProductOperation } from '@vercel/commerce/types/product' + +import type { OlistConfig, Provider } from '..' + +import { + mapItemRawToCommerceResponse, + extractProductId, + mapImagesRawToCommerceResponse, + mapVariantsRawToCommerceResponse, + mapVariantsOptionsToCommerceCommerce, +} from '../../utils/product' + +export default function getProductOperation({ + commerce, +}: OperationContext) { + async function getProduct({ + config, + variables, + }: { + query?: string + variables?: T['variables'] + config?: Partial + preview?: boolean + } = {}): Promise { + const { service } = commerce.getConfig(config) + + const productId = extractProductId(variables?.slug || variables?.path) + + const productPromise = service.product.getById(Number(productId)) + const variantsPromise = service.variant.list(Number(productId)) + const imagesPromise = service.product.image.list(Number(productId)) + + const [product, images, variants] = await Promise.all([ + productPromise, + imagesPromise, + variantsPromise, + ]) + + return { + product: { + ...mapItemRawToCommerceResponse(product), + images: mapImagesRawToCommerceResponse(images), + variants: mapVariantsRawToCommerceResponse(variants), + options: mapVariantsOptionsToCommerceCommerce(variants), + }, + } + } + + return getProduct +} diff --git a/packages/olist/src/api/operations/get-site-info.ts b/packages/olist/src/api/operations/get-site-info.ts new file mode 100644 index 000000000..f5aedc826 --- /dev/null +++ b/packages/olist/src/api/operations/get-site-info.ts @@ -0,0 +1,44 @@ +import type { OperationContext } from '@vercel/commerce/api/operations' +import type { + Category, + GetSiteInfoOperation, +} from '@vercel/commerce/types/site' + +import type { OlistConfig, Provider } from '..' + +export type GetSiteInfoResult< + T extends { categories: any[]; brands: any[] } = { + categories: Category[] + brands: any[] + } +> = T + +export default function getSiteInfoOperation({ + commerce, +}: OperationContext) { + async function getSiteInfo({ + config, + }: { + query?: string + variables?: any + config?: Partial + preview?: boolean + } = {}): Promise { + const { service } = commerce.getConfig(config) + + const tags = await service.tag.list({ paginate: { perPage: 10 } }) + + return { + categories: + tags?.map((category, idx) => ({ + id: category.name, + name: category.name || `name-${idx}`, + slug: category.name || `slug-${idx}`, + path: `/${category.name}`, + })) || [], + brands: [], + } + } + + return getSiteInfo +} diff --git a/packages/olist/src/api/operations/index.ts b/packages/olist/src/api/operations/index.ts new file mode 100644 index 000000000..84b04a978 --- /dev/null +++ b/packages/olist/src/api/operations/index.ts @@ -0,0 +1,6 @@ +export { default as getAllPages } from './get-all-pages' +export { default as getPage } from './get-page' +export { default as getSiteInfo } from './get-site-info' +export { default as getProduct } from './get-product' +export { default as getAllProducts } from './get-all-products' +export { default as getAllProductPaths } from './get-all-product-paths' diff --git a/packages/olist/src/api/utils/fetch-graphql.ts b/packages/olist/src/api/utils/fetch-graphql.ts new file mode 100644 index 000000000..7197e6260 --- /dev/null +++ b/packages/olist/src/api/utils/fetch-graphql.ts @@ -0,0 +1,14 @@ +import type { GraphQLFetcher } from '@vercel/commerce/api' +import type { OlistConfig } from '../' + +import { FetcherError } from '@vercel/commerce/utils/errors' + +const fetchGraphqlApi: (getConfig: () => OlistConfig) => GraphQLFetcher = + () => async () => { + throw new FetcherError({ + errors: [{ message: 'GraphQL fetch is not implemented' }], + status: 500, + }) + } + +export default fetchGraphqlApi diff --git a/packages/olist/src/api/utils/fetch-rest.ts b/packages/olist/src/api/utils/fetch-rest.ts new file mode 100644 index 000000000..e9b427291 --- /dev/null +++ b/packages/olist/src/api/utils/fetch-rest.ts @@ -0,0 +1,127 @@ +import vercelFetch from '@vercel/fetch' +import { FetcherError } from '@vercel/commerce/utils/errors' + +import { OlistConfig } from '..' + +export type FetchRest = ( + method: string, + resource: string, + body?: Record, + fetchOptions?: Record +) => Promise + +// Get an instance to vercel fetch +const fetch = vercelFetch() + +export async function fetchData(opts: { + token: string + path: string + method: string + config: OlistConfig + fetchOptions?: Record + body?: Record +}): Promise { + // Destructure opts + const { path, body, fetchOptions, config, token, method = 'GET' } = opts + + // Do the request with the correct headers + const dataResponse = await fetch( + `${config.commerceUrl}/${config.apiVersion}${path}`, + { + ...fetchOptions, + method, + headers: { + ...fetchOptions?.headers, + 'Content-Type': 'application/json', + accept: 'application/json, text/plain, */*', + authorization: `Bearer ${token}`, + }, + body: body ? JSON.stringify(body) : undefined, + } + ) + + // If something failed getting the data response + if (!dataResponse.ok) { + // Get the body of it + const error = await dataResponse.textConverted() + + // And return an error + throw new FetcherError({ + errors: [{ message: error || dataResponse.statusText }], + status: dataResponse.status, + }) + } + + try { + const result = { data: await dataResponse.json() } + // Return data response as json + return result as unknown as Promise + } catch (error) { + // If response is empty return it as text + return null as unknown as Promise + } +} + +export const createMiddlewareFetcher: ( + getConfig: () => OlistConfig +) => FetchRest = + (getConfig) => + async ( + method: string, + path: string, + body?: Record, + fetchOptions?: Record + ) => { + // Get provider config + const config = getConfig() + + // Get a token + const token = config.apiToken + + // Return the data and specify the expected type + return fetchData({ + token, + fetchOptions, + method, + config, + path, + body, + }) + } + +export const createBuyerFetcher: (getConfig: () => OlistConfig) => FetchRest = + (getConfig) => + async ( + method: string, + path: string, + body?: Record, + fetchOptions?: Record + ) => { + // Get provider config + const config = getConfig() + + // If a token was passed, set it on global + if (fetchOptions?.token) { + ;(global as any).token = fetchOptions.token + } + + // Get a token + if (!(global as any).token) { + ;(global as any).token = config.apiToken + } + + // Return the data and specify the expected type + const data = await fetchData({ + token: (global as any).token as string, + fetchOptions, + config, + method, + path, + body, + }) + + return { + ...data, + meta: { token: (global as any).token as string }, + } + } diff --git a/packages/olist/src/api/utils/fetch.ts b/packages/olist/src/api/utils/fetch.ts new file mode 100644 index 000000000..cbaf95eac --- /dev/null +++ b/packages/olist/src/api/utils/fetch.ts @@ -0,0 +1,9 @@ +import { + VndaServiceInstance, + VndaServiceSingleton, +} from '@vnda/headless-framework' + +export const client = ( + apiToken: string, + shopHost: string +): VndaServiceSingleton => VndaServiceInstance(apiToken, { shopHost }) diff --git a/packages/olist/src/auth/index.ts b/packages/olist/src/auth/index.ts new file mode 100644 index 000000000..36e757a89 --- /dev/null +++ b/packages/olist/src/auth/index.ts @@ -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' diff --git a/packages/olist/src/auth/use-login.tsx b/packages/olist/src/auth/use-login.tsx new file mode 100644 index 000000000..015d07e3b --- /dev/null +++ b/packages/olist/src/auth/use-login.tsx @@ -0,0 +1,43 @@ +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 '@vercel/commerce/types/login' +import useCustomer from '../customer/use-customer' + +export default useLogin as UseLogin + +export const handler: MutationHook = { + 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 }) => + function useHook() { + const { mutate } = useCustomer() + + return useCallback( + async function login(input) { + const data = await fetch({ input }) + + await mutate() + + return data + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [fetch, mutate] + ) + }, +} diff --git a/packages/olist/src/auth/use-logout.tsx b/packages/olist/src/auth/use-logout.tsx new file mode 100644 index 000000000..3b55f127f --- /dev/null +++ b/packages/olist/src/auth/use-logout.tsx @@ -0,0 +1,30 @@ +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 '@vercel/commerce/types/logout' +import useCustomer from '../customer/use-customer' + +export default useLogout as UseLogout + +export const handler: MutationHook = { + fetchOptions: { + url: '/api/logout', + method: 'GET', + }, + useHook: ({ fetch }) => + function useHook() { + const { mutate } = useCustomer() + + return useCallback( + async function logout() { + const data = await fetch() + + await mutate(null, false) + + return data + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [fetch, mutate] + ) + }, +} diff --git a/packages/olist/src/auth/use-signup.tsx b/packages/olist/src/auth/use-signup.tsx new file mode 100644 index 000000000..acdd7bc90 --- /dev/null +++ b/packages/olist/src/auth/use-signup.tsx @@ -0,0 +1,48 @@ +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 '@vercel/commerce/types/signup' +import useCustomer from '../customer/use-customer' + +export default useSignup as UseSignup + +export const handler: MutationHook = { + 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 }) => + function useHook() { + const { mutate } = useCustomer() + + return useCallback( + async function signup(input) { + const data = await fetch({ input }) + + await mutate() + + return data + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [fetch, mutate] + ) + }, +} diff --git a/packages/olist/src/cart/index.ts b/packages/olist/src/cart/index.ts new file mode 100644 index 000000000..3b8ba990e --- /dev/null +++ b/packages/olist/src/cart/index.ts @@ -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' diff --git a/packages/olist/src/cart/use-add-item.tsx b/packages/olist/src/cart/use-add-item.tsx new file mode 100644 index 000000000..1ae72583a --- /dev/null +++ b/packages/olist/src/cart/use-add-item.tsx @@ -0,0 +1,49 @@ +import { useCallback } from 'react' +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 type { MutationHook } from '@vercel/commerce/utils/types' + +import useCart from './use-cart' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + 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 }) => + function useHook() { + const { mutate } = useCart() + + return useCallback( + async function addItem(input) { + const data = await fetch({ input }) + + await mutate(data, false) + + return data + }, + [mutate] + ) + }, +} diff --git a/packages/olist/src/cart/use-cart.tsx b/packages/olist/src/cart/use-cart.tsx new file mode 100644 index 000000000..2e0226d32 --- /dev/null +++ b/packages/olist/src/cart/use-cart.tsx @@ -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 + +export const handler: SWRHook = { + fetchOptions: { + url: '/api/cart', + method: 'GET', + }, + useHook: ({ useData }) => + function useHook(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] + ) + }, +} diff --git a/packages/olist/src/cart/use-remove-item.tsx b/packages/olist/src/cart/use-remove-item.tsx new file mode 100644 index 000000000..944c121de --- /dev/null +++ b/packages/olist/src/cart/use-remove-item.tsx @@ -0,0 +1,66 @@ +import { useCallback } from 'react' +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 type { + MutationHookContext, + HookFetcherContext, +} from '@vercel/commerce/utils/types' + +import useCart from './use-cart' + +export type RemoveItemFn = T extends LineItem + ? (input?: RemoveItemActionInput) => Promise + : (input: RemoveItemActionInput) => Promise + +export type RemoveItemActionInput = T extends LineItem + ? Partial + : RemoveItemHook['actionInput'] + +export default useRemoveItem as UseRemoveItem + +export const handler = { + fetchOptions: { + url: '/api/cart', + method: 'DELETE', + }, + async fetcher({ + input: { itemId }, + options, + fetch, + }: HookFetcherContext) { + return await fetch({ ...options, body: { itemId } }) + }, + useHook: ({ fetch }: MutationHookContext) => + function useHook( + ctx: { item?: T } = {} + ) { + const { item } = ctx + const { mutate } = useCart() + const removeItem: RemoveItemFn = 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 + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + return useCallback(removeItem as RemoveItemFn, [fetch, mutate]) + }, +} diff --git a/packages/olist/src/cart/use-update-item.tsx b/packages/olist/src/cart/use-update-item.tsx new file mode 100644 index 000000000..6e791d336 --- /dev/null +++ b/packages/olist/src/cart/use-update-item.tsx @@ -0,0 +1,94 @@ +import { useCallback } from 'react' +import debounce from 'lodash.debounce' +import { MutationHook } from '@vercel/commerce/utils/types' +import { ValidationError } from '@vercel/commerce/utils/errors' +import useUpdateItem, { + UseUpdateItem, +} from '@vercel/commerce/cart/use-update-item' + +import type { UpdateItemHook, LineItem } from '@vercel/commerce/types/cart' +import type { + HookFetcherContext, + MutationHookContext, +} from '@vercel/commerce/utils/types' + +import useCart from './use-cart' +import { handler as removeItemHandler } from './use-remove-item' + +export type UpdateItemActionInput = T extends LineItem + ? Partial + : UpdateItemHook['actionInput'] + +export default useUpdateItem as UseUpdateItem + +export const handler: MutationHook = { + fetchOptions: { + url: '/api/cart', + method: 'PUT', + }, + async fetcher({ + input: { itemId, item }, + options, + fetch, + }: HookFetcherContext) { + if (Number.isInteger(item.quantity)) { + 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) => + function useHook( + ctx: { + item?: T + wait?: number + } = {} + ) { + const { item } = ctx + const { mutate } = useCart() as any + + // eslint-disable-next-line react-hooks/exhaustive-deps + return useCallback( + debounce(async (input: UpdateItemActionInput) => { + const itemId = input.id ?? item?.id + const productId = input.productId ?? item?.productId + const variantId = input.productId ?? item?.variantId + + if (!itemId || !productId) { + throw new ValidationError({ + message: 'Invalid input used for this operation', + }) + } + + const data = await fetch({ + input: { + itemId, + item: { + productId, + variantId: variantId || '', + quantity: input.quantity, + }, + }, + }) + + await mutate(data, false) + + return data + }, ctx.wait ?? 500), + [fetch, mutate] + ) + }, +} diff --git a/packages/olist/src/checkout/index.ts b/packages/olist/src/checkout/index.ts new file mode 100644 index 000000000..306621059 --- /dev/null +++ b/packages/olist/src/checkout/index.ts @@ -0,0 +1,2 @@ +export { default as useSubmitCheckout } from './use-submit-checkout' +export { default as useCheckout } from './use-checkout' diff --git a/packages/olist/src/checkout/use-checkout.tsx b/packages/olist/src/checkout/use-checkout.tsx new file mode 100644 index 000000000..fdb0024ee --- /dev/null +++ b/packages/olist/src/checkout/use-checkout.tsx @@ -0,0 +1,43 @@ +import type { GetCheckoutHook } from '@vercel/commerce/types/checkout' + +import { useMemo } from 'react' +import { SWRHook } from '@vercel/commerce/utils/types' +import useCheckout, { + UseCheckout, +} from '@vercel/commerce/checkout/use-checkout' +import useSubmitCheckout from './use-submit-checkout' + +export default useCheckout as UseCheckout + +export const handler: SWRHook = { + fetchOptions: { + url: '/api/checkout', + method: 'GET', + }, + useHook: ({ useData }) => + function useHook(input) { + const submit = useSubmitCheckout() + const response = useData({ + swrOptions: { revalidateOnFocus: false, ...input?.swrOptions }, + }) + + return useMemo( + () => + Object.create(response, { + isEmpty: { + get() { + return (response.data?.lineItems?.length ?? 0) <= 0 + }, + enumerable: true, + }, + submit: { + get() { + return submit + }, + enumerable: true, + }, + }), + [response, submit] + ) + }, +} diff --git a/packages/olist/src/checkout/use-submit-checkout.tsx b/packages/olist/src/checkout/use-submit-checkout.tsx new file mode 100644 index 000000000..79c438499 --- /dev/null +++ b/packages/olist/src/checkout/use-submit-checkout.tsx @@ -0,0 +1,38 @@ +import type { SubmitCheckoutHook } from '@vercel/commerce/types/checkout' +import type { MutationHook } from '@vercel/commerce/utils/types' + +import { useCallback } from 'react' +import useSubmitCheckout, { + UseSubmitCheckout, +} from '@vercel/commerce/checkout/use-submit-checkout' + +export default useSubmitCheckout as UseSubmitCheckout + +export const handler: MutationHook = { + fetchOptions: { + url: '/api/checkout', + method: 'POST', + }, + async fetcher({ input: item, options, fetch }) { + // @TODO: Make form validations in here, import generic error like import { CommerceError } from '@vercel/commerce/utils/errors' + // Get payment and delivery information in here + + const data = await fetch({ + ...options, + body: { item }, + }) + + return data + }, + useHook: ({ fetch }) => + function useHook() { + return useCallback( + async function onSubmitCheckout(input) { + const data = await fetch({ input }) + + return data + }, + [fetch] + ) + }, +} diff --git a/packages/olist/src/commerce.config.json b/packages/olist/src/commerce.config.json new file mode 100644 index 000000000..8ec4586d2 --- /dev/null +++ b/packages/olist/src/commerce.config.json @@ -0,0 +1,10 @@ +{ + "provider": "olist", + "features": { + "wishlist": false, + "cart": true, + "search": true, + "customerAuth": false, + "customCheckout": false + } +} diff --git a/packages/olist/src/constants.ts b/packages/olist/src/constants.ts new file mode 100644 index 000000000..3f1c775cb --- /dev/null +++ b/packages/olist/src/constants.ts @@ -0,0 +1,10 @@ +export const CART_COOKIE = 'olist.vnda.cart' +export const CART_TOKEN_COOKIE = 'olist.vnda.cart.token' +export const TOKEN_COOKIE = 'olist.vnda.token' +export const CUSTOMER_COOKIE = 'olist.vnda.customer' +export const API_URL = `https://${process.env.NEXT_PUBLIC_OLIST_STOREFRONT_DOMAIN}/api` +export const API_VERSION = 'v2' +export const LOCALE = 'en-us' +export const STORE_DOMAIN = process.env.NEXT_PUBLIC_OLIST_STOREFRONT_DOMAIN +export const API_TOKEN = process.env.NEXT_PUBLIC_OLIST_STOREFRONT_ACCESS_TOKEN +export const CUSTOMER_TOKEN_COOKIE = 'olist.vnda.customer.token' diff --git a/packages/olist/src/customer/address/use-add-item.tsx b/packages/olist/src/customer/address/use-add-item.tsx new file mode 100644 index 000000000..edc900824 --- /dev/null +++ b/packages/olist/src/customer/address/use-add-item.tsx @@ -0,0 +1,18 @@ +import useAddItem, { + UseAddItem, +} from '@vercel/commerce/customer/address/use-add-item' + +import type { MutationHook } from '@vercel/commerce/utils/types' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + query: '', + }, + async fetcher({ input, options, fetch }) {}, + useHook: + ({ fetch }) => + () => + async () => ({}), +} diff --git a/packages/olist/src/customer/card/use-add-item.tsx b/packages/olist/src/customer/card/use-add-item.tsx new file mode 100644 index 000000000..93f45b66e --- /dev/null +++ b/packages/olist/src/customer/card/use-add-item.tsx @@ -0,0 +1,18 @@ +import useAddItem, { + UseAddItem, +} from '@vercel/commerce/customer/card/use-add-item' + +import type { MutationHook } from '@vercel/commerce/utils/types' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + query: '', + }, + async fetcher({ input, options, fetch }) {}, + useHook: + ({ fetch }) => + () => + async () => ({}), +} diff --git a/packages/olist/src/customer/index.ts b/packages/olist/src/customer/index.ts new file mode 100644 index 000000000..6c903ecc5 --- /dev/null +++ b/packages/olist/src/customer/index.ts @@ -0,0 +1 @@ +export { default as useCustomer } from './use-customer' diff --git a/packages/olist/src/customer/use-customer.tsx b/packages/olist/src/customer/use-customer.tsx new file mode 100644 index 000000000..f1c3fa704 --- /dev/null +++ b/packages/olist/src/customer/use-customer.tsx @@ -0,0 +1,29 @@ +import { SWRHook } from '@vercel/commerce/utils/types' +import useCustomer, { + UseCustomer, +} from '@vercel/commerce/customer/use-customer' +import type { CustomerHook } from '@vercel/commerce/types/customer' + +export default useCustomer as UseCustomer + +export const handler: SWRHook = { + fetchOptions: { + url: '/api/customer', + method: 'GET', + }, + async fetcher({ options, fetch }) { + const data = await fetch(options) + return data?.customer ?? null + }, + useHook: + ({ useData }) => + (input) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + return useData({ + swrOptions: { + revalidateOnFocus: false, + ...input?.swrOptions, + }, + }) + }, +} diff --git a/packages/olist/src/fetcher.ts b/packages/olist/src/fetcher.ts new file mode 100644 index 000000000..1da35718e --- /dev/null +++ b/packages/olist/src/fetcher.ts @@ -0,0 +1,17 @@ +import { Fetcher } from '@vercel/commerce/utils/types' + +const clientFetcher: Fetcher = async ({ method, url, body }) => { + const response = await fetch(url!, { + method, + body: body ? JSON.stringify(body) : undefined, + headers: { + 'Content-Type': 'application/json', + }, + }) + .then((response) => response.json()) + .then((response) => response.data) + + return response +} + +export default clientFetcher diff --git a/packages/olist/src/index.tsx b/packages/olist/src/index.tsx new file mode 100644 index 000000000..806640c94 --- /dev/null +++ b/packages/olist/src/index.tsx @@ -0,0 +1,12 @@ +import { olistProvider, OlistProvider } from './provider' +import { + getCommerceProvider, + useCommerce as useCoreCommerce, +} from '@vercel/commerce' + +export { olistProvider } +export type { OlistProvider } + +export const CommerceProvider = getCommerceProvider(olistProvider) + +export const useCommerce = () => useCoreCommerce() diff --git a/packages/olist/src/next.config.cjs b/packages/olist/src/next.config.cjs new file mode 100644 index 000000000..76baee81e --- /dev/null +++ b/packages/olist/src/next.config.cjs @@ -0,0 +1,16 @@ +const commerce = require('./commerce.config.json') + +module.exports = { + commerce, + images: { + domains: [ + 'localhost', + 'b0.vnda.com.br', + 'b1.vnda.com.br', + 'b2.vnda.com.br', + 'b3.vnda.com.br', + 'b4.vnda.com.br', + 'cdn.vnda.dev', + ], + }, +} diff --git a/packages/olist/src/product/index.ts b/packages/olist/src/product/index.ts new file mode 100644 index 000000000..426a3edcd --- /dev/null +++ b/packages/olist/src/product/index.ts @@ -0,0 +1,2 @@ +export { default as usePrice } from './use-price' +export { default as useSearch } from './use-search' diff --git a/packages/olist/src/product/use-price.tsx b/packages/olist/src/product/use-price.tsx new file mode 100644 index 000000000..fd42d7033 --- /dev/null +++ b/packages/olist/src/product/use-price.tsx @@ -0,0 +1,2 @@ +export * from '@vercel/commerce/product/use-price' +export { default } from '@vercel/commerce/product/use-price' diff --git a/packages/olist/src/product/use-search.tsx b/packages/olist/src/product/use-search.tsx new file mode 100644 index 000000000..76e91a8bb --- /dev/null +++ b/packages/olist/src/product/use-search.tsx @@ -0,0 +1,39 @@ +import { SWRHook } from '@vercel/commerce/utils/types' +import useSearch, { UseSearch } from '@vercel/commerce/product/use-search' +import { SearchProductsHook } from '@vercel/commerce/types/product' +export default useSearch as UseSearch + +export const handler: SWRHook = { + fetchOptions: { + url: '/api/catalog/products', + method: 'GET', + }, + fetcher({ input: { search, categoryId, brandId, sort }, options, fetch }) { + const url = new URLSearchParams() + + if (search) url.set('search', String(search)) + if (categoryId) url.set('categoryId', String(categoryId)) + if (brandId) url.set('brandId', String(brandId)) + if (sort) url.set('sort', String(sort)) + + return fetch({ + url: `${options.url!}?${url}`, + method: options.method, + }) + }, + useHook: ({ useData }) => + function useHook(input = {}) { + return useData({ + input: [ + ['search', input.search], + ['categoryId', input.categoryId], + ['brandId', input.brandId], + ['sort', input.sort], + ], + swrOptions: { + revalidateOnFocus: false, + ...input.swrOptions, + }, + }) + }, +} diff --git a/packages/olist/src/provider.ts b/packages/olist/src/provider.ts new file mode 100644 index 000000000..46844aa5a --- /dev/null +++ b/packages/olist/src/provider.ts @@ -0,0 +1,51 @@ +import { handler as useCart } from './cart/use-cart' +import { handler as useAddCartItem } from './cart/use-add-item' +import { handler as useUpdateCartItem } from './cart/use-update-item' +import { handler as useRemoveCartItem } from './cart/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 { handler as useCheckout } from './checkout/use-checkout' +import { handler as useSubmitCheckout } from './checkout/use-submit-checkout' + +import { handler as useAddCardItem } from './customer/card/use-add-item' + +import { handler as useAddAddressItem } from './customer/address/use-add-item' + +import { CART_COOKIE, CART_TOKEN_COOKIE, LOCALE } from './constants' +import { default as fetcher } from './fetcher' + +export const olistProvider = { + locale: LOCALE, + cartCookie: CART_COOKIE, + cartTokenCookie: CART_TOKEN_COOKIE, + fetcher, + cart: { + useCart, + useAddItem: useAddCartItem, + useUpdateItem: useUpdateCartItem, + useRemoveItem: useRemoveCartItem, + }, + checkout: { + useCheckout, + useSubmitCheckout, + }, + customer: { + useCustomer, + card: { + useAddItem: useAddCardItem, + }, + address: { + useAddItem: useAddAddressItem, + }, + }, + products: { useSearch }, + auth: { useLogin, useLogout, useSignup }, +} + +export type OlistProvider = typeof olistProvider diff --git a/packages/olist/src/types/api.ts b/packages/olist/src/types/api.ts new file mode 100644 index 000000000..a979fb45d --- /dev/null +++ b/packages/olist/src/types/api.ts @@ -0,0 +1,12 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import type { OlistConfig } from '../api' + +export type Handler = { + req: NextApiRequest + res: NextApiResponse + config: OlistConfig +} + +export type FetcherResponse = { + data: T +} diff --git a/packages/olist/src/types/cart.ts b/packages/olist/src/types/cart.ts new file mode 100644 index 000000000..55199a348 --- /dev/null +++ b/packages/olist/src/types/cart.ts @@ -0,0 +1,95 @@ +export * from '@vercel/commerce/types/cart' + +export type RawCartItemRequest = { + sku: string + quantity: number + extra?: {} + place_id?: number +} + +export type RawCartResponse = { + agent: string + billing_address_id: number + channel: string + client_id: number + code: string + coupon_code: string + discount: { + id: number + name: string + description: string + facebook: false + valid_to: string + seal_uid: string + seal_url: string + start_at: string + end_at: string + email: string + cpf: string + tags: string + } + discount_price: number + extra: Record + id: number + items: RawCartItemResponse[] + items_count: number + shipping_address_id: number + shipping_method: string + shipping_methods: [ + { + package: string + name: string + label: string + price: string + delivery_days: string + delivery_type: string + description: string + short_description: string + } + ] + shipping_price: number + subtotal: number + token: string + total: number + total_for_deposit: number + total_for_slip: number + total_for_pix: number + updated_at: string + rebate_token: string + rebate_discount: number + handling_days: number + subtotal_discount: number + total_discount: number +} + +export type RawCartItemResponse = { + id: string + available_quantity: number + delivery_days: number + extra: Record + place_id: number + price: number + product_id: number + product_name: string + product_reference: string + product_url: string + product_type: string + quantity: number + seller: string + seller_name: string + subtotal: number + total: number + updated_at: string + has_customizations: boolean + image_url: string + variant_attributes: Record + variant_min_quantity: number + variant_name: string + variant_price: number + variant_intl_price: number + variant_properties: Record< + string, + { name: string; value: string; defining: boolean } + > + variant_sku: string +} diff --git a/packages/olist/src/types/category.ts b/packages/olist/src/types/category.ts new file mode 100644 index 000000000..5ee9d3d91 --- /dev/null +++ b/packages/olist/src/types/category.ts @@ -0,0 +1,8 @@ +export interface RawCategory { + id: string + name: string + title: string + subtitle: null | string + description: null | string + image_url: null | string +} diff --git a/packages/olist/src/types/checkout.ts b/packages/olist/src/types/checkout.ts new file mode 100644 index 000000000..976d78e7a --- /dev/null +++ b/packages/olist/src/types/checkout.ts @@ -0,0 +1,4 @@ +import * as Core from '@vercel/commerce/types/checkout' + +export type CheckoutTypes = Core.CheckoutTypes +export type CheckoutSchema = Core.CheckoutSchema diff --git a/packages/olist/src/types/customer.ts b/packages/olist/src/types/customer.ts new file mode 100644 index 000000000..8fde36308 --- /dev/null +++ b/packages/olist/src/types/customer.ts @@ -0,0 +1,51 @@ +export * from '@vercel/commerce/types/customer' + +export type RawCustomerResponse = { + id: number + first_name: string + last_name: string + email: string + gender: string + phone_area: string + phone: string + cpf: string + cnpj: string + ie: string + tags: string + lists: string[] + facebook_uid: string + liked_facebook_page: boolean + updated_at: string + birthdate: string + recent_address: [ + { + id: string + first_name: string + last_name: string + company_name: string + street_name: string + street_number: string + neighborhood: string + complement: string + reference: string + city: string + state: string + zip: string + first_phone_area: string + first_phone: string + second_phone_area: string + second_phone: string + email: string + documents: { + cpf: string + cnpj: string + } + } + ] + auth_token: string + last_confirmed_order_at: string + received_orders_count: number + confirmed_orders_count: number + canceled_orders_count: number + renew_password: boolean +} diff --git a/packages/olist/src/types/global.d.ts b/packages/olist/src/types/global.d.ts new file mode 100644 index 000000000..df6fa5a7a --- /dev/null +++ b/packages/olist/src/types/global.d.ts @@ -0,0 +1,7 @@ +declare global { + namespace NodeJS { + interface Global { + token: string | undefined | null + } + } +} diff --git a/packages/olist/src/types/index.d.ts b/packages/olist/src/types/index.d.ts new file mode 100644 index 000000000..368d290e2 --- /dev/null +++ b/packages/olist/src/types/index.d.ts @@ -0,0 +1,3 @@ +declare module globalThis { + var token: string | null | undefined +} diff --git a/packages/olist/src/types/login.ts b/packages/olist/src/types/login.ts new file mode 100644 index 000000000..dae5a6cf4 --- /dev/null +++ b/packages/olist/src/types/login.ts @@ -0,0 +1,6 @@ +export * from '@vercel/commerce/types/login' + +export type RawLoginResponse = { + id: number + auth_token: string +} diff --git a/packages/olist/src/types/logout.ts b/packages/olist/src/types/logout.ts new file mode 100644 index 000000000..1de06f8dc --- /dev/null +++ b/packages/olist/src/types/logout.ts @@ -0,0 +1 @@ +export * from '@vercel/commerce/types/logout' diff --git a/packages/olist/src/types/product.ts b/packages/olist/src/types/product.ts new file mode 100644 index 000000000..6afb556a0 --- /dev/null +++ b/packages/olist/src/types/product.ts @@ -0,0 +1,63 @@ +export interface RawVariantProperty { + name: string + value: string + defining?: boolean +} + +export interface RawVariantProperties { + [key: string]: RawVariantProperty +} + +export interface RawVariant { + id: number + main: boolean + available: boolean + sku: string + name: string + slug: string + min_quantity: number + quantity: number + quantity_sold: number + stock: number + custom_attributes: object + properties?: RawVariantProperties + updated_at: string + price: number + installments: number[] + available_quantity: number + weight: number + width: number + height: number + length: number + handling_days: number + inventories: any[] + sale_price: number + intl_price: number + image_url: string + product_id: number + barcode: string + norder: number +} + +export interface RawProduct { + id: number + name: string + active: boolean + available: boolean + description: string | null + html_description?: string | null + image_url?: string | null + price: number + sale_price: number + reference: string + slug: string + url: string + variants?: RawVariant[] +} + +export interface RawProductImages { + id: number + url: string + updated_at: string + variant_ids?: string[] | number[] +} diff --git a/packages/olist/src/types/signup.ts b/packages/olist/src/types/signup.ts new file mode 100644 index 000000000..82bb322fe --- /dev/null +++ b/packages/olist/src/types/signup.ts @@ -0,0 +1,58 @@ +export * from '@vercel/commerce/types/signup' + +export type RawSignUpRequest = { + email: string + first_name: string + last_name: string + birthdate: string + gender: string + tags: string + lists: string[] + password: string + password_confirmation: string + terms: boolean +} + +export type RawSignUpResponse = { + id: number + first_name: string + last_name: string + email: string + gender: string + phone_area: string + phone: string + cpf: string + cnpj: string + ie: string + tags: string + lists: string[] + facebook_uid: string + liked_facebook_page: boolean + updated_at: string + birthdate: string + recent_address: [ + { + id: string + first_name: string + last_name: string + company_name: string + street_name: string + street_number: string + neighborhood: string + complement: string + reference: string + city: string + state: string + zip: string + first_phone_area: string + first_phone: string + second_phone_area: string + second_phone: string + email: string + documents: { + cpf: string + cnpj: string + } + } + ] +} diff --git a/packages/olist/src/utils/cart.ts b/packages/olist/src/utils/cart.ts new file mode 100644 index 000000000..1ea138daf --- /dev/null +++ b/packages/olist/src/utils/cart.ts @@ -0,0 +1,77 @@ +import type { Cart, CartItemBody, LineItem } from '@vercel/commerce/types/cart' +import type { + Cart as CartRequest, + CartItem, + CartAddItemRequest, +} from '@vnda/headless-framework' + +import { getLastItem } from './product' + +export const mapCommerceToRawRequest = ({ + quantity, + variantId, +}: CartItemBody): CartAddItemRequest => ({ + quantity: quantity || 1, + sku: variantId, +}) + +export const mapItemRawToCommerceResponse = ({ + id, + quantity, + productId, + productName, + productUrl, + variantName, + variantSku, + variantPrice, + imageUrl, + availableQuantity, + variantProperties, +}: CartItem): LineItem => ({ + id: id.toString(), + variantId: variantSku, + productId: productId.toString(), + name: productName, + quantity: quantity, + discounts: [], + path: getLastItem(productUrl), + variant: { + id: variantSku, + sku: variantSku, + name: variantName, + requiresShipping: false, + price: variantPrice, + listPrice: variantPrice, + isInStock: availableQuantity > 0, + availableForSale: availableQuantity > 0, + image: { + url: imageUrl || 'http://localhost:3000/', + }, + }, + options: Object.values(variantProperties).map(({ name, value }) => ({ + name: name === 'Cor' ? 'Color' : name, + value, + })), +}) + +export const mapRawToCommerceResponse = ({ + id, + clientId, + discountPrice, + subtotal, + total, + items, +}: CartRequest): Cart => ({ + id: id?.toString(), + customerId: clientId?.toString(), + createdAt: '', + currency: { + code: 'BRL', + }, + taxesIncluded: false, + lineItems: items?.map(mapItemRawToCommerceResponse), + lineItemsSubtotalPrice: 0, + subtotalPrice: subtotal, + totalPrice: total, + discounts: [{ value: discountPrice }], +}) diff --git a/packages/olist/src/utils/product.ts b/packages/olist/src/utils/product.ts new file mode 100644 index 000000000..cfeba675a --- /dev/null +++ b/packages/olist/src/utils/product.ts @@ -0,0 +1,122 @@ +import type { + Product, + ProductImage, + ProductOption, + ProductVariant, +} from '@vercel/commerce/types/product' +import type { + Variant, + Product as ProductRequest, + ProductImage as ProductImageRequest, +} from '@vnda/headless-framework' + +export const getLastItem = (path: string) => + path.substring(path.lastIndexOf('/') + 1) + +const getRelativePaths = (url: string) => + !['http:', 'https:'].includes(url) ? `https:${url}` : url + +export const getVariantOptions = (variant: Variant): ProductOption[] => + Object.entries(variant.properties || []) + .filter(([_, { value }]) => !!value && !!value) + .map(([_, property]) => ({ + __typename: 'MultipleChoiceOption', + id: `option-${property.name.toLocaleLowerCase()}`, + displayName: property.name, + values: [{ label: property.value }], + })) + +export const mapImagesRawToCommerceResponse = ( + images: ProductImageRequest[] +): ProductImage[] => + (images || []).map((item) => ({ + url: getRelativePaths(item.url), + alt: `image-for-item-id-${item.id}`, + })) + +export const mapVariantsOptionsToCommerceCommerce = ( + variants: Variant[] +): ProductOption[] => { + if (!variants || !variants.length) { + return [] + } + + let options: ProductOption[] = [] + + variants.forEach((variant) => { + const opts = getVariantOptions(variant) + + opts.forEach((opt) => { + if (options.filter((option) => option.id !== opt.id)) { + options.push({ + id: opt.id, + displayName: opt.displayName, + values: opt.values, + }) + } + }) + }) + + var ids = Array.from(new Set(options.map((d) => d.id))) + + options = ids.map((id) => { + let values: any[] = [] + + options + .filter((option) => option.id === id) + .map(({ values }) => + values?.forEach(({ label, hexColors }) => { + if (label && !values.find((value) => value?.label === label)) { + values.push({ label, hexColors }) + } + }) + ) + + return { + id, + displayName: options.find((option) => option.id === id)?.displayName!, + values: Array.from(new Set(values)), + } + }) + + return options +} + +export const mapVariantsRawToCommerceResponse = ( + variants: Variant[] +): ProductVariant[] => + (variants || []).map((variant) => ({ + id: variant.sku, + options: getVariantOptions(variant), + availableForSale: variant.stock ? variant.stock > 0 : false, + })) + +export const mapItemRawToCommerceResponse = ( + product: ProductRequest +): Product => ({ + id: product.id.toString(), + description: product.description || '', + images: product.imageUrl ? [{ url: getRelativePaths(product.imageUrl) }] : [], + name: product.name, + price: { + value: product.price, + currencyCode: 'BRL', + salePrice: product.salePrice, + }, + ...(product.htmlDescription && { + descriptionHtml: product.htmlDescription, + }), + sku: product.reference, + slug: `${product.slug}-${product.id.toString()}`, + path: product.url ? getLastItem(product.url) : undefined, + options: [], + variants: [], +}) + +export const extractProductId = (value?: string) => { + if (!value || typeof value !== 'string') { + return value + } + + return value.substring(value.lastIndexOf('-') + 1) +} diff --git a/packages/olist/src/utils/signup.ts b/packages/olist/src/utils/signup.ts new file mode 100644 index 000000000..dffb249a2 --- /dev/null +++ b/packages/olist/src/utils/signup.ts @@ -0,0 +1,15 @@ +import type { RawSignUpRequest, SignupBody } from '../types/signup' + +export const mapCommerceToRawRequest = ({ + email, + firstName, + lastName, + password, +}: SignupBody): Partial => ({ + first_name: firstName, + last_name: lastName, + email, + password, + password_confirmation: password, + terms: true, +}) diff --git a/packages/olist/src/wishlist/use-add-item.tsx b/packages/olist/src/wishlist/use-add-item.tsx new file mode 100644 index 000000000..75f067c3a --- /dev/null +++ b/packages/olist/src/wishlist/use-add-item.tsx @@ -0,0 +1,13 @@ +import { useCallback } from 'react' + +export function emptyHook() { + const useEmptyHook = async (options = {}) => { + return useCallback(async function () { + return Promise.resolve() + }, []) + } + + return useEmptyHook +} + +export default emptyHook diff --git a/packages/olist/src/wishlist/use-remove-item.tsx b/packages/olist/src/wishlist/use-remove-item.tsx new file mode 100644 index 000000000..a2d3a8a05 --- /dev/null +++ b/packages/olist/src/wishlist/use-remove-item.tsx @@ -0,0 +1,17 @@ +import { useCallback } from 'react' + +type Options = { + includeProducts?: boolean +} + +export function emptyHook(options?: Options) { + const useEmptyHook = async ({ id }: { id: string | number }) => { + return useCallback(async function () { + return Promise.resolve() + }, []) + } + + return useEmptyHook +} + +export default emptyHook diff --git a/packages/olist/src/wishlist/use-wishlist.tsx b/packages/olist/src/wishlist/use-wishlist.tsx new file mode 100644 index 000000000..b2785d46f --- /dev/null +++ b/packages/olist/src/wishlist/use-wishlist.tsx @@ -0,0 +1,43 @@ +import { HookFetcher } from '@vercel/commerce/utils/types' +import type { Product } from '@vercel/commerce/types/product' + +const defaultOpts = {} + +export type Wishlist = { + items: [ + { + product_id: number + variant_id: number + id: number + product: Product + } + ] +} + +export interface UseWishlistOptions { + includeProducts?: boolean +} + +export interface UseWishlistInput extends UseWishlistOptions { + customerId?: number +} + +export const fetcher: HookFetcher = () => { + return null +} + +export function extendHook( + customFetcher: typeof fetcher, + // swrOptions?: SwrOptions + swrOptions?: any +) { + const useWishlist = ({ includeProducts }: UseWishlistOptions = {}) => { + return { data: null } + } + + useWishlist.extend = extendHook + + return useWishlist +} + +export default extendHook(fetcher) diff --git a/packages/olist/taskfile.js b/packages/olist/taskfile.js new file mode 100644 index 000000000..39b1b2a86 --- /dev/null +++ b/packages/olist/taskfile.js @@ -0,0 +1,20 @@ +export async function build(task, opts) { + await task + .source('src/**/*.+(ts|tsx|js)') + .swc({ dev: opts.dev, outDir: 'dist', baseUrl: 'src' }) + .target('dist') + .source('src/**/*.+(cjs|json)') + .target('dist') + task.$.log('Compiled src files') +} + +export async function release(task) { + await task.clear('dist').start('build') +} + +export default async function dev(task) { + const opts = { dev: true } + await task.clear('dist') + await task.start('build', opts) + await task.watch('src/**/*.+(ts|tsx|js|cjs|json)', 'build', opts) +} diff --git a/packages/olist/tsconfig.json b/packages/olist/tsconfig.json new file mode 100644 index 000000000..cd04ab2ff --- /dev/null +++ b/packages/olist/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "outDir": "dist", + "baseUrl": "src", + "lib": ["dom", "dom.iterable", "esnext"], + "declaration": true, + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "incremental": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ordercloud/package.json b/packages/ordercloud/package.json index 43eeac9ad..81d2eaf98 100644 --- a/packages/ordercloud/package.json +++ b/packages/ordercloud/package.json @@ -65,7 +65,7 @@ "@types/node": "^17.0.8", "@types/react": "^17.0.38", "lint-staged": "^12.1.7", - "next": "^12.0.8", + "next": "^12", "prettier": "^2.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/packages/saleor/package.json b/packages/saleor/package.json index 16fb0a654..e9a6d80fd 100644 --- a/packages/saleor/package.json +++ b/packages/saleor/package.json @@ -70,7 +70,7 @@ "@types/node": "^17.0.8", "@types/react": "^17.0.38", "lint-staged": "^12.1.7", - "next": "^12.0.8", + "next": "^12", "prettier": "^2.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/packages/sfcc/package.json b/packages/sfcc/package.json index c04dc1909..e18414c12 100644 --- a/packages/sfcc/package.json +++ b/packages/sfcc/package.json @@ -63,7 +63,7 @@ "@types/node": "^17.0.8", "@types/react": "^17.0.38", "lint-staged": "^12.1.7", - "next": "^12.0.8", + "next": "^12", "prettier": "^2.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/packages/shopify/package.json b/packages/shopify/package.json index 6cc45b47e..420e2bec7 100644 --- a/packages/shopify/package.json +++ b/packages/shopify/package.json @@ -71,7 +71,7 @@ "@types/react": "^17.0.38", "dotenv": "^12.0.3", "lint-staged": "^12.1.7", - "next": "^12.0.8", + "next": "^12", "prettier": "^2.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/packages/spree/package.json b/packages/spree/package.json index 491a12df9..846881d96 100644 --- a/packages/spree/package.json +++ b/packages/spree/package.json @@ -66,7 +66,7 @@ "@types/node": "^17.0.8", "@types/react": "^17.0.38", "lint-staged": "^12.1.7", - "next": "^12.0.8", + "next": "^12", "prettier": "^2.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/packages/spree/src/utils/require-config.ts b/packages/spree/src/utils/require-config.ts index 92b7916ca..4424e3690 100644 --- a/packages/spree/src/utils/require-config.ts +++ b/packages/spree/src/utils/require-config.ts @@ -6,7 +6,7 @@ const requireConfig = (isomorphicConfig: T, key: keyof T) => { if (typeof valueUnderKey === 'undefined') { throw new MissingConfigurationValueError( - `Value for configuration key ${key} was undefined.` + `Value for configuration key ${key as string} was undefined.` ) } diff --git a/packages/swell/package.json b/packages/swell/package.json index 27a179882..fcf06ec44 100644 --- a/packages/swell/package.json +++ b/packages/swell/package.json @@ -66,7 +66,7 @@ "@types/node": "^17.0.8", "@types/react": "^17.0.38", "lint-staged": "^12.1.7", - "next": "^12.0.8", + "next": "^12", "prettier": "^2.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/packages/vendure/package.json b/packages/vendure/package.json index 484a52743..0e795f34f 100644 --- a/packages/vendure/package.json +++ b/packages/vendure/package.json @@ -68,7 +68,7 @@ "@types/node": "^17.0.8", "@types/react": "^17.0.38", "lint-staged": "^12.1.7", - "next": "^12.0.8", + "next": "^12", "prettier": "^2.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/site/.env.template b/site/.env.template index a9eb798fe..87d761011 100644 --- a/site/.env.template +++ b/site/.env.template @@ -10,6 +10,7 @@ # @vercel/commerce-kibocommerce # @vercel/commerce-commercejs # @vercel/commerce-sfcc +# @vercel/commerce-olist COMMERCE_PROVIDER= BIGCOMMERCE_STOREFRONT_API_URL= @@ -35,6 +36,9 @@ NEXT_PUBLIC_SALEOR_CHANNEL= NEXT_PUBLIC_VENDURE_SHOP_API_URL= NEXT_PUBLIC_VENDURE_LOCAL_URL= +NEXT_PUBLIC_OLIST_STOREFRONT_DOMAIN= +NEXT_PUBLIC_OLIST_STOREFRONT_ACCESS_TOKEN= + ORDERCLOUD_CLIENT_ID= ORDERCLOUD_CLIENT_SECRET= STRIPE_SECRET= @@ -53,4 +57,4 @@ SFCC_CLIENT_ID= SFCC_CLIENT_SECRET= SFCC_ORG_ID= SFCC_SHORT_CODE= -SFCC_SITE_ID=RefArch \ No newline at end of file +SFCC_SITE_ID=RefArch diff --git a/site/commerce-config.js b/site/commerce-config.js index f92f221d0..a2b52b402 100644 --- a/site/commerce-config.js +++ b/site/commerce-config.js @@ -20,6 +20,7 @@ const PROVIDERS = [ '@vercel/commerce-spree', '@vercel/commerce-commercejs', '@vercel/commerce-sfcc', + '@vercel/commerce-olist', ] function getProviderName() { @@ -31,6 +32,8 @@ function getProviderName() { ? '@vercel/commerce-shopify' : process.env.NEXT_PUBLIC_SWELL_STORE_ID ? '@vercel/commerce-swell' + : process.env.NEXT_PUBLIC_OLIST_STOREFRONT_DOMAIN + ? '@vercel/commerce-olist' : '@vercel/commerce-local') ) } @@ -40,6 +43,7 @@ function withCommerceConfig(nextConfig = {}) { { commerce: { provider: getProviderName() } }, nextConfig ) + const { commerce } = config const { provider } = commerce diff --git a/site/components/cart/CartItem/CartItem.tsx b/site/components/cart/CartItem/CartItem.tsx index ecd3e39ae..2b0782bb9 100644 --- a/site/components/cart/CartItem/CartItem.tsx +++ b/site/components/cart/CartItem/CartItem.tsx @@ -93,7 +93,7 @@ const CartItem = ({ width={150} height={150} src={item.variant.image?.url || placeholderImg} - alt={item.variant.image?.altText || "Product Image"} + alt={item.variant.image?.altText || 'Product Image'} unoptimized /> diff --git a/site/next.config.js b/site/next.config.js index 22ecc1e17..7b5442ebe 100644 --- a/site/next.config.js +++ b/site/next.config.js @@ -7,6 +7,7 @@ const isShopify = provider === '@vercel/commerce-shopify' const isSaleor = provider === '@vercel/commerce-saleor' const isSwell = provider === '@vercel/commerce-swell' const isVendure = provider === '@vercel/commerce-vendure' +const isOlist = provider === '@vercel/commerce-olist' module.exports = withCommerceConfig({ commerce, @@ -16,7 +17,7 @@ module.exports = withCommerceConfig({ }, rewrites() { return [ - (isBC || isShopify || isSwell || isVendure || isSaleor) && { + (isBC || isShopify || isSwell || isVendure || isSaleor || isOlist) && { source: '/checkout', destination: '/api/checkout', }, diff --git a/site/package.json b/site/package.json index efddd9a7b..102f9d6a9 100644 --- a/site/package.json +++ b/site/package.json @@ -26,6 +26,7 @@ "@vercel/commerce-spree": "^0.0.1", "@vercel/commerce-swell": "^0.0.1", "@vercel/commerce-vendure": "^0.0.1", + "@vercel/commerce-olist": "^0.0.1", "autoprefixer": "^10.4.2", "body-scroll-lock": "^4.0.0-beta.0", "clsx": "^1.1.1", @@ -34,7 +35,8 @@ "keen-slider": "^6.6.3", "lodash.random": "^3.2.0", "lodash.throttle": "^4.1.1", - "next": "^12.0.8", + "next": "^12", + "next-compose-plugins": "^2.2.1", "next-themes": "^0.0.15", "postcss": "^8.3.5", "postcss-nesting": "^8.0.1", @@ -47,7 +49,7 @@ "tailwindcss": "^3.0.13" }, "devDependencies": { - "@next/bundle-analyzer": "^12.0.8", + "@next/bundle-analyzer": "^12", "@types/body-scroll-lock": "^3.1.0", "@types/js-cookie": "^3.0.1", "@types/lodash.random": "^3.2.6", @@ -55,7 +57,7 @@ "@types/node": "^17.0.8", "@types/react": "^17.0.38", "eslint": "^8.6.0", - "eslint-config-next": "^12.0.8", + "eslint-config-next": "^12", "eslint-config-prettier": "^8.3.0", "lint-staged": "^12.1.7", "postcss-flexbugs-fixes": "^5.0.2", diff --git a/turbo.json b/turbo.json index 8e38ccddc..9eaafbd15 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,8 @@ "pipeline": { "build": { "dependsOn": ["^build"], - "outputs": ["dist/**"] + "outputs": ["dist/**"], + "cache": false }, "next-commerce#build": { "dependsOn": [ @@ -11,7 +12,8 @@ "$COMMERCE_PROVIDER", "$BIGCOMMERCE_STOREFRONT_API_URL", "$NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN", - "$NEXT_PUBLIC_SWELL_STORE_ID" + "$NEXT_PUBLIC_SWELL_STORE_ID", + "$NEXT_PUBLIC_OLIST_STOREFRONT_DOMAIN" ], "outputs": [".next/**"] },