Merge branch 'master' of github.com:vercel/commerce into outgrow-reaction-commerce-provider

Signed-off-by: Loan Laux <loan@outgrow.io>
This commit is contained in:
Loan Laux
2021-06-14 11:31:52 +04:00
190 changed files with 34523 additions and 1653 deletions

View File

@@ -0,0 +1,12 @@
import * as mutation from './mutations'
import { CheckoutCustomerAttach } from '../schema'
export const checkoutAttach = async (fetch: any, { variables, headers }: any): Promise<CheckoutCustomerAttach> => {
const data = await fetch({
query: mutation.CheckoutAttach,
variables,
headers,
})
return data
}

View File

@@ -0,0 +1,25 @@
import Cookies from 'js-cookie'
import * as mutation from './mutations'
import { CheckoutCreate } from '../schema'
import { CHECKOUT_ID_COOKIE } from '@framework/const'
export const checkoutCreate = async (fetch: any): Promise<CheckoutCreate> => {
const data = await fetch({ query: mutation.CheckoutCreate })
const checkout = data.checkoutCreate?.checkout
const checkoutId = checkout?.id
const checkoutToken = checkout?.token
const value = `${checkoutId}:${checkoutToken}`
if (checkoutId) {
const options = {
expires: 60 * 60 * 24 * 30,
}
Cookies.set(CHECKOUT_ID_COOKIE, value, options)
}
return checkout
}
export default checkoutCreate

View File

@@ -0,0 +1,48 @@
import { Cart } from '../types'
import { CommerceError } from '@commerce/utils/errors'
import {
CheckoutLinesAdd,
CheckoutLinesUpdate,
CheckoutCreate,
CheckoutError,
Checkout,
Maybe,
CheckoutLineDelete,
} from '../schema'
import { normalizeCart } from './normalize'
import throwUserErrors from './throw-user-errors'
export type CheckoutQuery = {
checkout: Checkout
errors?: Array<CheckoutError>
}
export type CheckoutPayload =
| CheckoutLinesAdd
| CheckoutLinesUpdate
| CheckoutCreate
| CheckoutQuery
| CheckoutLineDelete
const checkoutToCart = (checkoutPayload?: Maybe<CheckoutPayload>): Cart => {
if (!checkoutPayload) {
throw new CommerceError({
message: 'Missing checkout payload from response',
})
}
const checkout = checkoutPayload?.checkout
throwUserErrors(checkoutPayload?.errors)
if (!checkout) {
throw new CommerceError({
message: 'Missing checkout object from response',
})
}
return normalizeCart(checkout)
}
export default checkoutToCart

View File

@@ -0,0 +1,25 @@
import Cookies, { CookieAttributes } from 'js-cookie'
import * as Const from '../const'
export const getToken = () => Cookies.get(Const.SALEOR_TOKEN)
export const setToken = (token?: string, options?: CookieAttributes) => {
setCookie(Const.SALEOR_TOKEN, token, options)
}
export const getCSRFToken = () => Cookies.get(Const.SALEOR_CRSF_TOKEN)
export const setCSRFToken = (token?: string, options?: CookieAttributes) => {
setCookie(Const.SALEOR_CRSF_TOKEN, token, options)
}
export const getCheckoutToken = () => Cookies.get(Const.CHECKOUT_ID_COOKIE)
export const setCheckoutToken = (token?: string, options?: CookieAttributes) => {
setCookie(Const.CHECKOUT_ID_COOKIE, token, options)
}
const setCookie = (name: string, token?: string, options?: CookieAttributes) => {
if (!token) {
Cookies.remove(name)
} else {
Cookies.set(name, token, options ?? { expires: 60 * 60 * 24 * 30 })
}
}

View File

@@ -0,0 +1,49 @@
export const CheckoutDetails = /* GraphQL */ `
fragment CheckoutDetails on Checkout {
id
token
created
totalPrice {
currency
gross {
amount
}
}
subtotalPrice {
currency
gross {
amount
}
}
lines {
id
variant {
id
name
sku
product {
name
slug
}
media {
url
}
pricing {
price {
gross {
amount
}
}
}
}
quantity
totalPrice {
currency
gross {
amount
}
}
}
}
`

View File

@@ -0,0 +1,2 @@
export { ProductConnection } from './product'
export { CheckoutDetails } from './checkout-details'

