initial provider

This commit is contained in:
Kasper
2021-09-14 17:23:36 +02:00
parent 5b4d1b57d4
commit cc83c1f0e9
66 changed files with 1665 additions and 2 deletions

View File

@@ -14,6 +14,7 @@ const PROVIDERS = [
'shopify', 'shopify',
'swell', 'swell',
'vendure', 'vendure',
'medusa',
] ]
function getProviderName() { function getProviderName() {
@@ -25,6 +26,8 @@ function getProviderName() {
? 'shopify' ? 'shopify'
: process.env.NEXT_PUBLIC_SWELL_STORE_ID : process.env.NEXT_PUBLIC_SWELL_STORE_ID
? 'swell' ? 'swell'
: process.env.NEXT_PUBLIC_MEDUSA_STORE_URL
? 'medusa'
: 'local') : 'local')
) )
} }

View File

@@ -0,0 +1,63 @@
<p align="center">
<a href="https://www.medusa-commerce.com">
<img alt="Medusa" src="https://user-images.githubusercontent.com/7554214/129161578-19b83dc8-fac5-4520-bd48-53cba676edd2.png" width="100" />
</a>
</p>
<h1 align="center">
Medusa Provider
</h1>
<p align="center">
Medusa is an open-source headless commerce engine that enables developers to create amazing digital commerce experiences.
</p>
<p align="center">
<a href="https://github.com/medusajs/medusa/blob/master/LICENSE">
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="Medusa is released under the MIT license." />
</a>
<a href="https://github.com/medusajs/medusa/blob/master/CONTRIBUTING.md">
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs welcome!" />
</a>
<a href="https://discord.gg/xpCwq3Kfn8">
<img src="https://img.shields.io/badge/chat-on%20discord-7289DA.svg" alt="Discord Chat" />
</a>
<a href="https://twitter.com/intent/follow?screen_name=medusajs">
<img src="https://img.shields.io/twitter/follow/medusajs.svg?label=Follow%20@medusajs" alt="Follow @medusajs" />
</a>
</p>
## Demo
You can view a working demo of the Medusa provider for Next.js Commerce at https://medusa.vercel.store/
## Quickstart
You need a [Medusa](https://medusa-commerce.com/) instance, either in the cloud or self-hosted.
Clone this repo and install dependencies using `yarn` or `npm install`
Copy the `.env.template` file in this directory to `.env.local` in the main directory (which will be ignored by Git):
```bash
mv framework/medusa/.env.template .env.local
```
Then, set the environment following variables in your `.env.local`.
- `NEXT_PUBLIC_MEDUSA_STORE_URL` must point to the URL where your Medusa instance is running.
- `NEXT_PUBLIC_MEDUSA_IMAGE_HOST` must point to your image hosting service.
```
COMMERCE_PROVIDER=medusa
NEXT_PUBLIC_MEDUSA_STORE_URL=https://medusa-demo.store
NEXT_PUBLIC_MEDUSA_IMG_HOST=medusa-public-images.s3.eu-west-1.amazonaws.com
```
## Notes
- The entire customer flow is carried out using the [Storefront API](https://docs.medusa-commerce.com/api/store). This means that there is no existing, pre-built checkout flow. The checkout flow must be built using the `Storefront API`, for an example of how to do this feel free to have a look at our [Next.js](https://github.com/medusajs/gatsby-starter-medusa) starter project.
- `Medusa` does not currently support any wishlist features.
- `Medusa` does not nativly support searches. This can be implemented using plugins such as `MeiliSearch`, see [#381](https://github.com/medusajs/medusa/pull/381).
- `Medusa` does not come with any page/blog building feature. This can be implemented using `Medusa` in conjunction with a CMS such as `Contentful`. For inspiration on how to do this check out our [Contentful starter](https://github.com/medusajs/medusa-starter-contentful)

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 @@
export default function noopApi(...args: any[]): void {}

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 @@
export default function noopApi(...args: any[]): void {}

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 @@
export default function noopApi(...args: any[]): void {}

View File

@@ -0,0 +1,30 @@
import type { CommerceAPIConfig } from '@commerce/api'
import { CommerceAPI, getCommerceApi as commerceApi } from '@commerce/api'
import fetchApi from './utils/fetch-medusa-api'
import { MEDUSA_CART_ID_COOKIE } from '../const'
import * as operations from './operations'
export interface MedusaConfig extends CommerceAPIConfig {
fetch: any
}
const config: MedusaConfig = {
commerceUrl: '',
apiToken: '',
cartCookie: MEDUSA_CART_ID_COOKIE,
customerCookie: '',
cartCookieMaxAge: 60 * 60 * 24 * 30,
fetch: fetchApi,
}
export const provider = { config, operations }
export type Provider = typeof provider
export type MedusaAPI<P extends Provider = Provider> = CommerceAPI<P | any>
export function getCommerceApi<P extends Provider>(
customProvider: P = provider as any
): MedusaAPI<P> {
return commerceApi(customProvider as any)
}

View File

@@ -0,0 +1,19 @@
export type Page = { url: string }
export type GetAllPagesResult = { pages: Page[] }
import type { MedusaConfig } from '..'
export default function getAllPagesOperation() {
function getAllPages({
config,
preview,
}: {
url?: string
config?: Partial<MedusaConfig>
preview?: boolean
}): Promise<GetAllPagesResult> {
return Promise.resolve({
pages: [],
})
}
return getAllPages
}

View File

@@ -0,0 +1,35 @@
import { OperationContext } from '@commerce/api/operations'
import { Product } from '@commerce/types/product'
import { Product as MedusaProduct } from '@medusajs/medusa-js/lib/types'
import { MedusaConfig } from '..'
export type GetAllProductPathsResult = {
products: Array<{ path: string }>
}
export default function getAllProductPathsOperation({
commerce,
}: OperationContext<any>) {
async function getAllProductsPaths({
config: cfg,
}: {
config?: Partial<MedusaConfig>
preview?: boolean
} = {}): Promise<{ products: Product[] | any[] }> {
const config = commerce.getConfig(cfg)
const results = await config.fetch('products', 'list', {})
const productHandles = results.data?.products
? results.data.products.map(({ handle }: MedusaProduct) => ({
path: `/${handle}`,
}))
: []
return {
products: productHandles,
}
}
return getAllProductsPaths
}

View File

@@ -0,0 +1,41 @@
import { Product } from '@commerce/types/product'
import type { OperationContext } from '@commerce/api/operations'
import type { MedusaConfig } from '../'
import { normalizeProduct } from '@framework/utils/normalizers/normalize-products'
import { Product as MedusaProduct } from '@medusajs/medusa-js/lib/types'
export type ProductVariables = { first?: number }
export default function getAllProductsOperation({
commerce,
}: OperationContext<any>) {
async function getAllProducts({
config: cfg,
variables,
}: {
query?: string
variables?: ProductVariables
config?: Partial<MedusaConfig>
preview?: boolean
} = {}): Promise<{ products: Product[] | any[] }> {
const config = commerce.getConfig(cfg)
const query = variables?.first && { limit: variables.first }
const results = await config.fetch(
'products',
'list',
query ? { query: query } : {}
)
const products: Product[] = results.data?.products
? results.data.products.map((product: MedusaProduct) =>
normalizeProduct(product)
)
: []
return {
products,
}
}
return getAllProducts
}

View File

@@ -0,0 +1,6 @@
export default function getCustomerWishlistOperation() {
function getCustomerWishlist(): any {
return { wishlist: {} }
}
return getCustomerWishlist
}

View File

@@ -0,0 +1,13 @@
export type Page = any
export type GetPageResult = { page?: Page }
export type PageVariables = {
id: number
}
export default function getPageOperation() {
function getPage(): Promise<GetPageResult> {
return Promise.resolve({})
}
return getPage
}

View File

@@ -0,0 +1,46 @@
import { Product } from '@commerce/types/product'
import { GetProductOperation } from '@commerce/types/product'
import type { OperationContext } from '@commerce/api/operations'
import { Product as MedusaProduct } from '@medusajs/medusa-js/lib/types'
import { normalizeProduct } from '@framework/utils/normalizers/normalize-products'
import { MedusaConfig } from '..'
export default function getProductOperation({
commerce,
}: OperationContext<any>) {
async function getProduct<T extends GetProductOperation>({
variables,
config: cfg,
}: {
query?: string
variables?: T['variables']
config?: Partial<MedusaConfig>
preview?: boolean
} = {}): Promise<Product | {} | any> {
const config = commerce.getConfig(cfg)
const response = await config.fetch('products', 'list', {})
if (response.data?.products) {
const products: MedusaProduct[] = response.data.products
const product = products
? products.find(({ handle }) => handle === variables!.slug)
: null
/**
* Commerce only provides us with the slug/path for finding
* the specified product. We do not have a endpoint for retrieving
* products using handles. Perhaps we should ask Vercel if we can
* change this so the variables also expose the product_id, which
* we can use for retrieving products.
*/
if (product) {
return {
product: normalizeProduct(product),
}
}
}
}
return getProduct
}

View File

@@ -0,0 +1,33 @@
import { OperationContext } from '@commerce/api/operations'
import { Category } from '@commerce/types/site'
import { MedusaConfig } from '..'
export type GetSiteInfoResult<
T extends { categories: any[]; brands: any[] } = {
categories: Category[]
brands: any[]
}
> = T
export default function getSiteInfoOperation({}: OperationContext<any>) {
function getSiteInfo({
query,
variables,
config: cfg,
}: {
query?: string
variables?: any
config?: Partial<MedusaConfig>
preview?: boolean
} = {}): Promise<GetSiteInfoResult> {
/** We should add collections to our Storefront API,
* so we can populate the site with collections here
*/
return Promise.resolve({
categories: [],
brands: [],
})
}
return getSiteInfo
}

View File

@@ -0,0 +1,6 @@
export { default as getPage } from './get-page'
export { default as getSiteInfo } from './get-site-info'
export { default as getAllPages } from './get-all-pages'
export { default as getProduct } from './get-product'
export { default as getAllProducts } from './get-all-products'
export { default as getAllProductPaths } from './get-all-product-paths'

View File

@@ -0,0 +1,8 @@
import { callMedusa } from '@framework/utils/call-medusa'
const fetchApi = async (query: string, method: string, variables: any) => {
const response = await callMedusa(method, query, variables)
return response
}
export default fetchApi

View File

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

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,49 @@
import { MutationHook } from '@commerce/utils/types'
import useLogin, { UseLogin } from '@commerce/auth/use-login'
import { CommerceError, ValidationError } from '@commerce/utils/errors'
import { useCustomer } from '../customer'
import { useCallback } from 'react'
export default useLogin as UseLogin<typeof handler>
export const handler: MutationHook<any> = {
fetchOptions: {
query: 'auth',
method: 'authenticate',
},
async fetcher({ input: { email, password }, options, fetch }) {
if (!(email && password)) {
throw new CommerceError({
message: 'An email and password are required to login',
})
}
await fetch({
...options,
variables: { email: email, password: password },
}).catch((_e) => {
throw new CommerceError({
errors: [
new ValidationError({
message:
'A user with that email and password combination does not exist',
}),
],
})
})
},
useHook:
({ fetch }) =>
() => {
const { revalidate } = useCustomer()
return useCallback(
async function login(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
},
}

View File

@@ -0,0 +1,29 @@
import { MutationHook } from '@commerce/utils/types'
import useLogout, { UseLogout } from '@commerce/auth/use-logout'
import { useCallback } from 'react'
import Cookies from 'js-cookie'
export default useLogout as UseLogout<typeof handler>
export const handler: MutationHook<any> = {
fetchOptions: {
query: 'auth',
method: 'logout',
},
async fetcher({ options, fetch }) {
await fetch({ ...options })
return null
},
useHook:
({ fetch }) =>
() => {
return useCallback(
async function logout(input) {
const data = await fetch({ input })
return data
},
[fetch]
)
},
}

View File

@@ -0,0 +1,51 @@
import { useCallback } from 'react'
import useCustomer from '../customer/use-customer'
import { MutationHook } from '@commerce/utils/types'
import useSignup, { UseSignup } from '@commerce/auth/use-signup'
import { CommerceError } from '@commerce/utils/errors'
export default useSignup as UseSignup<typeof handler>
export const handler: MutationHook<any> = {
fetchOptions: {
query: 'customers',
method: 'create',
},
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 await fetch({
...options,
variables: {
payload: {
first_name: firstName,
last_name: lastName,
email,
password,
},
},
})
},
useHook:
({ fetch }) =>
() => {
const { revalidate } = useCustomer()
return useCallback(
async function signup(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
},
}

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,63 @@
import useAddItem, { UseAddItem } from '@commerce/cart/use-add-item'
import { CommerceError } from '@commerce/utils/errors'
import { MutationHook } from '@commerce/utils/types'
import { MEDUSA_CART_ID_COOKIE } from '@framework/const'
import { MedusaAddItemProps } from '@framework/types'
import type { AddItemHook } from '../types/cart'
import { normalizeCart } from '@framework/utils/normalizers/normalize-cart'
import { useCart } from 'framework/local/cart'
import Cookies from 'js-cookie'
import { useCallback } from 'react'
export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<AddItemHook> = {
fetchOptions: {
query: 'carts',
method: 'addItem',
},
async fetcher({ input: item, options, fetch }) {
if (item.quantity && !Number.isInteger(item.quantity)) {
throw new CommerceError({
message: 'The item quantity has to be a valid integer greater than 0',
})
}
const variables: {
cart_id: string
payload: MedusaAddItemProps
} = {
cart_id: Cookies.get(MEDUSA_CART_ID_COOKIE)!,
payload: {
variant_id: item.variantId,
quantity: item.quantity ?? 1,
},
}
try {
const data = await fetch({
...options,
variables,
})
return normalizeCart(data.cart)
} catch (e: any) {
console.log(e)
throw new CommerceError({ message: e.message })
}
},
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,76 @@
import { useMemo } from 'react'
import { SWRHook } from '@commerce/utils/types'
import useCart, { UseCart } from '@commerce/cart/use-cart'
import { normalizeCart } from '@framework/utils/normalizers/normalize-cart'
import { CommerceError } from '@commerce/utils/errors'
import Cookies from 'js-cookie'
import { MEDUSA_CART_ID_COOKIE } from '@framework/const'
export default useCart as UseCart<typeof handler>
export const handler: SWRHook<any> = {
fetchOptions: {
query: '',
},
async fetcher({ fetch }) {
const cart_id = Cookies.get(MEDUSA_CART_ID_COOKIE)
/**
* If cart already exits, then try to fetch it
*/
if (cart_id) {
try {
const existingCartResponse = await fetch({
query: 'carts',
method: 'retrieve',
variables: { cart_id: cart_id },
})
if (existingCartResponse?.cart) {
return normalizeCart(existingCartResponse.cart)
}
} catch (e) {
/**
* noop: If the cart_id does not exits, then we
* continue and create a new cart and set a new
* CART_COOKIE
*/
}
}
const newCartResponse = await fetch({
query: 'carts',
method: 'create',
variables: {},
})
if (newCartResponse?.cart) {
Cookies.set(MEDUSA_CART_ID_COOKIE, newCartResponse.cart.id, {
expires: 30,
})
return normalizeCart(newCartResponse.cart)
}
throw new CommerceError({ message: 'Medusa cart error' })
},
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,55 @@
import {
HookFetcherContext,
MutationHook,
MutationHookContext,
} from '@commerce/utils/types'
import useRemoveItem, { UseRemoveItem } from '@commerce/cart/use-remove-item'
import { MEDUSA_CART_ID_COOKIE } from '@framework/const'
import Cookies from 'js-cookie'
import { normalizeCart } from '@framework/utils/normalizers/normalize-cart'
import { CommerceError } from '@commerce/utils/errors'
import { RemoveItemHook } from '@commerce/types/cart'
import { useCallback } from 'react'
import useCart from './use-cart'
export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler: MutationHook<any> = {
fetchOptions: {
query: 'carts',
method: 'deleteItem',
},
async fetcher({
input: { itemId },
options,
fetch,
}: HookFetcherContext<RemoveItemHook>) {
const cart_id = Cookies.get(MEDUSA_CART_ID_COOKIE)
if (cart_id) {
const data = await fetch({
...options,
variables: { cart_id: cart_id, line_id: itemId },
})
return normalizeCart(data.cart)
} else {
throw new CommerceError({ message: 'No cart was found' })
}
},
useHook:
({ fetch }: MutationHookContext<RemoveItemHook>) =>
() => {
const { mutate } = useCart()
return useCallback(
async function removeItem(input) {
const data = await fetch({ input: { itemId: input.id } })
await mutate(data, false)
return data
},
[fetch, mutate]
)
},
}

View File

@@ -0,0 +1,91 @@
import { MutationHook, MutationHookContext } from '@commerce/utils/types'
import useUpdateItem, { UseUpdateItem } from '@commerce/cart/use-update-item'
import { handler as removeItem } from './use-remove-item'
import { CommerceError, ValidationError } from '@commerce/utils/errors'
import Cookies from 'js-cookie'
import { MEDUSA_CART_ID_COOKIE } from '@framework/const'
import { normalizeCart } from '@framework/utils/normalizers/normalize-cart'
import { LineItem, UpdateItemHook } from '@commerce/types/cart'
import { useCallback } from 'react'
import { debounce } from 'lodash'
import useCart from '@commerce/cart/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: MutationHook<any> = {
fetchOptions: {
query: 'carts',
method: 'updateItem',
},
async fetcher({ input: { itemId, item }, options, fetch }) {
if (Number.isInteger(item.quantity)) {
if (item.quantity! < 1) {
return removeItem.fetcher!({
options: removeItem.fetchOptions,
input: { itemId },
fetch,
})
}
} else if (item.quantity) {
throw new ValidationError({
message: 'The item quantity has to be a valid integer',
})
}
const cart_id = Cookies.get(MEDUSA_CART_ID_COOKIE)
const data = await fetch({
...options,
variables: {
cart_id: cart_id,
line_id: itemId,
payload: { quantity: item.quantity },
},
})
if (data.cart) {
return normalizeCart(data.cart)
} else {
throw new CommerceError({ message: 'No cart was found' })
}
},
useHook:
({ fetch }: MutationHookContext<UpdateItemHook>) =>
<T extends LineItem | undefined = undefined>(
ctx: {
item?: T
wait?: number
} = {}
) => {
const { item } = ctx
const { mutate } = useCart()
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,9 @@
{
"provider": "medusa",
"features": {
"wishlist": false,
"customerAuth": true,
"customCheckout": true,
"search": false
}
}

View File

@@ -0,0 +1,2 @@
export const MEDUSA_PUBLIC_STORE_URL = process.env.NEXT_PUBLIC_MEDUSA_STORE_URL
export const MEDUSA_CART_ID_COOKIE = 'medusa_cart_id'

View File

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

View File

@@ -0,0 +1,27 @@
import { SWRHook } from '@commerce/utils/types'
import useCustomer, { UseCustomer } from '@commerce/customer/use-customer'
import { normalizeCustomer } from '@framework/utils/normalizers/normalize-customer'
export default useCustomer as UseCustomer<typeof handler>
export const handler: SWRHook<any> = {
fetchOptions: {
query: 'auth',
method: 'getSession',
},
async fetcher({ options, fetch }) {
const data = await fetch({
...options,
})
return normalizeCustomer(data?.customer) || null
},
useHook:
({ useData }) =>
(input) => {
return useData({
swrOptions: {
revalidateOnFocus: false,
...input?.swrOptions,
},
})
},
}

View File

@@ -0,0 +1,44 @@
import { CommerceError } from '@commerce/utils/errors'
import { Fetcher } from '@commerce/utils/types'
import { callMedusa } from './utils/call-medusa'
enum Query {
Auth = <any>'auth',
Carts = <any>'carts',
Customers = <any>'customers',
Errors = <any>'errors',
Orders = <any>'orders',
Products = <any>'products',
ReturnReasons = <any>'returnReasons',
Returns = <any>'returns',
ShippingOptions = <any>'shippingOptions',
Swaps = <any>'swaps',
}
export const fetcher: Fetcher = async ({ method, query, variables }) => {
if (!query) {
throw new CommerceError({ message: 'An argument for query is required' })
}
if (!Object.values(Query).includes(query!)) {
throw new CommerceError({
message: `${query} is not a valid method argument. Available queries are ${Object.keys(
Query
)
.map((k) => Query[k as any])
.join(', ')}`,
})
}
if (!method) {
throw new CommerceError({ message: 'An argument for method is required' })
}
const response = await callMedusa(method, query, variables)
if (response.statusText === 'OK') {
const { data } = response
return data
}
throw response
}

View File

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

View File

@@ -0,0 +1,6 @@
import Medusa from '@medusajs/medusa-js'
import { MEDUSA_PUBLIC_STORE_URL } from './const'
const medusa: Medusa = new Medusa({ baseUrl: MEDUSA_PUBLIC_STORE_URL! })
export default medusa

View File

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

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 '@commerce/product/use-price'
export { default } from '@commerce/product/use-price'

View File

@@ -0,0 +1,42 @@
import { SWRHook } from '@commerce/utils/types'
import useSearch, { UseSearch } from '@commerce/product/use-search'
import { Product } from '@commerce/types/product'
import { Product as MedusaProduct } from '@medusajs/medusa-js/lib/types'
import { normalizeProduct } from '@framework/utils/normalizers/normalize-products'
export default useSearch as UseSearch<typeof handler>
export const handler: SWRHook<any> = {
fetchOptions: {
query: 'products',
method: 'list',
},
async fetcher({ input, options, fetch }) {
const { products } = await fetch({
...options,
variables: { query: null },
})
return {
products: products
? products.map((product: MedusaProduct) => normalizeProduct(product))
: [],
found: products.length,
}
},
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,30 @@
import { Provider } from '@commerce'
import { fetcher } from './fetcher'
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 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 { MEDUSA_CART_ID_COOKIE } from './const'
export const medusaProvider: Provider = {
locale: 'en-us',
cartCookie: MEDUSA_CART_ID_COOKIE,
fetcher: fetcher,
cart: { useCart, useAddItem, useUpdateItem, useRemoveItem },
customer: { useCustomer },
products: { useSearch },
auth: { useLogin, useLogout, useSignup },
}
export type MedusaProvider = typeof medusaProvider

View File

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

View File

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

View File

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

View File

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

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,12 @@
import * as Core from '@commerce/types/login'
import type { LoginMutationVariables } from '../schema'
import { LoginBody, LoginTypes } from '@commerce/types/login'
export * from '@commerce/types/login'
export type LoginHook<T extends LoginTypes = LoginTypes> = {
data: null
actionInput: LoginBody
fetcherInput: LoginBody
body: T['body']
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,420 @@
import { CommerceError } from '@commerce/utils/errors'
import { MEDUSA_PUBLIC_STORE_URL } from '@framework/const'
import { Product } from '@medusajs/medusa-js/lib/types'
import Cookies from 'js-cookie'
import medusa from '../medusa'
export const callMedusa = async (
method: string,
query: string,
variables: any
) => {
switch (query) {
case 'auth':
if (method === 'authenticate') {
const { email, password } = variables
if (!email || !password) {
throw new CommerceError({
message: 'An argument for email and password is required',
})
}
return await medusa.auth.authenticate({
email: email,
password: password,
})
} else if (method === 'exists') {
const { email } = variables
if (!email) {
throw new CommerceError({
message: 'An argument for email is required',
})
}
return await medusa.auth.exists(email)
} else if (method === 'getSession') {
return await medusa.auth.getSession()
} else if ('logout') {
//NOT WORKING
return await fetch(`${MEDUSA_PUBLIC_STORE_URL}/store/auth`, {
method: 'DELETE',
})
} else {
throw new CommerceError({
message: 'No valid method argument was provided',
})
}
case 'carts':
if (method === 'complete') {
const { cart_id } = variables
if (!cart_id) {
throw new CommerceError({
message: 'An argument for cart_id is required',
})
}
return await medusa.carts.complete(cart_id)
} else if (method === 'create') {
const { payload } = variables
return await medusa.carts.create(payload)
} else if (method === 'createPaymentSessions') {
const { cart_id } = variables
if (!cart_id) {
throw new CommerceError({
message: 'An argument for cart_id is required',
})
}
return await medusa.carts.createPaymentSessions(cart_id)
} else if (method === 'deletePaymentSessions') {
const { cart_id, provider_id } = variables
if (!(cart_id && provider_id)) {
throw new CommerceError({
message: 'An argument for cart_id and provider_id is required',
})
}
return await medusa.carts.deletePaymentSession(cart_id, provider_id)
} else if (method === 'refreshPaymentSession') {
const { cart_id, provider_id } = variables
if (!(cart_id && provider_id)) {
throw new CommerceError({
message: 'An argument for cart_id and provider_id is required',
})
}
return await medusa.carts.refreshPaymentSession(cart_id, provider_id)
} else if (method === 'updatePaymentSession') {
const { cart_id, provider_id, data } = variables
if (!(cart_id && provider_id)) {
throw new CommerceError({
message: 'An argument for cart_id and provider_id is required',
})
}
return await medusa.carts.updatePaymentSession(cart_id, {
provider_id,
data,
})
} else if (method === 'setPaymentSession') {
const { cart_id, provider_id } = variables
if (!(cart_id && provider_id)) {
throw new CommerceError({
message: 'An argument for cart_id and provider_id is required',
})
}
return await medusa.carts.setPaymentSession(cart_id, { provider_id })
} else if (method === 'deleteDiscount') {
const { cart_id, code } = variables
if (!(cart_id && code)) {
throw new CommerceError({
message: 'An argument for cart_id and code is required',
})
}
return await medusa.carts.deleteDiscount(cart_id, code)
} else if (method === 'retrieve') {
const { cart_id } = variables
if (!cart_id) {
throw new CommerceError({
message: 'An argument for cart_id and code is required',
})
}
return await medusa.carts.retrieve(cart_id)
} else if (method === 'update') {
const { cart_id, payload } = variables
if (!(cart_id && payload)) {
throw new CommerceError({
message: 'An argument for cart_id and payload is required',
})
}
return await medusa.carts.update(cart_id, payload)
} else if (method === 'addItem') {
const { cart_id, payload } = variables
const { variant_id, quantity } = payload
if (!cart_id) {
throw new CommerceError({
message: 'An argument for cart_id is required',
})
}
if (!(variant_id && quantity)) {
throw new CommerceError({
message: 'An argument for variant_id and quantity is required',
})
}
return await medusa.carts.lineItems.create(cart_id, {
variant_id: variant_id,
quantity: quantity,
})
} else if (method === 'deleteItem') {
const { cart_id, line_id } = variables
if (!(cart_id && line_id)) {
throw new CommerceError({
message: 'An argument for cart_id and line_id is required',
})
}
return await medusa.carts.lineItems.delete(cart_id, line_id)
} else if (method === 'updateItem') {
const { cart_id, line_id, payload } = variables
if (!(cart_id && line_id && payload)) {
throw new CommerceError({
message: 'An argument for cart_id, line_id and payload is required',
})
}
return await medusa.carts.lineItems.update(cart_id, line_id, payload)
} else {
throw new CommerceError({
message: 'No valid method argument was provided',
})
}
case 'customers':
if (method === 'addAddresses') {
const { customer_id, payload } = variables
return await medusa.customers.addresses.addAddress(customer_id, payload)
} else if (method === 'updateAddresses') {
const { customer_id, address_id, payload } = variables
return await medusa.customers.addresses.updateAddress(
customer_id,
address_id,
payload
)
} else if (method === 'deleteAddress') {
const { customer_id, address_id } = variables
return await medusa.customers.addresses.deleteAddress(
customer_id,
address_id
)
} else if (method === 'listPaymentMethods') {
const { customer_id } = variables
return await medusa.customers.paymentMethods.list(customer_id)
} else if (method === 'create') {
const { payload } = variables
if (!payload) {
throw new CommerceError({
message: 'An argument for payload is required',
})
}
return await medusa.customers.create(payload)
} else if (method === 'generatePasswordToken') {
const { payload } = variables
if (!payload) {
throw new CommerceError({
message: 'An argument for payload is required',
})
}
return await medusa.customers.generatePasswordToken(payload)
} else if (method === 'listOrders') {
const { customer_id } = variables
if (!customer_id) {
throw new CommerceError({
message: 'An argument for customer_id is required',
})
}
return await medusa.customers.listOrders(customer_id)
} else if (method === 'resetPassword') {
const { payload } = variables
if (!payload) {
throw new CommerceError({
message: 'An argument for payload is required',
})
}
return await medusa.customers.resetPassword(payload)
} else if (method === 'retrieve') {
const { customer_id } = variables
if (!customer_id) {
throw new CommerceError({
message: 'An argument for customer_id is required',
})
}
return await medusa.customers.retrieve(customer_id)
} else if (method === 'update') {
const { customer_id, payload } = variables
if (!customer_id) {
throw new CommerceError({
message: 'An argument for customer_id is required',
})
}
return await medusa.customers.update(customer_id, payload)
}
case 'orders':
if (method === 'lookupOrder') {
const { payload } = variables
if (!payload) {
throw new CommerceError({
message: 'An argument for payload is required',
})
}
return await medusa.orders.lookupOrder(payload)
} else if (method === 'retrieve') {
const { order_id } = variables
if (!order_id) {
throw new CommerceError({
message: 'An argument for order_id is required',
})
}
return await medusa.orders.retrieve(order_id)
} else if (method === 'retrieveByCartId') {
const { cart_id } = variables
if (!cart_id) {
throw new CommerceError({
message: 'An argument for cart_id is required',
})
}
return await medusa.orders.retrieveByCartId(cart_id)
}
case 'products':
if (method === 'variantsList') {
const { params } = variables
return await medusa.products.variants.list(params)
} else if (method === 'variantsRetrieve') {
const { variant_id } = variables
if (!variant_id) {
throw new CommerceError({
message: 'An argument for variant_id is required',
})
}
return await medusa.products.variants.retrieve(variant_id)
} else if (method === 'list') {
const { query } = variables
return await medusa.products.list(
query && {
limit: query.limit || null,
offset: query.offset || null,
}
)
} else if (method === 'retrieve') {
const { product_id } = variables
if (!product_id) {
throw new CommerceError({
message: 'An argument for product_id is required',
})
}
return await medusa.products.retrieve(product_id)
} else {
throw new CommerceError({
message: 'No valid method argument was provided',
})
}
case 'returnReasons':
if (method === 'list') {
return await medusa.returnReasons.list()
} else {
throw new CommerceError({
message: 'No valid method argument was provided',
})
}
case 'returns':
if (method === 'create') {
const { payload } = variables
if (!payload) {
throw new CommerceError({
message: 'An argument for payload is required',
})
}
return await medusa.returns.create(payload)
} else {
throw new CommerceError({
message: 'No valid method argument was provided',
})
}
case 'shippingOptions':
if (method === 'list') {
const { cart_id } = variables
if (!cart_id) {
throw new CommerceError({
message: 'An argument for cart_id is required',
})
}
return await medusa.shippingOptions.list(cart_id)
} else if (method === 'create') {
const { cart_id } = variables
if (!cart_id) {
throw new CommerceError({
message: 'An argument for cart_id is required',
})
}
return await medusa.shippingOptions.listCartOptions(cart_id)
} else {
throw new CommerceError({
message: 'No valid method argument was provided',
})
}
case 'swaps':
if (method === 'create') {
const { cart_id } = variables
if (!cart_id) {
throw new CommerceError({
message: 'An argument for cart_id is required',
})
}
return await medusa.swaps.create({ cart_id })
} else if (method === 'retrieve') {
const { cart_id } = variables
if (!cart_id) {
throw new CommerceError({
message: 'An argument for cart_id is required',
})
}
return await medusa.swaps.retrieveByCartId(cart_id)
} else {
throw new CommerceError({
message: 'No valid method argument was provided',
})
}
default:
throw new CommerceError({
message: 'No valid query argument was provided',
})
}
}

View File

@@ -0,0 +1,77 @@
import { Cart, LineItem, ProductVariant } from '../../types/cart'
import {
Cart as MedusaCart,
Discount as MedusaDiscount,
LineItem as MedusaLineItem,
ProductVariant as MedusaProductVariant,
} from '@medusajs/medusa-js/lib/types'
import { Discount } from '@commerce/types/common'
export function normalizeProductVariant(
{ id, title: name, sku }: MedusaProductVariant,
price: number,
thumbnail: string
): ProductVariant {
return {
id,
name,
price: price / 100,
sku: sku || id,
listPrice: price / 100,
requiresShipping: true,
image: { url: thumbnail, altText: name },
}
}
export function normalizeLineItem({
id,
title: name,
variant,
quantity,
unit_price,
thumbnail,
}: MedusaLineItem): LineItem {
return {
id,
name,
path: variant?.product.handle || name.replace(' ', '-'),
variant: normalizeProductVariant(variant!, unit_price, thumbnail!),
variantId: variant!.id,
productId: variant!.product.id,
discounts: [],
quantity,
}
}
export function normalizeDiscount(discount: MedusaDiscount): Discount {
return {
value: discount.rule.value,
}
}
export function normalizeCart({
id,
email,
created_at,
region,
items,
subtotal,
total,
tax_total,
customer_id,
discounts,
}: MedusaCart): Cart {
return {
id,
email,
customerId: customer_id,
discounts: discounts.map((discount) => normalizeDiscount(discount)),
createdAt: `${created_at}`,
currency: { code: region.currency_code },
lineItems: items.map((item) => normalizeLineItem(item)),
subtotalPrice: subtotal / 100,
totalPrice: total / 100,
taxesIncluded: tax_total > 0,
lineItemsSubtotalPrice: subtotal / 100,
}
}

View File

@@ -0,0 +1,9 @@
import { Customer } from '@commerce/types/customer'
import { Customer as MedusaCustomer } from '@medusajs/medusa-js/lib/types'
export function normalizeCustomer(customer: MedusaCustomer): Customer {
return {
firstName: customer.first_name,
lastName: customer.last_name,
}
}

View File

@@ -0,0 +1,98 @@
import {
Image as MedusaImage,
MoneyAmount as MedusaMoneyAmount,
Product as MedusaProduct,
ProductOption as MedusaProductOption,
ProductVariant as MedusaProductVariant,
} from '@medusajs/medusa-js/lib/types'
import type {
Product,
ProductImage,
ProductOption,
ProductPrice,
ProductVariant,
} from '../../types/product'
export function normalizeProductImages(images: MedusaImage[]): ProductImage[] {
if (!images || images.length < 1) {
return [{ url: '/' }]
}
console.error(images)
return images.map(({ url }: MedusaImage) => ({
url,
}))
}
export function normalizeAvailability(variant: MedusaProductVariant): boolean {
if (
variant.manage_inventory &&
!variant.allow_backorder &&
variant.inventory_quantity < 1
)
return false
return true
}
export function normalizeProductVariants(
variants: MedusaProductVariant[]
): ProductVariant[] {
return variants.map((variant) => {
return {
id: variant.id,
options: [], //variants don't have options
availableForSale: normalizeAvailability(variant),
}
})
}
export function normalizePrice(price: MedusaMoneyAmount): ProductPrice {
return {
value: price.amount / 100,
currencyCode: price.currency_code.toUpperCase(),
}
}
export function normalizeOptions(
options: MedusaProductOption[]
): ProductOption[] {
return options.map((opt) => ({
id: opt.id,
displayName: opt.title,
values: opt.values.map((val) => {
return {
label: val.value,
}
}),
}))
}
export function normalizeProduct({
id,
title: name,
description,
variants: medusaVariants,
options: medusaOptions,
images,
handle: slug,
thumbnail,
}: MedusaProduct): Product {
const tmpVariant = medusaVariants.reduce((prev, curr) =>
prev.prices.amount < curr.prices.amount ? prev : curr
)
const minPrice = normalizePrice(tmpVariant.prices[0]) //need to fix typing in medusa types
return {
id,
name,
description: description || '',
variants: normalizeProductVariants(medusaVariants),
images: thumbnail && !images.length ? [{ url: thumbnail }] : images,
options: normalizeOptions(medusaOptions),
price: minPrice,
path: `/${slug}`,
slug,
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,43 @@
import { HookFetcher } from '@commerce/utils/types'
import type { Product } from '@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<Wishlist | null, UseWishlistInput> = () => {
return null
}
export function extendHook(
customFetcher: typeof fetcher,
// swrOptions?: SwrOptions<Wishlist | null, UseWishlistInput>
swrOptions?: any
) {
const useWishlist = ({ includeProducts }: UseWishlistOptions = {}) => {
return { data: null }
}
useWishlist.extend = extendHook
return useWishlist
}
export default extendHook(fetcher)

View File

@@ -10,6 +10,7 @@ const isShopify = provider === 'shopify'
const isSaleor = provider === 'saleor' const isSaleor = provider === 'saleor'
const isSwell = provider === 'swell' const isSwell = provider === 'swell'
const isVendure = provider === 'vendure' const isVendure = provider === 'vendure'
const isMedusa = provider === 'medusa'
module.exports = withCommerceConfig({ module.exports = withCommerceConfig({
commerce, commerce,

View File

@@ -20,6 +20,7 @@
"node": ">=14.x" "node": ">=14.x"
}, },
"dependencies": { "dependencies": {
"@medusajs/medusa-js": "^1.0.0",
"@react-spring/web": "^9.2.1", "@react-spring/web": "^9.2.1",
"@vercel/fetch": "^6.1.0", "@vercel/fetch": "^6.1.0",
"autoprefixer": "^10.2.6", "autoprefixer": "^10.2.6",

View File

@@ -23,8 +23,8 @@
"@components/*": ["components/*"], "@components/*": ["components/*"],
"@commerce": ["framework/commerce"], "@commerce": ["framework/commerce"],
"@commerce/*": ["framework/commerce/*"], "@commerce/*": ["framework/commerce/*"],
"@framework": ["framework/local"], "@framework": ["framework/medusa"],
"@framework/*": ["framework/local/*"] "@framework/*": ["framework/medusa/*"]
} }
}, },
"include": ["next-env.d.ts", "**/*.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"], "include": ["next-env.d.ts", "**/*.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"],

View File

@@ -929,6 +929,13 @@
resolved "https://registry.yarnpkg.com/@iarna/toml/-/toml-2.2.5.tgz#b32366c89b43c6f8cefbdefac778b9c828e3ba8c" resolved "https://registry.yarnpkg.com/@iarna/toml/-/toml-2.2.5.tgz#b32366c89b43c6f8cefbdefac778b9c828e3ba8c"
integrity sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg== integrity sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==
"@medusajs/medusa-js@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@medusajs/medusa-js/-/medusa-js-1.0.0.tgz#b8e2231f7d15eebfaf0d5299dc5716ac37171757"
integrity sha512-P8l/xI6Q07PHLO65ED1BuS+4BGa2dSLDAkpWGPf3Sc7giKYlgDXo1xXXT8+jnsJklRgWbaJIU9mbcdi79QbFQw==
dependencies:
axios "^0.21.0"
"@microsoft/fetch-event-source@2.0.1": "@microsoft/fetch-event-source@2.0.1":
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz#9ceecc94b49fbaa15666e38ae8587f64acce007d" resolved "https://registry.yarnpkg.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz#9ceecc94b49fbaa15666e38ae8587f64acce007d"
@@ -1620,6 +1627,13 @@ axe-core@^4.0.2:
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.3.2.tgz#fcf8777b82c62cfc69c7e9f32c0d2226287680e7" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.3.2.tgz#fcf8777b82c62cfc69c7e9f32c0d2226287680e7"
integrity sha512-5LMaDRWm8ZFPAEdzTYmgjjEdj1YnQcpfrVajO/sn/LhbpGp0Y0H64c2hLZI1gRMxfA+w1S71Uc/nHaOXgcCvGg== integrity sha512-5LMaDRWm8ZFPAEdzTYmgjjEdj1YnQcpfrVajO/sn/LhbpGp0Y0H64c2hLZI1gRMxfA+w1S71Uc/nHaOXgcCvGg==
axios@^0.21.0:
version "0.21.4"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575"
integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==
dependencies:
follow-redirects "^1.14.0"
axobject-query@^2.2.0: axobject-query@^2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
@@ -3174,6 +3188,11 @@ flatten@^1.0.2:
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.3.tgz#c1283ac9f27b368abc1e36d1ff7b04501a30356b" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.3.tgz#c1283ac9f27b368abc1e36d1ff7b04501a30356b"
integrity sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg== integrity sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==
follow-redirects@^1.14.0:
version "1.14.4"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379"
integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==
foreach@^2.0.5: foreach@^2.0.5:
version "2.0.5" version "2.0.5"
resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99"