View File

@@ -0,0 +1,29 @@
export const ProductConnection = /* GraphQL */ `
fragment ProductConnection on ProductCountableConnection {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
node {
id
name
description
slug
pricing {
priceRange {
start {
net {
amount
}
}
}
}
media {
url
alt
}
}
}
}
`

View File

@@ -0,0 +1,23 @@
import { Category } from '@commerce/types/site'
import { SaleorConfig } from '../api'
import { CollectionCountableEdge } from '../schema'
import * as query from './queries'
const getCategories = async (config: SaleorConfig): Promise<Category[]> => {
const { data } = await config.fetch(query.CollectionMany, {
variables: {
first: 100,
},
})
return (
data.collections?.edges?.map(({ node: { id, name, slug } }: CollectionCountableEdge) => ({
id,
name,
slug,
path: `/${slug}`,
})) ?? []
)
}
export default getCategories

View File

@@ -0,0 +1,9 @@
import Cookies from 'js-cookie'
import { CHECKOUT_ID_COOKIE } from '../const'
const getCheckoutId = (id?: string) => {
const r = Cookies.get(CHECKOUT_ID_COOKIE)?.split(':') || []
return { checkoutId: r[0], checkoutToken: r[1] }
}
export default getCheckoutId

View File

@@ -0,0 +1,18 @@
import { getSortVariables } from './get-sort-variables'
import type { SearchProductsInput } from '../product/use-search'
export const getSearchVariables = ({ brandId, search, categoryId, sort }: SearchProductsInput) => {
const sortBy = {
field: 'NAME',
direction: 'ASC',
...getSortVariables(sort, !!categoryId),
channel: 'default-channel',
}
return {
categoryId,
filter: { search },
sortBy,
}
}
export default getSearchVariables

View File

@@ -0,0 +1,30 @@
export const getSortVariables = (sort?: string, isCategory: boolean = false) => {
let output = {}
switch (sort) {
case 'price-asc':
output = {
field: 'PRICE',
direction: 'ASC',
}
break
case 'price-desc':
output = {
field: 'PRICE',
direction: 'DESC',
}
break
case 'trending-desc':
output = {
field: 'RANK',
direction: 'DESC',
}
break
case 'latest-desc':
output = {
field: 'DATE',
direction: 'DESC',
}
break
}
return output
}

View File

@@ -0,0 +1,41 @@
import { SaleorConfig } from '../api'
export type Brand = {
entityId: string
name: string
path: string
}
export type BrandEdge = {
node: Brand
}
export type Brands = BrandEdge[]
// TODO: Find a way to get vendors from meta
const getVendors = async (config: SaleorConfig): Promise<BrandEdge[]> => {
// const vendors = await fetchAllProducts({
// config,
// query: getAllProductVendors,
// variables: {
// first: 100,
// },
// })
// let vendorsStrings = vendors.map(({ node: { vendor } }) => vendor)
// return [...new Set(vendorsStrings)].map((v) => {
// const id = v.replace(/\s+/g, '-').toLowerCase()
// return {
// node: {
// entityId: id,
// name: v,
// path: `brands/${id}`,
// },
// }
// })
return []
}
export default getVendors

View File

@@ -0,0 +1,27 @@
import { FetcherError } from '@commerce/utils/errors'
export function getError(errors: any[], status: number) {
errors = errors ?? [{ message: 'Failed to fetch Saleor API' }]
return new FetcherError({ errors, status })
}
export async function getAsyncError(res: Response) {
const data = await res.json()
return getError(data.errors, res.status)
}
const handleFetchResponse = async (res: Response) => {
if (res.ok) {
const { data, errors } = await res.json()
if (errors && errors.length) {
throw getError(errors, res.status)
}
return data
}
throw await getAsyncError(res)
}
export default handleFetchResponse

View File

@@ -0,0 +1,35 @@
import { FetcherOptions } from '@commerce/utils/types'
import { CreateToken, Mutation, MutationTokenCreateArgs } from '../schema'
import { setToken, setCSRFToken } from './customer-token'
import * as mutation from './mutations'
import throwUserErrors from './throw-user-errors'
const handleLogin = (data: CreateToken) => {
throwUserErrors(data?.errors)
const token = data?.token
if (token) {
setToken(token)
setCSRFToken(token)
}
return token
}
export const handleAutomaticLogin = async (
fetch: <T = any, B = Body>(options: FetcherOptions<B>) => Promise<T>,
input: MutationTokenCreateArgs
) => {
try {
const { tokenCreate } = await fetch<Mutation, MutationTokenCreateArgs>({
query: mutation.SessionCreate,
variables: { ...input },
})
handleLogin(tokenCreate!)
} catch (error) {
//
}
}
export default handleLogin

View File

@@ -0,0 +1,19 @@
export { getSortVariables } from './get-sort-variables'
export { default as handleFetchResponse } from './handle-fetch-response'
export { default as getSearchVariables } from './get-search-variables'
export { default as getVendors } from './get-vendors'
export { default as getCategories } from './get-categories'
export { default as getCheckoutId } from './get-checkout-id'
export { default as checkoutCreate } from './checkout-create'
export { checkoutAttach } from './checkout-attach'
export { default as checkoutToCart } from './checkout-to-cart'
export { default as handleLogin, handleAutomaticLogin } from './handle-login'
export { default as throwUserErrors } from './throw-user-errors'
export * from './queries'
export * from './mutations'
export * from './normalize'
export * from './customer-token'

View File

@@ -0,0 +1,15 @@
export const AccountCreate = /* GraphQL */ `
mutation AccountCreate($input: AccountRegisterInput!) {
accountRegister(input: $input) {
errors {
code
field
message
}
user {
email
isActive
}
}
}
`

View File

@@ -0,0 +1,12 @@
export const CheckoutAttach = /* GraphQl */ `
mutation CheckoutAttach($checkoutId: ID!) {
checkoutCustomerAttach(checkoutId: $checkoutId) {
errors {
message
}
checkout {
id
}
}
}
`

View File

@@ -0,0 +1,17 @@
import * as fragment from '../fragments'
export const CheckoutCreate = /* GraphQL */ `
mutation CheckoutCreate {
checkoutCreate(input: { email: "customer@example.com", lines: [], channel: "default-channel" }) {
errors {
code
field
message
}
checkout {
...CheckoutDetails
}
}
}
${fragment.CheckoutDetails}
`

View File

@@ -0,0 +1,17 @@
import * as fragment from '../fragments'
export const CheckoutLineAdd = /* GraphQL */ `
mutation CheckoutLineAdd($checkoutId: ID!, $lineItems: [CheckoutLineInput!]!) {
checkoutLinesAdd(checkoutId: $checkoutId, lines: $lineItems) {
errors {
code
field
message
}
checkout {
...CheckoutDetails
}
}
}
${fragment.CheckoutDetails}
`

View File

@@ -0,0 +1,17 @@
import * as fragment from '../fragments'
export const CheckoutLineDelete = /* GraphQL */ `
mutation CheckoutLineDelete($checkoutId: ID!, $lineId: ID!) {
checkoutLineDelete(checkoutId: $checkoutId, lineId: $lineId) {
errors {
code
field
message
}
checkout {
...CheckoutDetails
}
}
}
${fragment.CheckoutDetails}
`

View File

@@ -0,0 +1,17 @@
import * as fragment from '../fragments'
export const CheckoutLineUpdate = /* GraphQL */ `
mutation CheckoutLineUpdate($checkoutId: ID!, $lineItems: [CheckoutLineInput!]!) {
checkoutLinesUpdate(checkoutId: $checkoutId, lines: $lineItems) {
errors {
code
field
message
}
checkout {
...CheckoutDetails
}
}
}
${fragment.CheckoutDetails}
`

View File

@@ -0,0 +1,8 @@
export { AccountCreate } from './account-create'
export { CheckoutCreate } from './checkout-create'
export { CheckoutLineAdd } from './checkout-line-add'
export { CheckoutLineUpdate } from './checkout-line-update'
export { CheckoutLineDelete } from './checkout-line-remove'
export { SessionCreate } from './session-create'
export { SessionDestroy } from './session-destroy'
export { CheckoutAttach } from './checkout-attach'

View File

@@ -0,0 +1,14 @@
export const SessionCreate = /* GraphQL */ `
mutation SessionCreate($email: String!, $password: String!) {
tokenCreate(email: $email, password: $password) {
token
refreshToken
csrfToken
errors {
code
field
message
}
}
}
`

View File

@@ -0,0 +1,10 @@
export const SessionDestroy = /* GraphQL */ `
mutation SessionDestroy {
tokensDeactivateAll {
errors {
field
message
}
}
}
`

View File

@@ -0,0 +1,133 @@
import { Product } from '@commerce/types/product'
import { Product as SaleorProduct, Checkout, CheckoutLine, Money, ProductVariant } from '../schema'
import type { Cart, LineItem } from '../types'
// TODO: Check nextjs-commerce bug if no images are added for a product
const placeholderImg = '/product-img-placeholder.svg'
const money = ({ amount, currency }: Money) => {
return {
value: +amount,
currencyCode: currency || 'USD',
}
}
const normalizeProductOptions = (options: ProductVariant[]) => {
return options
?.map((option) => option?.attributes)
.flat(1)
.reduce<any>((acc, x) => {
if (acc.find(({ displayName }: any) => displayName === x.attribute.name)) {
return acc.map((opt: any) => {
return opt.displayName === x.attribute.name
? {
...opt,
values: [
...opt.values,
...x.values.map((value: any) => ({
label: value?.name,
})),
],
}
: opt
})
}
return acc.concat({
__typename: 'MultipleChoiceOption',
displayName: x.attribute.name,
variant: 'size',
values: x.values.map((value: any) => ({
label: value?.name,
})),
})
}, [])
}
const normalizeProductVariants = (variants: ProductVariant[]) => {
return variants?.map((variant) => {
const { id, sku, name, pricing } = variant
const price = pricing?.price?.net && money(pricing.price.net)?.value
return {
id,
name,
sku: sku ?? id,
price,
listPrice: price,
requiresShipping: true,
options: normalizeProductOptions([variant]),
}
})
}
export function normalizeProduct(productNode: SaleorProduct): Product {
const { id, name, media = [], variants, description, slug, pricing, ...rest } = productNode
const product = {
id,
name,
vendor: '',
description: description ? JSON.parse(description)?.blocks[0]?.data.text : '',
path: `/${slug}`,
slug: slug?.replace(/^\/+|\/+$/g, ''),
price: (pricing?.priceRange?.start?.net && money(pricing.priceRange.start.net)) || {
value: 0,
currencyCode: 'USD',
},
// TODO: Check nextjs-commerce bug if no images are added for a product
images: media?.length ? media : [{ url: placeholderImg }],
variants: variants && variants.length > 0 ? normalizeProductVariants(variants as ProductVariant[]) : [],
options: variants && variants.length > 0 ? normalizeProductOptions(variants as ProductVariant[]) : [],
...rest,
}
return product as Product
}
export function normalizeCart(checkout: Checkout): Cart {
const lines = checkout.lines as CheckoutLine[]
const lineItems: LineItem[] = lines.length > 0 ? lines?.map<LineItem>(normalizeLineItem) : []
return {
id: checkout.id,
customerId: '',
email: '',
createdAt: checkout.created,
currency: {
code: checkout.totalPrice?.currency!,
},
taxesIncluded: false,
lineItems,
lineItemsSubtotalPrice: checkout.subtotalPrice?.gross?.amount!,
subtotalPrice: checkout.subtotalPrice?.gross?.amount!,
totalPrice: checkout.totalPrice?.gross.amount!,
discounts: [],
}
}
function normalizeLineItem({ id, variant, quantity }: CheckoutLine): LineItem {
return {
id,
variantId: String(variant?.id),
productId: String(variant?.id),
name: `${variant.product.name}`,
quantity,
variant: {
id: String(variant?.id),
sku: variant?.sku ?? '',
name: variant?.name!,
image: {
url: variant?.media![0] ? variant?.media![0].url : placeholderImg,
},
requiresShipping: false,
price: variant?.pricing?.price?.gross.amount!,
listPrice: 0,
},
path: String(variant?.product?.slug),
discounts: [],
options: [],
}
}

View File

@@ -0,0 +1,12 @@
import * as fragment from '../fragments'
export const CheckoutOne = /* GraphQL */ `
query CheckoutOne($checkoutId: UUID!) {
checkout(token: $checkoutId) {
... on Checkout {
...CheckoutDetails
}
}
}
${fragment.CheckoutDetails}
`

View File

@@ -0,0 +1,13 @@
export const CollectionMany = /* GraphQL */ `
query CollectionMany($first: Int!, $channel: String = "default-channel") {
collections(first: $first, channel: $channel) {
edges {
node {
id
name
slug
}
}
}
}
`

View File

@@ -0,0 +1,13 @@
import * as fragment from '../fragments'
export const CollectionOne = /* GraphQL */ `
query getProductsFromCollection($categoryId: ID!, $first: Int = 100, $channel: String = "default-channel") {
collection(id: $categoryId, channel: $channel) {
id
products(first: $first) {
...ProductConnection
}
}
}
${fragment.ProductConnection}
`

View File

@@ -0,0 +1,11 @@
export const CustomerCurrent = /* GraphQL */ `
query CustomerCurrent {
me {
id
email
firstName
lastName
dateJoined
}
}
`

View File

@@ -0,0 +1,7 @@
export const CustomerOne = /* GraphQL */ `
query CustomerOne($customerAccessToken: String!) {
customer(customerAccessToken: $customerAccessToken) {
id
}
}
`

View File

@@ -0,0 +1,16 @@
export const getAllProductVendors = /* GraphQL */ `
query getAllProductVendors($first: Int = 250, $cursor: String) {
products(first: $first, after: $cursor) {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
node {
vendor
}
cursor
}
}
}
`

View File

@@ -0,0 +1,16 @@
export const getAllProductsPathsQuery = /* GraphQL */ `
query getAllProductPaths($first: Int = 100, $cursor: String, $channel: String = "default-channel") {
products(first: $first, after: $cursor, channel: $channel) {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
node {
slug
}
cursor
}
}
}
`

View File

@@ -0,0 +1,14 @@
export { CollectionMany } from './collection-many'
export { ProductOneBySlug } from './product-one-by-slug'
export { ProductMany } from './product-many'
export { CollectionOne } from './collection-one'
export { CheckoutOne } from './checkout-one'
export { PageMany } from './page-many'
export { PageOne } from './page-one'
export { CustomerCurrent } from './customer-current'
// getCustomerIdQuery
export { CustomerOne } from './customer-one'
export { getAllProductsPathsQuery } from './get-all-products-paths-query'
export { getAllProductVendors } from './get-all-product-vendors-query'

View File

@@ -0,0 +1,13 @@
export const PageMany = /* GraphQL */ `
query PageMany($first: Int = 100) {
pages(first: $first) {
edges {
node {
id
title
slug
}
}
}
}
`

View File

@@ -0,0 +1,9 @@
export const PageOne = /* GraphQL */ `
query PageOne($id: ID!) {
page(id: $id) {
id
title
slug
}
}
`

View File

@@ -0,0 +1,15 @@
import * as fragment from '../fragments'
export const ProductMany = /* GraphQL */ `
query ProductMany(
$first: Int = 100
$filter: ProductFilterInput
$sortBy: ProductOrder
$channel: String = "default-channel"
) {
products(first: $first, channel: $channel, filter: $filter, sortBy: $sortBy) {
...ProductConnection
}
}
${fragment.ProductConnection}
`

View File

@@ -0,0 +1,43 @@
export const ProductOneBySlug = /* GraphQL */ `
query ProductOneBySlug($slug: String!, $channel: String = "default-channel") {
product(slug: $slug, channel: $channel) {
id
slug
name
description
pricing {
priceRange {
start {
net {
amount
}
}
}
}
variants {
id
name
attributes {
attribute {
name
}
values {
name
}
}
pricing {
price {
net {
amount
currency
}
}
}
}
media {
url
alt
}
}
}
`

View File

@@ -0,0 +1,20 @@
import { ValidationError } from '@commerce/utils/errors'
import { CheckoutError, CheckoutErrorCode, AppError, AccountError, AccountErrorCode } from '../schema'
export type UserErrors = Array<CheckoutError | AccountError | AppError>
export type UserErrorCode = CheckoutErrorCode | AccountErrorCode | null | undefined
export const throwUserErrors = (errors?: UserErrors) => {
if (errors && errors.length) {
throw new ValidationError({
errors: errors.map(({ code, message }) => ({
code: code ?? 'validation_error',
message: message || '',
})),
})
}
}
export default throwUserErrors